Compare commits

..

1 commit

Author SHA1 Message Date
Francisco Solla
27ad9bfb02 plans(S5): separação visual da seção Acompanhe de perto (Related #33)
All checks were successful
CI (PR) / docs-guards (pull_request) Successful in 8s
Agent pool supervisor / tick (pull_request) Successful in 32s
CI (PR) / int (pull_request) Has been skipped
CI (PR) / checks (pull_request) Successful in 2s
CI (PR) / plans-only-closes (pull_request) Successful in 7s
CI (PR) / format (pull_request) Successful in 1m5s
CI (PR) / build (pull_request) Has been skipped
Promote plan-issue Related on main merge / plan-issue-ready (pull_request) Successful in 21s
CI (PR) / scope (pull_request) Successful in 35s
CI (PR) / e2e (1) (pull_request) Has been skipped
CI (PR) / e2e (2) (pull_request) Has been skipped
CI (PR) / typecheck (pull_request) Has been skipped
CI (PR) / knip (pull_request) Has been skipped
CI (PR) / cycles (pull_request) Has been skipped
CI (PR) / unit (pull_request) Has been skipped
Agent PR Ready + auto-merge / ready-automerge (pull_request) Has been skipped
CI (PR) / lint (pull_request) Successful in 46s
Main merge flips claimed issues to done + in-prod / issue-done (pull_request) Successful in 14s
2026-08-18 07:55:33 -03:00
341 changed files with 1860 additions and 118478 deletions

View file

@ -1,90 +1,83 @@
---
description: PRs de agente — Ready (nunca draft), base main, auto-merge nativo do GitHub
description: PRs de agente — Ready (nunca draft), base main, auto-merge imediato
alwaysApply: true
---
# Agent PR workflow (Teqo)
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + GitHub's native auto-merge (OPS71). The ISSUE TRACKER stays on the Forgejo — GitHub is only the Actions host.
**This repo overrides any user-level or Cloud-tool default that says "create PRs as draft".** Agent deliveries merge to `main` via required checks + auto-merge.
## Create (Ready, base main)
```bash
pnpm push -u origin HEAD # origin = GitHub (cutover OPS71)
```
Depois, abra a PR no **GitHub** — **Ready**, base **`main`**, title `<id> — <title>`, body:
```
pnpm push -u origin HEAD
gh pr create --base main --title "<id> — <title>" --body "$(cat <<'EOF'
## O que é
<2–4 linhas>
Closes #<N>
EOF
)"
```
- **Never** `--draft`. PRs must be **Ready for review** on creation.
- Base is always **`main`**.
- **Plans-only PRs** (diff só em `docs/plans/`): **nunca** `Closes #N` — use `Related #N`; CI falha com keywords de fechamento (`plans-only-closes` em `ci-pr.yml`).
- PR via CLI (não há `gh` — OPS50): `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<t>" --body-file <file>` — Ready por construção (não existe flag draft).
### Cursor Cloud / `ManagePullRequest`
When the environment says to use `ManagePullRequest` instead of `gh pr create`:
- **`draft: false`** (or omit only if the tool’s default is Ready — today Cloud defaults to draft, so **always pass `draft: false`**).
- Não há auto-merge a armar por PR: o safety net (abaixo) arma o auto-merge nativo.
- Never end a turn with an open agent PR left as **Draft** — inclui PRs do `engineering-audit` (artefatos e remediações).
- Right after create (or on `update_pr` if you opened draft by mistake): mark Ready if still draft, then enable auto-merge with `gh pr merge --auto --rebase <url|number>` (write allowed for this step — same as `agent-pr-workflow`; do not leave the PR waiting on a human).
- Never end a turn with an open agent PR left as **Draft** or without **auto-merge** armed — inclui PRs do `engineering-audit` (artefatos e remediações).
## Push (determinístico)
**Use `pnpm push`, never bare `git push`.** Não depende do Husky estar wired:
1. `ensure-repo-deps` — `pnpm install` se `node_modules` faltar/stale + `pnpm run prepare` (Husky) se hooks não estiverem apontando para `.husky`
2. `pnpm gate:push` — espelho do `ci-pr.yml` sem e2e (OPS72: o e2e é passo **explícito da skill** — antes do push rode localmente os e2e que criou + os da mesma superfície, discricionário via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected`, e o full fica no `verify` do deploy manual), com os `docs-guards` locais (OPS63: changelog append-only, agregado sync e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR)
2. `pnpm gate:push` — espelho do `ci-pr.yml` (inclui `format:check`)
3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch)
```bash
pnpm push -u origin HEAD # canonical — origin é o GitHub (OPS71)
pnpm push -u origin HEAD # canonical
git push # só depois de `pnpm i` — hook repete ensure-deps + gate:push
git push --no-verify # WIP / cutover escape — CI repete tudo; ver AGENT-OPS
```
## Auto-merge immediately
The safety net (`agent-pr-ready-automerge.yml`, abaixo) arms GitHub's **native auto-merge** (rebase) — the server merges the PR when the required check passes. There is nothing to arm per PR. Do not wait for human approval on normal deliveries.
Right after the PR exists and is **Ready**:
## Required checks
```bash
gh pr merge --auto --rebase
```
Branch protection on `main` (GitHub, applied at the OPS71 cutover via `pnpm configure:branch-protection`) requires only:
Do not wait for human approval on normal deliveries. Auto-merge fires when **required** checks pass.
- `CI (PR) / checks` — the ci-pr single-job check-run (OPS62: one sequential job posts one honest verdict; no matrix, no shards, no rollup to lie). **Literal de match na proteção: `checks`** (o GitHub casa pelo nome do check-run/job — a UI exibe `CI (PR) / checks`; pin ao vivo PR #742).
## Required checks vs Vercel
OPS64 history: the Forgejo runner could report the LAST matrix shard's result as the parent's (runs 604/607/610) — that bug is gone by construction: ci-pr.yml is a SINGLE sequential job, and GitHub's native auto-merge only merges with every required check green (server-side guarantee). The safety-net CLI has no poll path to port.
Branch protection on `main` requires only:
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR.)
- `checks` (GitHub Actions rollup from `ci-pr.yml`)
**There is no deploy from PR CI.** Publishing is a **manual `workflow_dispatch`** (`.github/workflows/deploy.yml`): `verify` runs the full suite (hosted) and only then the `deploy` job (self-hosted runner on the homeserver) publishes `jorgesolla1313.com.br` — migrations applied before the rollout. Do not add a Vercel/Git-integration deploy gate.
(`migration-lock` removed 2026-08-12 — the CI validates migration chains via migrate + int on every PR/main.)
When watching CI, use the PR page (or the API) — the safety net arms the server-side auto-merge; `gh` does not exist in this repo (OPS50).
**There is no deploy from CI.** The `ci.yml` verifier on `main` runs the full suite and stops (production is self-hosted on the homeserver — `jorgesolla1313.com.br` — deployed via an ops runbook outside this repo). Do not add a Vercel/Git-integration deploy gate.
Use the required check `CI (PR) / checks` — without it, you are not covering the cascade.
When watching CI:
```bash
gh pr checks <PR> --watch --required
```
Use `--required` — without it, `--watch` also waits for Vercel.
## Promote
There is no stage→main promote. Merging to `main` is the delivery; publishing is a deliberate manual dispatch with full verification (runbook: `docs/ops/teqo-1313-deploy.md`).
There is no stage→main promote. Merging to `main` is the delivery; `ci.yml` verifies the full suite and production deploys from the homeserver runbook.
## Safety net (workflow)
## Safety net (GitHub Action)
`agent-pr-ready-automerge.yml` — PRs same-repo → `main` (todas as branches).
O workflow roda `scripts/github-pr-automerge.mjs` (plain Node, zero-dep,
REST+GraphQL): se draft `cursor/*` → marca Ready (PATCH `draft: false`); arm
`enablePullRequestAutoMerge` (GraphQL, mergeMethod REBASE) no `node_id` do PR
— o **servidor** espera o required check `CI (PR) / checks` POSTADO e verde
antes de mergear; o próprio check-run do safety net fica `pending` durante o
run e não interfere. A regra de branch protection no servidor
(`enforce_admins: true`) bloqueia qualquer caminho de merge — inclusive API
manual — enquanto o required check não estiver satisfeito (foi um merge
manual que colocou a PR #52 em main com o CI vermelho na era Forgejo).
Cobre o caso em que o
agente (ou o default draft do Cloud) esqueceu e PRs humanas/worktree com merge
falho silencioso (incidente OPS52). **Draft de branch não-`cursor/*` nunca é
tocado** — o draft é o veto do ator (skip do CLI). Não há opt-out por skill
(audit incluído).
`agent-pr-ready-automerge.yml` — PRs `cursor/*` → `main`: se Draft → `gh pr ready`; sempre arma `gh pr merge --auto --rebase`. Cobre o caso em que o agente (ou o default draft do Cloud) esqueceu. Não há opt-out por skill (audit incluído).

View file

@ -11,7 +11,7 @@ Established 2026-07-23 by the engineering-standards hardening (tracker: `docs/IM
1. **Dead code dies immediately.** After your edits, check what your change orphaned and delete it in the same delivery: run `pnpm exec knip` (files/dependencies are CI-blocking errors; unused exports are warnings being driven to zero). Never leave commented-out code, unused exports, superseded components, or one-shot scripts behind — git history is the archive.
2. **Zero warnings.** `pnpm lint` runs with `--max-warnings=0`. Deliberate placeholders use a `_` prefix. Never silence a rule without an inline justification comment.
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml` **sem e2e** — OPS72: o e2e é passo **explícito da skill** (`work-issue`/`agent-work-issue`): antes do push rode localmente os e2e que criou + os da mesma superfície, **discricionário** via `pnpm test:e2e:affected`; o CI de PR roda só o blast radius `selected` — nunca `full` — e o full fica no `verify` do deploy manual; **com os `docs-guards` locais** — OPS63: changelog append-only, agregado sync e marcadores de conflito, mesmos scripts do CI; o escape `changelog-rewrite:` fica só no CI, body do PR). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; publish gated pelo `verify` do `deploy.yml` (dispatch manual — OPS71; não há `ci.yml` verificador de main). **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
3. **Gate em duas velocidades (desde 2026-07-30, paradigma de agentes paralelos — `docs/AGENT-OPS.md`).** **Fast gate local do agente** (iteração durante a sessão): `pnpm gate:fast` (lint + typecheck + test:unit). **Push canônico:** `pnpm push` — `ensure-repo-deps` (install + Husky em worktrees) + `git push`; o hook pre-push roda `gate:push` → `gate:ci` (espelho serial do `ci-pr.yml`, inclui `format:check`). Não use `git push` nu. Fonte única: scripts `gate:*` e `scripts/git-push.mjs` em `package.json`; escape para WIP: `git push --no-verify`. Migrations NÃO são serializadas entre PRs: a cadeia é validada por `pnpm migrate` + `test:int` (incl. `campaignMigrationReconciliation`) em todo PR e na main; rebase antes de `migrate:create` continua obrigatório (o antigo `migration-lock` foi removido em 2026-08-12 — deadlock de CI). Entrega = merge em `main`; deploy gated pelo verificador `ci.yml`. **CI vermelho no teu PR é teu, mesmo pré-existente ou de infra** — "fora do escopo da feature" não é critério de parada (política "Dono do PR, dono do CI" em `docs/AGENT-OPS.md`).
4. **Run gate commands bare, never piped** (`pnpm test | tail` swallows the exit code). Never run `knip --fix` blind — verify each removal with `git grep -w <symbol>` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3).
## Type honesty

View file

@ -2,7 +2,7 @@
name: agent-pool
description: >-
Opera e monitora o pool de agentes Cursor Cloud do Teqo (supervisor remoto
determinístico em Forgejo Actions). Ao invocar: SEMPRE status + fila elegível
determinístico em GitHub Actions). Ao invocar: SEMPRE status + fila elegível
primeiro; NUNCA liga o pool sem confirmação explícita do usuário. Usar quando
o usuário pedir /agent-pool, ligar/desligar/pausar o pool, ver status dos
workers, "pool de agentes", orquestrador, triage de Issue que o pool mandou
@ -11,11 +11,7 @@ description: >-
# Agent pool — operação do supervisor remoto
**DORMENTE (OPS65):** o workflow `agent-pool.yml` foi removido (o tick `*/10`
custava 144 `pnpm install`/dia na workstation) e o dispatch via CLI falha 404 —
scripts/skill ficam para histórico e leitura. O que foi:
O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 12) rodando `agent-work-issue` sobre Issues `ready` elegíveis para autonomia. O supervisor é **determinístico** (não é um agente): workflow `.forgejo/workflows/agent-pool.yml` (removido) com tick stateless a cada 10 min + a cada merge em `main` + sob dispatch, rodando `scripts/agent-pool.mjs`. Arquitetura e elegibilidade: `docs/plans/agent-pool-orchestrator.md`.
O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 12) rodando `agent-work-issue` sobre Issues `ready` elegíveis para autonomia. O supervisor é **determinístico** (não é um agente): workflow **`.github/workflows/agent-pool.yml`** (tick stateless a cada 10 min + a cada merge em `main` + sob dispatch) rodando `scripts/agent-pool.mjs`. Arquitetura e elegibilidade: `docs/plans/agent-pool-orchestrator.md`.
**O pool NUNCA deploya** — deploy gated fica em `ci.yml` após merge em `main`.
@ -23,7 +19,7 @@ O pool mantém até **5 Cursor Cloud Agents** (configurável, máx 12) rodando `
## Fluxo obrigatório ao invocar (não pule)
**Nunca** rode `pnpm agent:pool -- start` no primeiro passo — e o dispatch do workflow não existe mais (OPS65: `start`/`stop` via CLI falham 404). O default é só leitura.
**Nunca** rode `pnpm agent:pool -- start` (nem `gh workflow run … action=start`) no primeiro passo. O default é só leitura.
```
- [ ] 1. Pré-check leve: `pnpm agent:pool -- doctor` se for a 1ª vez na sessão / suspeita de secret; senão pode ir direto ao status
@ -91,7 +87,7 @@ pnpm agent:pool -- resume
pnpm agent:pool -- status # leitura local (config, slots, fila, exclusões)
```
Os wrappers disparam `workflow_dispatch` em `agent-pool.yml` via API do Forgejo (workflow removido no OPS65 — dispatch falha 404); acompanhe com `pnpm agent:pool -- status` (o job summary de cada run era a superfície remota de status: slots, spawns, falhas, decisão do tick). Estado escalar vive em `pool-state.json` na branch `pool-state`; workers/Issues em voo são derivados — Issues `in-progress` com marcador `pool-worker` nos comentários.
Os wrappers disparam `gh workflow run agent-pool.yml -f action=…`; acompanhe com `gh run list --workflow agent-pool.yml --limit 3` (o job summary de cada run é a superfície remota de status: slots, spawns, falhas, decisão do tick). Estado escalar vive em repo variables `POOL_*` (Settings → Variables); workers/Issues em voo são derivados — Issues `in-progress` com marcador `pool-worker` nos comentários.
## Ciclo de vida de uma Issue no pool

View file

@ -33,9 +33,9 @@ Conduz **uma** Issue já claimada do plano de implementação ao merge em `main`
- [ ] 1. Sessão: rename_chat + open_resource na intenção
- [ ] 2. Modelo: spawn do pool já fixou (`model:` da Issue) — sem comparação
- [ ] 3. Plan mode → plano de implementação (`*-impl.md`)
- [ ] 4. Executar o impl plan (schema/server → UI → e2e local afetado → gates)
- [ ] 4. Executar o impl plan (schema/server → UI → gates)
- [ ] 5. /simplify → fixes; capture-review-debts (modo autônomo)
- [ ] 6. PR Ready `--base main` (GitHub) + auto-merge nativo — `pnpm push -u origin HEAD` → `node scripts/github-pr.mjs` (ou `ManagePullRequest` `draft: false` no Cloud)
- [ ] 6. PR Ready `--base main` + auto-merge + checks --required
```
## Passo 0 — Prep
@ -79,20 +79,13 @@ O spawn do pool já fixou o modelo (`model:` da Issue — ver `model-selection`)
(executar → simplify → fechar em main), com os deltas do ator pool:
- **Branch:** `agent/<id>-<slug>` (worktrees Cursor podem já ter criado).
- **E2E local afetado (OPS72):** mesmo passo discricionário do ator humano
(rodar os e2e criados + da mesma superfície — ver `execution-pipeline.md`);
**worktree com browsers Playwright** → rode; **Cursor Cloud sem browsers** →
registre a justificativa no fechamento e siga — o CI de PR roda o blast
radius quando o diff classifica `selected`; em diff high-risk (`full`) o PR
não roda e2e e a rede final é o `verify` do deploy.
- **UI:** shape → craft → critique → polish (harden/optimize só sob gatilho).
- **`capture-review-debts`:** **autônomo** — só `expensive_lock` com score ≥4
(Issues novas com `depends: [<id-pai>]` se necessário); score ≤3 /
cheap_polish / defer_trigger → defer com gatilho no `*-impl.md` ou descarte —
**não** polua a fila.
- **Cloud:** PR via `ManagePullRequest` com `draft: false` no **GitHub**
(repo/PR agora no GitHub; o tracker de Issues segue no Forgejo — OPS71);
Prep Cloud no Passo 0.
- **Cloud:** PR via `ManagePullRequest` com `draft: false`; Prep Cloud no
Passo 0.
## Resumo final

View file

@ -1,6 +1,6 @@
---
name: capture-review-debts
description: Use when the user asks to register leftover /simplify or /impeccable findings as trackable Forgejo Issues, harvest session review debts, triage post-simplify or post-critique follow-ups, or says "registra os débitos", "o que ficou do simplify", "coloca como issue o que o critique apontou", "harvest debts".
description: Use when the user asks to register leftover /simplify or /impeccable findings as trackable GitHub Issues, harvest session review debts, triage post-simplify or post-critique follow-ups, or says "registra os débitos", "o que ficou do simplify", "coloca como issue o que o critique apontou", "harvest debts".
---
# Capturar débitos de /simplify e /impeccable em Issues
@ -15,7 +15,7 @@ Após um ciclo de entrega, `/simplify` e `/impeccable` deixam achados **maiores
```
- [ ] 1. Colher candidatos da sessão (simplify + impeccable)
- [ ] 2. Deduplicar contra código, Issues (`pnpm issue all`) e planos
- [ ] 2. Deduplicar contra código, Issues (gh issue list) e planos
- [ ] 3. Pontuar importância e classificar destino
- [ ] 4. Mesclar relacionados (mesmo lote / mesma superfície)
- [ ] 5. Apresentar tabela de triage e obter confirmação
@ -50,7 +50,7 @@ Contra o repositório **antes** de pontuar:
| Check | Ação se verdadeiro |
| ------------------------------------------------------ | --------------------------------------------------------------- |
| Já no diff / código da sessão | → balde **já_resolvido** (não reabrir) |
| Já em `docs/plans/*.md` ou numa Issue (grep + `pnpm issue all`) | → **absorver** no plano existente ou **descartar** como coberto |
| Já em `docs/plans/*.md` ou numa Issue (grep + `gh issue list`) | → **absorver** no plano existente ou **descartar** como coberto |
| Pré-existente em `main`, fora do escopo da entrega | → **descartar** deste lote (bug separado só se o usuário pedir) |
| Intentional em `DESIGN.md` / decisão travada | → **descartar** (ex.: field-mode mobile documentado) |
@ -144,7 +144,7 @@ Para cada lote com destino **registrar** ou **absorver**:
Não implemente as fases aqui. Execução é via `work-issue` / `agent-work-issue`, só se o usuário (ou o pipeline autônomo) seguir.
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR no GitHub `--base main` com `Closes #N` → auto-merge nativo → CI).
**Próximo no fluxo de entrega:** após a triage (confirmada ou modo autônomo), o fechamento segue o Passo 6 de `agent-work-issue` / `work-issue` (`pnpm push` → PR `--base main` com `Closes #N` → auto-merge → CI).
## Modo autônomo (`work-issue` pós-plano / `agent-work-issue` / pool)

View file

@ -5,15 +5,15 @@ description: Runs a Pass-style engineering audit of the Teqo codebase — code s
# Engineering Audit (Pass N)
**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. O **agent-pool está morto desde OPS65** (workflow removido; scripts/skills dormentes) — nada a pausar.
**Audit solitário (não paralelizável):** quando esta skill roda, o desenvolvimento paralelo **pausa** — nenhum outro agente trabalhando no repo. O agente do audit também **executa as remediações P0/P1 na mesma sessão** para trazer o projeto aos trilhos (não só registra); P2/P3 seguem o fluxo normal de ledger. Se o **agent-pool** estiver ligado, pause-o no início do audit (`gh workflow run agent-pool.yml -f action=pause`) e retome ao final (`-f action=resume`); o tick recusa spawn enquanto `POOL_PAUSED=true`. Em **Cursor Cloud** o `gh` do agente é read-only e o pause por dispatch não está disponível — vale o precheck fail-closed da seção "Modo autônomo (Cursor Cloud)".
## Modo autônomo (Cursor Cloud)
A skill roda sem supervisão humana no paradigma de agentes paralelos. Quatro diferenças em relação à execução interativa (desktop):
1. **Precheck solitário, fail-closed.** Antes de qualquer varredura: `pnpm agent:pool -- status` (read-only). Pool `desligado` ou `pausado` → segue. Pool **ligado** → o agente Cloud não consegue pausá-lo (dispatch de workflow é escrita): **pare** com o remédio nomeado ("pause o pool — `pnpm agent:pool -- pause` — e re-dispare o audit"). Nunca rodar a varredura com o pool spawnando.
1. **Precheck solitário, fail-closed.** Antes de qualquer varredura: `pnpm agent:pool -- status` (read-only). Pool `desligado` ou `pausado` → segue. Pool **ligado** → o agente Cloud não consegue pausá-lo (`gh workflow run` é escrita): **pare** com o remédio nomeado ("pause o pool — `pnpm agent:pool -- pause` — e re-dispare o audit"). Nunca rodar a varredura com o pool spawnando.
2. **Entrega = PR Ready + auto-merge.** O gate "apresentar os três artefatos antes de escrever" (passo 5) não se aplica em modo autônomo — não há humano interativo na sessão. Os artefatos são escritos na branch e o **PR Ready → `main` com auto-merge** (mesmo contrato `agent-pr-workflow`) é a entrega do plano. Fechar o PR antes do merge = rejeição (o ledger sobrevive no histórico da branch). As remediações **P0/P1 e os guardrails das misses colhidas** seguem na mesma sessão, cada um em PR próprio com gate completo + auto-merge. P2/P3 continuam só ledger/plano.
3. **Canais de PR:** fechar miss colhida = `Closes #N` no body do PR do guardrail (o flip `done`/`in-prod` no merge em `main` — keyword adjacente a cada número, `Closes #52, closes #73`, nunca `Closes #52, #73`). Em Cloud, usar `ManagePullRequest` com **`draft: false`** (o merge fica com o safety net `agent-pr-ready-automerge`). **Sem exceção:** o PR dos *artefatos* do audit também é Ready + auto-merge (igual remediações P0/P1 e guardrails). Push: `pnpm push -u origin HEAD`.
3. **Canais de PR:** fechar miss colhida = `Closes #N` no body do PR do guardrail (o flip `done`/`in-prod` no merge em `main` — keyword adjacente a cada número, `Closes #52, closes #73`, nunca `Closes #52, #73`). Em Cloud, preferir `ManagePullRequest` com **`draft: false`**, depois `gh pr merge --auto --rebase`. **Sem exceção:** o PR dos *artefatos* do audit também é Ready + auto-merge (igual remediações P0/P1 e guardrails). Push: `pnpm push -u origin HEAD`.
4. **Delta desde o último audit é o foco declarado** (passo 1): a varredura cobre o repo inteiro, mas o esforço concentra-se no churn desde a data do último Pass — é o trabalho paralelo dos agentes desde o último audit que precisa ser consolidado.
Read-only audit that sweeps the Teqo codebase for code smells, drift from documented patterns, and consolidation opportunities, then produces a findings ledger and a remediation plan. **No fixes in the audit run** — deliverables are updated docs, presented for sign-off before writing — exceto as remediações P0/P1 declaradas acima. Sign-off: interativo = apresentação prévia dos artefatos; autônomo (Cursor Cloud) = PR Ready + auto-merge dos artefatos (ver "Modo autônomo (Cursor Cloud)").
@ -39,7 +39,7 @@ Method: the engineering skills this repo follows (improve-code-quality, clean-co
1. Read-only on `src/`, `tests/`, `scripts/` durante a varredura. Os únicos writes da sessão são: os artefatos do passo 5 (após sign-off interativo; em modo autônomo, direto na branch do PR) e as remediações P0/P1 + guardrails de misses colhidas declarados no cabeçalho.
2. Gate commands bare, never piped (`pnpm test | tail` swallows the exit code).
3. Never `knip --fix` blind — verify with `git grep -w <symbol>` (knip cannot load `payload.config.ts`; ledgered P3).
4. Production is live Postgres on the homeserver (`teqo_1313`) with real PII. Local DB only, `teqo_test` for tests. The audit needs no DB writes.
4. Production is live Neon with real PII. Local DB only, `teqo_test` for tests. The audit needs no DB writes.
5. Every claim gets a number (lines, exports, call sites, ms, kB). "Rejected by measurement" is an acceptable outcome for any hypothesis, including the ones below.
## Step 0a — Load the canon
@ -63,12 +63,12 @@ Read, in this order:
Antes da varredura, colha os défices comportamentais registrados pelo fluxo:
```bash
pnpm issue all # kind `agent-miss` nos contadores; ou api.listIssues({ state: 'open', labels: 'kind:agent-miss' })
gh issue list --label kind:agent-miss --state open
```
**Investigue cada miss colhida** (leitura da Issue via API do Forgejo/MCP — disponível no Cloud): o corpo registra causa raiz, fix aplicado e o guardrail proposto por `agent:file-miss`. Verifique no código se o fix segurou e se a classe do defeito ainda é reproduzível. Miss cuja classe já tem guarda viva não gera guardrail novo — gera item de hardening da guarda existente (o passo 2 já trata guarda dodgeable como finding).
**Investigue cada miss colhida** (`gh issue view <N>` — leitura, disponível no Cloud): o corpo registra causa raiz, fix aplicado e o guardrail proposto por `agent:file-miss`. Verifique no código se o fix segurou e se a classe do defeito ainda é reproduzível. Miss cuja classe já tem guarda viva não gera guardrail novo — gera item de hardening da guarda existente (o passo 2 já trata guarda dodgeable como finding).
Cada Issue colhida alimenta o **Passo 4b (recurrence prevention)**: toda miss vira candidata a guardrail determinístico (tipo / ESLint / convention spec / CI / pin comportamental). O ledger desses guardrails vive em **`docs/GUARDRAILS.md`** (criar o arquivo na primeira execução — é o escopo da Issue OPS2): uma linha por guardrail — miss de origem (link da Issue), classe 1–6, mecanismo, status. Quando o guardrail mergeia, **feche as Issues colhidas** (`done`) com comentário apontando o guardrail. Em Cursor Cloud o fechamento é determinístico e não exige escrita manual: o PR do guardrail carrega `Closes #N` (keyword repetida por número — o regex do `issue-done-on-main-merge.yml` não resolve `Closes #A, #B`) e o merge em `main` flippa a miss para `done`; guardrails de várias misses podem ir no mesmo PR desde que cada uma tenha seu `closes`. Miss que não admite guardrail determinístico vira convenção explícita marcada "judgment-only" — não fingir que doc é guarda.
Cada Issue colhida alimenta o **Passo 4b (recurrence prevention)**: toda miss vira candidata a guardrail determinístico (tipo / ESLint / convention spec / CI / pin comportamental). O ledger desses guardrails vive em **`docs/GUARDRAILS.md`** (criar o arquivo na primeira execução — é o escopo da Issue OPS2): uma linha por guardrail — miss de origem (link da Issue), classe 1–6, mecanismo, status. Quando o guardrail mergeia, **feche as Issues colhidas** (`done`) com comentário apontando o guardrail. Em Cursor Cloud o fechamento é determinístico e não exige escrita `gh`: o PR do guardrail carrega `Closes #N` (keyword repetida por número — o regex do `issue-done-on-stage-merge.yml` não resolve `Closes #A, #B`) e o merge em `stage` flippa a miss para `done`; guardrails de várias misses podem ir no mesmo PR desde que cada uma tenha seu `closes`. Miss que não admite guardrail determinístico vira convenção explícita marcada "judgment-only" — não fingir que doc é guarda.
## Step 1 — Hotspot map
@ -166,7 +166,7 @@ Rules:
1. `docs/IMPROVE-CODE-QUALITY-PLAN.md` — new Pass section (context, audit headlines with numbers, workstreams, decisions), matching earlier-pass format.
2. `docs/TECH-DEBT.md` — new rows in the same tables/columns, marked `open — Pass N`; stale rows closed with evidence.
3. `docs/plans/entrega-engenharia-pN.md` (pt-BR, like the other plans) — workstreams ordered P0→P1→P2; each item: goal, evidence, target shape, migration path (Branch by Abstraction / Parallel Change for wide merges), pins to write first, full gate, rollback. Items too big for one delivery → Issue rastreável via `plan-issue`. Every workstream item declares its **Guarda determinística** (class 1–6, step 4b), and the plan carries a **guard map** section: new guards shipped / existing guards hardened / judgment-only residue with the convention that stands in.
4. **Interativo (desktop):** present ALL THREE for sign-off before writing. Then write and stop. **Autônomo (Cursor Cloud):** não há sign-off interativo — escreva os três na branch e abra o PR (**Ready**, base `main` — mesmo fluxo `agent-pr-workflow`; "Modo autônomo", item 2; o merge fica com o safety net). As remediações P0/P1 e os guardrails das misses colhidas seguem na mesma sessão, em PRs próprios com gate completo + auto-merge, sem esperar o merge dos artefatos.
4. **Interativo (desktop):** present ALL THREE for sign-off before writing. Then write and stop. **Autônomo (Cursor Cloud):** não há sign-off interativo — escreva os três na branch e abra o PR (**Ready**, base `main`, **`gh pr merge --auto --rebase`** — mesmo fluxo `agent-pr-workflow`; "Modo autônomo", item 2). As remediações P0/P1 e os guardrails das misses colhidas seguem na mesma sessão, em PRs próprios com gate completo + auto-merge, sem esperar o merge dos artefatos.
## Execution rules for the eventual implementation (record in the plan)

View file

@ -70,10 +70,9 @@ PROD_DATABASE_URL="<unpooled Neon URL>" pnpm db:pull
pnpm db:seed:posts
```
- `scripts/seed-posts.mjs` fetches ~43 articles **live** from jorgesolla.com.br (WordPress REST API, HTML-crawl fallback) and imports them into `post`/`tag`/`media` as published `noticia` posts, converting the WP HTML body to Payload Lexical and uploading covers through the configured media storage (Garage S3 when the `S3_*` envs are set — the adapter overwrites the deterministic `<slug>.<ext>` key natively; local disk otherwise).
- `scripts/seed-posts.mjs` fetches ~39 articles **live** from jorgesolla.com.br (WordPress REST API, HTML-crawl fallback) and imports them into `post`/`tag`/`media` as published `noticia` posts, converting the WP HTML body to Payload Lexical and uploading covers to Vercel Blob.
- Same safety guard as `pnpm dev`: it refuses a non-local `DATABASE_URL` unless `ALLOW_REMOTE_DB=true`.
- **Idempotent by slug** — re-running skips existing posts and reuses tags/media, so it never duplicates.
- **Bucket-write guard (OPS60):** with the `S3_*` envs set (e.g. a worktree with the main repo's prod-credentials copy), the sync requires explicit write intent — `SEED_MEDIA_CONFIRM=1` — and refuses with exit 1 before any write without it; `--dry-run` stays flag-free (it never writes).
- **Idempotent by slug** — re-running skips existing posts and reuses tags/media, so it never duplicates. Because the Vercel Blob store is shared across environments, it deletes any orphan blob under the deterministic `<slug>.<ext>` key before uploading, so it stays re-runnable.
- **Post-seed revalidation (required for prod):** the seed writes from a CLI process outside the deployed runtime, so it does **not** bust production's frozen `posts` cache. After seeding prod content — or after ANY direct-DB write — bust it:
```bash

View file

@ -89,4 +89,4 @@ Bipartir (`{id}-plan` Grok High → `{id}-exec` `kimi-k3-low`) só sob pedido ex
- **`work-issue`:** **não verifica modelo** — a sessão nasce no modelo fixo do ambiente (máquina do humano: DeepSeek V4 Flash). `model:` da Issue é metadata consultiva (o pool spawna nele; o claim brief o imprime).
- **`agent-work-issue`:** pool já fixou o modelo no spawn; sessão mais forte → informa e segue.
- **`plan-issue`:** sugere via esta tabela (slug único completo com effort); bipartição só sob blast radius extremo / pedido explícito.
- ~~`agent-pool` smoke~~ — **dormente (OPS65)**: workflow removido; pool morto.
- **`agent-pool` smoke:** Composer default; Grok com effort explícito / Kimi K3 Low em Issues de teste bipartidas.

View file

@ -1,7 +1,7 @@
---
name: plan-issue
description: >-
Transforma ideias humanas em Issues do Forgejo + planos de intenção em
Transforma ideias humanas em Issues GitHub + planos de intenção em
docs/plans/ (persona, fluxo, objetivo, direção suave no código — sem
decisões duras de engenharia). Se o item muda UI, apresenta no gate um
rascunho visual HTML+Tailwind (PNG embutido no plano de intenção). Divide
@ -13,7 +13,7 @@ disable-model-invocation: true
# Planejar Issues (intenção, não engenharia)
Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/plans/<slug>.md` por item, e (2) uma **Issue rastreável no Forgejo** (`pnpm agent:register`, frontmatter `id/depends/serializes/priority/model`). As Issues do Forgejo (`git.solla.dev/fsolla/teqo`) são a fonte canônica de spec/status/deps/prio/modelo — `docs/roadmap.md` é legado congelado e **nunca** é editado aqui.
Esta skill transforma ideias soltas em: (1) um **plano de intenção** em `docs/plans/<slug>.md` por item, e (2) uma **GitHub Issue rastreável** (`pnpm agent:register`, frontmatter `id/depends/serializes/priority/model`). GitHub Issues são a fonte canônica de spec/status/deps/prio/modelo — `docs/roadmap.md` é legado congelado e **nunca** é editado aqui.
## Ciclo de vida (obrigatório)
@ -29,7 +29,7 @@ rascunho local (Issue: —)
**Regras duras:**
1. **Nada no Forgejo antes do gate.** Antes da confirmação explícita do Passo 5: proibido `pnpm agent:register`, criar Issue/PR / push de PR de planos. Planos locais (`Issue: —`) ok.
1. **Nada no GitHub antes do gate.** Antes da confirmação explícita do Passo 5: proibido `pnpm agent:register`, `gh issue create`, `gh pr create` / push de PR de planos. Planos locais (`Issue: —`) ok.
2. **Confirmação = OK ao overview do lote** (ex. “confirma”, “pode registrar”). “Ok” ambíguo no meio da edição **não** dispara o Passo 6.
3. **Register com `--plan` não nasce `ready`.** O script aplica `blocked` automaticamente quando `--plan` está presente (`--blocked` explícito ainda vale para chores sem plano). Sem `--plan` (chore body-only / `file-miss`), pode nascer `ready`.
4. **Promote só depois do plano em `main`.** Nunca flipar para `ready` com o PR ainda aberto — isso recria a race de claim. Caminhos: (A) `pnpm agent:ready -- --issue N` no fim do Passo 6 após merge; (B) Action determinística no merge que lê `Related #N` (OPS18). Ambos idempotentes.
@ -67,7 +67,7 @@ Aqui **não** se implementa código de produto, **não** se escreve plano de imp
1. **Separe os itens.** Entrada pode ser 1 ideia ou N. Se ambíguo, assuma a leitura mais provável e liste — a confirmação vai no gate.
2. **Fatia mínima útil.** Prefira várias Issues pequenas a um epic. Cada item deve caber num appetite curto e entregar um outcome verificável sozinho. Mesclar só quando separar criaria trabalho inútil (mesmo fluxo, mesma persona, mesma superfície sem valor incremental).
3. **Dedup intra-lote:** mesclar | absorver (fase de plano existente, sem ID novo) | manter separados com `depends`.
4. **Dedup contra o existente:** `pnpm issue all` + `issuesById()` + grep em `docs/plans/*.md` e `docs/roadmap.md` (legado).
4. **Dedup contra o existente:** `gh issue list --state all` + `issuesById()` + grep em `docs/plans/*.md` e `docs/roadmap.md` (legado).
- Já coberto / entregue → apontar e não criar.
- Issue **`in-progress` / `done` / `in-prod`:** **não** editar o plano dela — se a intenção mudou, item **sucessor** (plano + Issue novos).
- Issue só `blocked` / `ready` (ainda não claimada): pode editar o plano existente (fase de plano) sem ID novo.
@ -150,7 +150,7 @@ Sem `--plan`: nasce `ready` (use `--blocked` só se quiser não-claimável sem p
2. Atualize `Issue: #N` (e status) no plano local.
3. Commit + **`pnpm push`** + PR **Ready** `--base main` com **`Related #N`** (nunca `Closes #N` em PR só de `docs/plans/` — `plans-only-closes`).
4. Auto-merge via safety net (`agent-pr-ready-automerge.yml` — o rollup `CI (PR) / checks` verde mergea por rebase); espere o merge em `main`.
4. Auto-merge (`gh pr merge --auto --rebase`); espere o merge em `main`.
5. **Promote** com o script (idempotente se já `ready`; só Issues `blocked` + link `docs/plans/`, sem gates humanos `needs:*` nem `in-progress`/`done`/`in-prod`):
```bash

View file

@ -1,11 +1,11 @@
---
name: project-status
description: Apresenta o estado do projeto Teqo a partir das Issues do Forgejo rastreáveis — overview (estado/prio/kind), fila atual na ordem do agent:claim com o modelo de cada item, grafo mermaid de dependências, bloqueios com motivo e sugestões de consolidação (merge/break-down). Read-only; NUNCA modifica Issues, em particular nunca toca Issues in-progress. Usar quando o usuário pedir status/visão geral do projeto, "como está o projeto", "qual a fila", "o que está bloqueado", "mostra o grafo", ou perguntar o que dá para consolidar.
description: Apresenta o estado do projeto Teqo a partir das GitHub Issues rastreáveis — overview (estado/prio/kind), fila atual na ordem do agent:claim com o modelo de cada item, grafo mermaid de dependências, bloqueios com motivo e sugestões de consolidação (merge/break-down). Read-only; NUNCA modifica Issues, em particular nunca toca Issues in-progress. Usar quando o usuário pedir status/visão geral do projeto, "como está o projeto", "qual a fila", "o que está bloqueado", "mostra o grafo", ou perguntar o que dá para consolidar.
---
# Status do projeto (read-only)
Esta skill apresenta o estado do projeto a partir das Issues do Forgejo rastreáveis — a fonte canônica de spec/status/deps/prio/modelo. **Read-only por contrato:** nenhuma Issue é criada, editada ou relabelada aqui, e Issues `in-progress` são intocáveis (risco de conflito com o agente que está executando).
Esta skill apresenta o estado do projeto a partir das GitHub Issues rastreáveis — a fonte canônica de spec/status/deps/prio/modelo. **Read-only por contrato:** nenhuma Issue é criada, editada ou relabelada aqui, e Issues `in-progress` são intocáveis (risco de conflito com o agente que está executando).
## Fluxo
@ -22,6 +22,6 @@ pnpm agent:status
- **Bloqueios** — cada `blocked` com o motivo (dep aberta, jurídico, `requirements-changed`).
- **Sugestões de consolidação** — as heurísticas do script (merge por domínio+prio; break-down por gargalo de depends) como **texto para o usuário decidir**. Nunca aplique consolidação aqui: aplicação é manual ou via `plan-issue`/`pnpm agent:register`. Nunca proponha nada sobre Issues `in-progress`.
3. Se o usuário pedir interpretação além da saída (ex.: "o que destrava mais coisa?"), responda a partir do grafo — sem inventar dados que o script não imprimiu; se faltar dado, leia a Issue via API do Forgejo (MCP/`pnpm issue`) somente-leitura.
3. Se o usuário pedir interpretação além da saída (ex.: "o que destrava mais coisa?"), responda a partir do grafo — sem inventar dados que o script não imprimiu; se faltar dado, rode `gh issue view <N>` somente-leitura.
**NÃO faz:** editar `docs/roadmap.md` (legado congelado), claim, registro, qualquer escrita de Issue.
**NÃO faz:** editar `docs/roadmap.md` (legado congelado), claim, registro, qualquer escrita no GitHub.

View file

@ -36,18 +36,17 @@ pular a pausa do impl plan; Draft / sem auto-merge.
- [ ] 1. Contexto: Issue do prompt/`$ARGUMENTS` da sessão (ausente → UMA pergunta com validação; nunca claim)
- [ ] 2. Abrir o plano de intenção do body da Issue (`Plano: docs/plans/...`; sem link → body é spec)
- [ ] 3. Plan mode → escrever `docs/plans/<slug>-impl.md` → **PARAR e confirmar com o humano**
- [ ] 4. Após “ok”: Passo 4 (execução → e2e local afetado → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
- [ ] 4. Após “ok”: Passo 4 (execução → /simplify → débitos autônomos → PR, via `execution-pipeline.md`)
```
## Passo 1 — Contexto da sessão
A Issue chega no prompt/`$ARGUMENTS` do `/work-issue` (`--issue <N>` ou o número —
contrato OPS33; chamada manual: passar o número). Sempre consulte o Forgejo para o
contrato OPS33; chamada manual: passar o número). Sempre consulte o GitHub para o
resto (fonte única é a Issue, nunca um brief duplicado).
- **Presente** → valide o básico (issue do Forgejo `git.solla.dev/fsolla/teqo`:
existe, `OPEN`, label `in-progress` — confirma que o claim foi feito fora da
skill) e use.
- **Presente** → valide o básico (`gh issue view <N>`: existe, `OPEN`, label
`in-progress` — confirma que o claim foi feito fora da skill) e use.
- **Ausente** → **uma** pergunta ao humano ("Qual Issue?"), valide o número
informado com a mesma checagem e siga.
- **Checagem falhou** (não existe / não `OPEN` / sem `in-progress`) → a Issue
@ -96,12 +95,6 @@ nunca invente produto novo.
fechar em main), com os deltas do ator humano:
- **Branch:** `<Code>-<slug>` do worktree — nunca crie branch nova na sessão.
- **E2E local afetado (OPS72, discricionário):** antes do push, rode
localmente os e2e que você **criou** + os da **mesma superfície afetada** —
você decide quais (`pnpm test:e2e:affected` ou specs diretas). Não está no
`gate:push`; ver a seção "E2E local afetado" do `execution-pipeline.md`
(incl. a limitação da #72: `--no-deps` + projetos paralelos → `--workers=1`
ou a cadeia padrão de projetos).
- **UI:** shape → craft → critique → polish.
- **`capture-review-debts`:** **autônomo** — colha, dedupe, pontue e **decida
você mesmo o destino** (registrar/absorver/deferir/descartar) pela triage

View file

@ -12,42 +12,12 @@ Ordem:
testes de domínio. Invariantes do engineering-brief.
2. **UI** — se Impeccable B/C/D: shape → craft → critique → polish. Tokens
`data-theme='campaign'`; shells existentes.
3. **E2E local afetado (OPS72, discricionário)** — antes do push, rode
localmente os e2e que você **criou** + os da **mesma superfície afetada**
(você decide quais). Ver §E2E local afetado.
4. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push` (não
`git push` nu).
Tracer bullet cedo se o item for grande. Inclua o `*-impl.md` no commit da
entrega.
## E2E local afetado (OPS72)
Política de e2e: **local = discricionário, CI/PR = blast radius, main = full
antes do deploy**.
- **O que rodar:** os specs e2e que o diff **criou** (novos `.e2e.spec.ts`) +
os da mesma superfície trabalhada (o `E2E_AFFECTED_MANIFEST` mapeia
`src/` → specs; a mecânica do CI é a mesma). **É discricionário: você
decide quais rodar** — não é um gate mecânico e não está no `gate:push`.
- **Ferramenta:** `pnpm test:e2e:affected` (espelho local do classifier:
modo `none`/`selected`/`full` conforme o diff; roda `migrate` +
`db:seed:minimal` antes). Alternativa para specs diretas:
`pnpm test:e2e --no-deps -- tests/e2e/<spec>.e2e.spec.ts` (ou
`--no-deps --project=<família>`).
- **CI/PR:** roda só o **blast radius** detectado (`ci-scope.mjs`, modo
`selected`) — nunca `full`. Diff high-risk (schema/lockfile/harness)
classifica `full` → o PR não roda e2e: o full fica para o `verify` do
deploy manual (antes de publicar) e, nesse mesmo diff, o espelho local
roda full.
- **Limitação da #72 (S3-FOLLOWUP):** e2e local com `--no-deps` + projetos
paralelos (`--project=a --project=b`) colide no `seedTestUser` (delete +
create concorrentes nos `beforeAll` → `ValidationError: email` e falhas
fantasma). Use `--workers=1` ou a cadeia padrão de projetos. Só afeta o
local: no CI/prod mode a cadeia de deps dos projects é descartada (as
famílias rodam em paralelo) e os runs selecionados filtram por arquivo —
quase sempre uma família — então o seed não colide.
## /simplify + débitos
1. Rode o comando `/simplify` completo (3 reviewers paralelos via Task,
@ -64,15 +34,11 @@ antes do deploy**.
e rode `pnpm changelog:build` para regenerar `docs/CHANGELOG-AGENTS.md`
(insert-only: entradas históricas nunca mudam; o diff será só a entrada
nova + o agregado). Rode `pnpm changelog:check` para confirmar.
3. **`pnpm push -u origin HEAD`** — origin é o **GitHub** (OPS71; o tracker de
Issues continua no Forgejo por API).
4. PR no **GitHub** via `GITHUB_TOKEN=<PAT> node scripts/github-pr.mjs --head <branch> --title "<id> — <título>" --body-file <arquivo>` — **Ready** (nunca draft; o script não tem flag draft), base `main`, body com `Closes #<N>` (ou `Related #N` em plans-only). Em Cursor Cloud: `ManagePullRequest` com `draft: false`.
5. O safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do
GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando
o required check `CI (PR) / checks` fica verde; nada a armar.
6. No merge, o workflow `issue-done-on-main-merge.yml` flipa `done`/`in-prod`
**no Forgejo** (lê o body do PR via API do GitHub, escreve no tracker por
`FORGEJO_API_TOKEN`). Comente na Issue o desfecho em uma linha.
3. **`pnpm push -u origin HEAD`**
4. PR **Ready** (nunca draft): `gh pr create --base main` com `Closes #<N>`
5. `gh pr merge --auto --rebase` imediatamente
6. `gh pr checks <PR> --watch --required` (`checks`; ignore Vercel Git)
7. CI flipa `done`/`in-prod` no merge. Comente na Issue o desfecho em uma linha.
## Deltas por ator

View file

@ -31,7 +31,7 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op
**Default-go:** `next`, `plan` e `new` imprimem `cd <dir>` na última linha **por padrão**; `--stay` suprime; `--go` explícito continua aceito como no-op. No terminal interativo, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile) para o `cd` ser aplicado de verdade no shell que te chamou — node não consegue mudar o cwd do shell pai. `kill` também imprime `cd <main>` no fim (a sessão nunca fica num diretório destruído).
**Launch do opencode (OPS26 + OPS33 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` já enviado (a Issue claimada vai no prompt; a skill lê o resto do Forgejo); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
**Launch do opencode (OPS26 + OPS33 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue <N>"]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue <N>` já enviado (a Issue claimada vai no prompt; a skill lê o resto do GitHub); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
## Fluxo quando invocado como skill (agentes que não têm opencode)

View file

@ -0,0 +1,69 @@
# Agent pool supervisor (docs/plans/agent-pool-orchestrator.md).
#
# Stateless tick: reconciles pool claims and spawns Cursor Cloud workers up to
# POOL_MAX_SLOTS over `ready` autonomous-eligible issues. Three triggers:
# - workflow_dispatch — the canonical human channel (start/stop/pause/resume)
# - schedule */10min — the periodic reconcile (default branch only)
# - pull_request closed on main — immediate slot replenish after a merge
#
# Secrets:
# CURSOR_API_KEY — Cursor Cloud Agents API key
# FORGEJO_API_TOKEN — optional PAT; the native GITHUB_TOKEN (full repo
# access) covers issue/label/contents ops inside Forgejo Actions
#
# Forgejo port (OPS50): no `concurrency:`/`permissions:` blocks; pool state
# lives in pool-state.json on the `pool-state` branch (variables API is
# GET-only here), read/written by scripts/lib/agent-pool-forgejo.mjs.
name: Agent pool supervisor
on:
schedule:
- cron: '*/10 * * * *'
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
inputs:
action:
description: 'Ação do pool'
required: true
type: choice
options:
- start
- stop
- pause
- resume
- tick
- status
maxSlots:
description: 'Slots simultâneos (só com action=start; default 5, máx 8)'
required: false
type: string
jobs:
tick:
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name != 'pull_request' || github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: agent:pool workflow
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
ACTION: ${{ inputs.action }}
MAX_SLOTS: ${{ inputs.maxSlots }}
run: |
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
args=(--action "$ACTION" --actor "$GITHUB_ACTOR")
if [ -n "$MAX_SLOTS" ]; then args+=(--max-slots "$MAX_SLOTS"); fi
else
args=(--action tick --actor "$GITHUB_ACTOR")
fi
node scripts/agent-pool.mjs workflow "${args[@]}"

View file

@ -0,0 +1,34 @@
# Safety net: agent PRs (cursor/* → main) must be Ready + auto-merge.
# Covers Cloud ManagePullRequest defaulting to draft and agents that forget
# to arm the merge. No skill opt-out (engineering-audit included).
#
# Forgejo port (OPS50): Forgejo's API has no auto-merge scheduling, so the
# workflow runs scripts/forgejo-pr-automerge.mjs (plain Node): marks draft →
# ready, waits for the commit statuses (the branch-protection `checks` gate),
# then merges by rebase. Same semantics as `gh pr merge --auto --rebase`,
# implemented as poll + merge. GitHub's cancel-workflow-on-failure action was
# dropped (no GITHUB_RUN_ID plumbing on Forgejo).
name: Agent PR Ready + auto-merge
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
branches: [main]
jobs:
ready-automerge:
if: >-
github.event.pull_request.state == 'open' &&
startsWith(github.event.pull_request.head.ref, 'cursor/')
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Mark Ready and merge when checks pass
run: node scripts/forgejo-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -11,8 +11,6 @@ on:
description: Cursor run id (run-…) optional cancel first
required: false
type: string
permissions:
contents: read
jobs:
archive:
runs-on: ubuntu-latest

View file

@ -0,0 +1,427 @@
# PR gate: feature → main. Two-phase cascade:
# Phase 1 (cheap, no Postgres): lint, format, typecheck, knip, cycles, unit
# Phase 2 (expensive, Postgres; needs phase 1 green/skipped): int, build, e2e
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs). A
# phase-1 failure skips phase 2 via `needs` + explicit if.
#
# Schema migrations are NOT serialized across PRs (migration-lock removed
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
# PR and on main (ci.yml), incl. campaignMigrationReconciliation — the check's
# hard fail deadlocked every open schema PR the moment two coexisted.
#
# High-risk diffs (schema, lockfile, classifiers — see
# scripts/lib/test-affected-core.mjs) force full unit/int/e2e. Push to main
# always runs the full suite in ci.yml (no affected skips).
#
# Forgejo port (OPS50): no `concurrency:`/`permissions:` (unsupported /
# native token); Postgres services publish per-job ports (5433 int, 5434
# build, 5435/5436 e2e shards) and jobs reach them through the bridge gateway
# (DB_HOST from /proc/net/route). The GitHub fail-fast action was dropped
# (Forgejo has no cancel-run plumbing); job timeouts cover that role.
name: CI (PR)
on:
pull_request:
branches: [main]
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
scope:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code_mode: ${{ steps.classify.outputs.code_mode }}
build_mode: ${{ steps.classify.outputs.build_mode }}
test_mode: ${{ steps.classify.outputs.test_mode }}
e2e_mode: ${{ steps.classify.outputs.e2e_mode }}
e2e_specs: ${{ steps.classify.outputs.e2e_specs }}
e2e_matrix: ${{ steps.classify.outputs.e2e_matrix }}
e2e_shards: ${{ steps.classify.outputs.e2e_shards }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Classify PR blast radius
id: classify
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: |
result=$(node scripts/ci-scope.mjs)
echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')"
echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT"
echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT"
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
# OPS34: full runs split across shards (one runner per shard); selected
# stays on a single runner (a second build for a handful of specs is
# waste). Single source: e2eShardConfig in scripts/lib/test-affected-core.mjs.
echo "e2e_matrix=$(echo "$result" | jq -c .e2e_shards.matrix)" >> "$GITHUB_OUTPUT"
echo "e2e_shards=$(echo "$result" | jq -r .e2e_shards.total)" >> "$GITHUB_OUTPUT"
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Check test file locations
run: node scripts/check-test-locations.mjs
- run: pnpm lint
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [scope]
if: needs.scope.outputs.code_mode != 'none'
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
knip:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [scope]
if: needs.scope.outputs.code_mode != 'none'
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm knip
cycles:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [scope]
if: needs.scope.outputs.code_mode != 'none'
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm check:cycles
unit:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [scope]
if: needs.scope.outputs.test_mode != 'none'
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Unit tests (full suite)
if: needs.scope.outputs.test_mode == 'full'
run: pnpm test:unit
- name: Unit tests (changed since PR base)
if: needs.scope.outputs.test_mode == 'changed'
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
int:
runs-on: ubuntu-latest
timeout-minutes: 25
needs: [scope, lint, format, typecheck, knip, cycles, unit]
if: |
always()
&& !cancelled()
&& needs.scope.outputs.test_mode != 'none'
&& needs.lint.result == 'success'
&& needs.format.result == 'success'
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
env:
# Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork holds
# a Payload pool (max 10) → overflows the service Postgres' 100 clients
# ("too many clients already", CI run 180). 4 forks stay well under.
# GitHub's 2–4 vCPU runners keep the CPU-relative default (no env).
VITEST_MAX_WORKERS: 4
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" >> "$GITHUB_ENV"
# vitest/playwright re-load .env.test over the process env, so the
# service URL must win through the .env.test.local layer (same
# mechanism `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" > .env.test.local
- run: pnpm migrate
- run: pnpm db:seed:minimal
- name: Integration tests (full suite)
if: needs.scope.outputs.test_mode == 'full'
run: pnpm test:int
- name: Integration tests (changed since PR base)
if: needs.scope.outputs.test_mode == 'changed'
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
build:
runs-on: ubuntu-latest
timeout-minutes: 25
needs: [scope, lint, format, typecheck, knip, cycles, unit]
if: |
always()
&& !cancelled()
&& needs.scope.outputs.build_mode != 'none'
&& needs.lint.result == 'success'
&& needs.format.result == 'success'
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5434:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5434/teqo_test" >> "$GITHUB_ENV"
- name: Build (against the local service database)
run: pnpm build
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [scope, lint, format, typecheck, knip, cycles, unit]
if: |
always()
&& !cancelled()
&& needs.scope.outputs.e2e_mode != 'none'
&& needs.lint.result == 'success'
&& needs.format.result == 'success'
&& (needs.typecheck.result == 'success' || needs.typecheck.result == 'skipped')
&& (needs.knip.result == 'success' || needs.knip.result == 'skipped')
&& (needs.cycles.result == 'success' || needs.cycles.result == 'skipped')
&& (needs.unit.result == 'success' || needs.unit.result == 'skipped')
# OPS34: full mode splits across 2 runners; selected mode stays on shard 1
# (shard 2 skips build+test via step `if`). Static [1, 2] — Forgejo 1.22
# does not pass job results into matrix expansion, so fromJson(needs.*)
# yields an empty shard. Keep the literal in sync with e2eShardConfig in
# scripts/lib/test-affected-core.mjs (ci.yml pins the same [1, 2] / 2).
# fail-fast: false keeps both shards' logs.
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
PLAYWRIGHT_WORKERS: 2
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- ${{ matrix.shard == 1 && '5435' || '5436' }}:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
DB_PORT=${{ matrix.shard == 1 && '5435' || '5436' }}
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV"
# vitest/playwright re-load .env.test over the process env, so the
# service URL must win through the .env.test.local layer (same
# mechanism `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local
- run: pnpm migrate
- run: pnpm db:seed:minimal
- name: Build for e2e
# Selected mode only needs shard 1 (a second build for a handful of
# specs is waste).
if: needs.scope.outputs.e2e_mode == 'full' || matrix.shard == 1
run: pnpm build
env:
# Same dir the Playwright webServer serves (playwright.config.ts
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
NEXT_DIST_DIR: .next-e2e
- name: E2E tests (full suite, shard ${{ matrix.shard }})
if: needs.scope.outputs.e2e_mode == 'full'
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
run: pnpm test:e2e --shard=${{ matrix.shard }}/${{ needs.scope.outputs.e2e_shards }}
- name: E2E tests (manifest-selected specs)
# Selected specs run once, on shard 1; shard 2 skips (no test step).
if: needs.scope.outputs.e2e_mode == 'selected' && matrix.shard == 1
run: pnpm test:e2e -- ${{ needs.scope.outputs.e2e_specs }}
docs-guards:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Changelog is append-only
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
PR_BODY: ${{ github.event.pull_request.body }}
run: node scripts/check-changelog-append-only.mjs
- name: Aggregate is up to date with docs/changelog
run: node scripts/build-changelog.mjs --check
- name: No conflict markers in markdown diffs
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: node scripts/check-docs-conflict-markers.mjs
plans-only-closes:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Block Closes/Fixes/Resolves on plans-only PRs
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
PR_BODY: ${{ github.event.pull_request.body }}
run: node scripts/check-plans-only-pr-closes.mjs
checks:
name: checks
runs-on: ubuntu-latest
needs:
- scope
- lint
- format
- typecheck
- knip
- cycles
- unit
- int
- build
- e2e
- docs-guards
- plans-only-closes
if: always()
steps:
- name: All cascade checks green
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -e '
to_entries
| all(.value.result == "success" or .value.result == "skipped")
'

228
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,228 @@
# Post-merge verifier on `main`: always the FULL suite (no affected skips).
# When the suite is green, the `deploy` job publishes teqo-1313 on the
# homeserver (OPS53): it streams scripts/deploy-homeserver.sh to the homeserver
# over SSH; the remote script builds the standalone image at the pushed SHA,
# pushes it to the local registry (localhost:5000), applies pending migrations
# through the compose maintenance service and rolls the container out.
#
# Forgejo port (OPS50): Postgres services publish to per-job HOST ports on the
# runner machine (act/forgejo runner semantics — the canonical 5432 is the
# workstation's own dev database), and jobs reach them through the bridge
# gateway: DB_HOST = gateway IP from /proc/net/route. No `permissions:`
# (Forgejo issues one native GITHUB_TOKEN) and no `concurrency:` (unsupported).
# `gh workflow run` requeues became `node scripts/forgejo-dispatch.mjs`.
# The GitHub fail-fast action was dropped (Forgejo has no cancel-run plumbing);
# job timeouts cover that role.
name: CI
on:
push:
branches: [main]
workflow_dispatch:
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
static:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: node scripts/check-test-locations.mjs
- run: pnpm lint
- run: pnpm format:check
- run: pnpm typecheck
- run: pnpm knip
- run: pnpm check:cycles
- run: pnpm test:unit
int:
runs-on: ubuntu-latest
timeout-minutes: 25
env:
# Self-hosted runner has 8 CPUs; vitest forks ≈ CPUs and each fork holds
# a Payload pool (max 10) → overflows the service Postgres' 100 clients
# ("too many clients already"). 4 forks stay well under. GitHub's 2–4
# vCPU runners keep the CPU-relative default (no env).
VITEST_MAX_WORKERS: 4
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" >> "$GITHUB_ENV"
# vitest re-loads .env.test over the process env, so the service URL
# must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5433/teqo_test" > .env.test.local
- run: pnpm migrate
- run: pnpm db:seed:minimal
- run: pnpm test:int
build:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5434:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:5434/teqo_test" >> "$GITHUB_ENV"
- name: Build (against the service database)
run: pnpm build
e2e:
runs-on: ubuntu-latest
timeout-minutes: 40
# Main always runs the full suite: split it across 2 jobs (OPS34), each
# building its own `.next-e2e` (measured 2026-08-10: build 172s + tests
# 196s + ~54s fixed → ≈ 5.5 min per shard vs 435s serial). `fail-fast:
# false` so one shard's failure keeps the other's diagnostics. Keep the
# [1, 2] / 2 literal in sync with `e2eShardConfig` in
# scripts/lib/test-affected-core.mjs (ci-pr pins the same literal).
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
PLAYWRIGHT_WORKERS: 2
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- ${{ matrix.shard == 1 && '5435' || '5436' }}:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Prepare database URL (service via bridge gateway)
run: |
set -euo pipefail
DB_HOST=$(awk '$2 == "00000000" {a=$3; print strtonum("0x" substr(a,7,2)) "." strtonum("0x" substr(a,5,2)) "." strtonum("0x" substr(a,3,2)) "." strtonum("0x" substr(a,1,2)); exit}' /proc/net/route)
DB_PORT=${{ matrix.shard == 1 && '5435' || '5436' }}
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" >> "$GITHUB_ENV"
# playwright re-loads .env.test over the process env, so the service
# URL must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses). OPS50.
echo "DATABASE_URL=postgresql://teqo:teqo@${DB_HOST}:${DB_PORT}/teqo_test" > .env.test.local
- run: pnpm migrate
- run: pnpm db:seed:minimal
- name: Build for e2e
run: pnpm build
env:
# Same dir the Playwright webServer serves (playwright.config.ts
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
NEXT_DIST_DIR: .next-e2e
- name: E2E tests (full suite, shard ${{ matrix.shard }})
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
run: pnpm test:e2e --shard=${{ matrix.shard }}/2
checks:
name: checks
runs-on: ubuntu-latest
needs: [static, int, build, e2e]
if: always()
steps:
- name: All main verifier checks green
env:
NEEDS: ${{ toJSON(needs) }}
run: |
set -euo pipefail
echo "$NEEDS" | jq -e '
to_entries
| all(.value.result == "success" or .value.result == "skipped")
'
deploy:
name: deploy homeserver (teqo-1313)
# OPS53. Runs directly on the runner HOST (not a job container): the
# deploy needs the workstation's SSH key to reach the homeserver
# (`ssh homeserver`) and streams the whole remote build/migrate/rollout
# to this job's logs. Deliberately wired ONLY here — push to main +
# workflow_dispatch — never in ci-pr.yml: a host job is code execution as
# `fsolla` on the workstation, and main only ever moves through a merged
# PR whose full suite was already green.
needs: [checks]
runs-on: host
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Deploy teqo-1313 on the homeserver
run: |
set -euo pipefail
ssh homeserver "bash -s -- $GITHUB_SHA" < scripts/deploy-homeserver.sh

View file

@ -0,0 +1,23 @@
# Main merge flips claimed issues to done + in-prod.
# Forgejo port (OPS50): the gh loop became scripts/forgejo-issue-transition.mjs
# (plain Node, reads the PR body from the API itself).
name: Main merge flips claimed issues to done + in-prod
on:
pull_request:
types: [closed]
jobs:
issue-done:
if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Flip Closes/Fixes #N issues to done and in-prod
run: node scripts/forgejo-issue-transition.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -0,0 +1,36 @@
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path).
#
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
# Forgejo port (OPS50): script reads the PR via the Forgejo API (plain Node).
name: Promote plan-issue Related on main merge
on:
pull_request:
types: [closed]
branches: [main]
jobs:
plan-issue-ready:
if: >-
github.event.pull_request.merged == true &&
(
contains(github.event.pull_request.body, 'Related #') ||
contains(github.event.pull_request.body, 'related #') ||
contains(github.event.pull_request.body, 'RELATED #')
)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable
# package-manager-cache: setup-node@v5 defaults to true and looks for
# pnpm (packageManager in package.json) before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Promote Related #N awaiting plan → ready
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -1,64 +0,0 @@
# Safety net (OPS71 — GitHub): every same-repo PR to main must be Ready +
# auto-merge armed. Covers Cloud ManagePullRequest defaulting to draft,
# agents that forget to arm the merge, and human/worktree PRs (the OPS52
# incident was a human PR whose merge failed silently). Draft PRs from
# non-`cursor/*` branches are never touched — the draft flag is the actor's
# veto (the CLI skips them). Fork PRs are excluded (the repo is public; a
# merged fork PR would deploy) — same RCE-guard class as the other workflows.
#
# GitHub's NATIVE auto-merge replaces the Forgejo-era poll CLI (removed in
# OPS71 Fase 2): `enablePullRequestAutoMerge` (GraphQL, REBASE —
# the repo's canonical merge style) is server-side — GitHub only merges when
# every required check (the `CI (PR) / checks` rollup of ci-pr.yml, OPS62) is
# green and the PR is mergeable. The OPS64 "rollup can lie" defense is
# structural here: a single sequential job posts one honest check-run and the
# server enforces it — nothing to poll, nothing to trust.
#
# Permissions: contents+pull-requests write — enough for PATCH draft:false
# and the GraphQL auto-merge mutation with the built-in GITHUB_TOKEN.
name: PR Ready + auto-merge
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
ready-automerge:
# Same-repo only. This MUST stay in the `if:` (pre-checkout): for a fork
# PR, actions/checkout checks out the fork's head, so a same-repo guard
# inside the CLI would already be running the fork's own script — RCE
# with the Actions token. The draft policy lives in the CLI (skips
# non-`cursor/*` drafts) because there the PR state is re-read from the
# API, not from webhook payload fields.
if: >-
github.event.pull_request.state == 'open' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Mark Ready and arm auto-merge
env:
# Explicit `github.token` — the built-in GITHUB_TOKEN env is not
# guaranteed to be injected on every event path; passing it via the
# documented context is the robust form.
GITHUB_TOKEN: ${{ github.token }}
# OPS71-FLIP: arming with the built-in GITHUB_TOKEN makes the merge
# happen as github-actions[bot], and GitHub's anti-recursion never
# creates workflow runs for events triggered by GITHUB_TOKEN actions
# — the post-merge `closed` flips would silently never run (PR #746).
# AUTOMERGE_PAT (real user PAT) attributes the merge to a user, so
# the `closed` event creates runs again. Fail-closed in the CLI.
AUTOMERGE_PAT: ${{ secrets.AUTOMERGE_PAT }}
run: node scripts/github-pr-automerge.mjs --pr "${{ github.event.pull_request.number }}"

View file

@ -1,226 +0,0 @@
# PR gate: feature → main (OPS71 — GitHub Actions). SINGLE sequential job
# (OPS62, X1): one container, one checkout, one pnpm install, one runner slot.
# Fail-fast is STRUCTURAL — a failed step skips every step after it, the job
# fails and the run fails. GitHub-native `concurrency` cancels the in-flight
# run when the PR gets a new push (the Forgejo era had no cancel API; here it
# is free and matches the OPS62 "one slot per PR" spirit).
#
# The job id `checks` preserves the `CI (PR) / checks` check-run that the
# branch protection rule and the native auto-merge rely on (OPS71): GitHub
# names it <workflow name> / <job name> — keep both literals frozen. GitHub's
# auto-merge is server-side: it only merges with every required check green,
# so the OPS64 "rollup can lie" defense is structural here (single job posts
# one honest check-run; no matrix, no shards).
#
# Job-level skips come from the `scope` classifier (scripts/ci-scope.mjs) run
# as a step; its outputs drive `if:` on the heavy steps (code/build/test:
# none/changed/full; e2e: none/selected/full). e2e policy (OPS72): the PR
# runs ONLY the blast radius — the specs the classifier detected as touched
# (mode `selected`), never `full`. A high-risk diff (schema/lockfile/test
# harness) classifies as `full` → the PR simply runs no e2e: the local agent
# mirror (`pnpm test:e2e:affected`) runs full in that same case, and the
# deploy `verify` job runs the full suite before publishing.
#
# Schema migrations are NOT serialized across PRs (migration-lock removed
# 2026-08-12): a stale chain is caught by `pnpm migrate` + `test:int` on every
# PR, incl. campaignMigrationReconciliation.
#
# Fork-PRs are excluded at job level (`if:` same-repo): the repo is public and
# a fork PR would burn hosted minutes running arbitrary code. Same guard class
# as the RCE guard on the post-merge workflows.
#
# Postgres services (OPS71 — GitHub hosted): jobs run directly on the runner
# VM (NOT inside the job-container network of the Forgejo era), so services
# are reached at localhost on PUBLISHED host ports. Each hosted run is its own
# VM, so fixed ports never collide across runs. Two isolated services — int
# on 5432 and build+e2e on 5433 — keep e2e from inheriting int fixture
# residue.
name: CI (PR)
on:
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-pr-${{ github.head_ref }}
cancel-in-progress: true
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
checks:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 50
services:
postgres-int:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
postgres-build:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Classify PR blast radius
id: scope
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
result=$(node scripts/ci-scope.mjs)
echo "scope: $(echo "$result" | jq -c '{code:.code.mode,build:.build.mode,test:.test.mode,e2e:.e2e.mode,reason:{code:.code.reason,build:.build.reason,test:.test.reason,e2e:.e2e.reason}}')"
echo "code_mode=$(echo "$result" | jq -r .code.mode)" >> "$GITHUB_OUTPUT"
echo "build_mode=$(echo "$result" | jq -r .build.mode)" >> "$GITHUB_OUTPUT"
echo "test_mode=$(echo "$result" | jq -r .test.mode)" >> "$GITHUB_OUTPUT"
echo "e2e_mode=$(echo "$result" | jq -r .e2e.mode)" >> "$GITHUB_OUTPUT"
echo "e2e_specs=$(echo "$result" | jq -r '[.e2e.specs[] | "tests/e2e/" + . + ".e2e.spec.ts"] | join(" ")')" >> "$GITHUB_OUTPUT"
# Content guards are PR-scoped and cheap — run them before the suite so
# a guard failure fails fast instead of after 20 minutes of tests.
- name: Changelog is append-only
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
PR_BODY: ${{ github.event.pull_request.body }}
run: node scripts/check-changelog-append-only.mjs
- name: Aggregate is up to date with docs/changelog
run: node scripts/build-changelog.mjs --check
- name: No conflict markers in markdown diffs
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: node scripts/check-docs-conflict-markers.mjs
- name: Block Closes/Fixes/Resolves on plans-only PRs
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
PR_BODY: ${{ github.event.pull_request.body }}
run: node scripts/check-plans-only-pr-closes.mjs
- name: Check test file locations
run: node scripts/check-test-locations.mjs
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
- name: Typecheck
if: steps.scope.outputs.code_mode != 'none'
run: pnpm typecheck
- name: Knip
if: steps.scope.outputs.code_mode != 'none'
run: pnpm knip
- name: Check cycles
if: steps.scope.outputs.code_mode != 'none'
run: pnpm check:cycles
- name: Unit tests (full suite)
if: steps.scope.outputs.test_mode == 'full'
run: pnpm test:unit
- name: Unit tests (changed since PR base)
if: steps.scope.outputs.test_mode == 'changed'
run: pnpm test:unit -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Prepare database URL (int service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
# vitest re-loads .env.test over the process env, so the service URL
# must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses).
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
- name: Migrate (int database)
run: pnpm migrate
- name: Seed minimal (int database)
run: pnpm db:seed:minimal
- name: Integration tests (full suite)
if: steps.scope.outputs.test_mode == 'full'
env:
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
# overflows the service Postgres' 100 clients ("too many clients
# already"). 4 forks stay well under. The unit step above stays
# CPU-relative (no DB).
VITEST_MAX_WORKERS: 4
run: pnpm test:int
- name: Integration tests (changed since PR base)
if: steps.scope.outputs.test_mode == 'changed'
env:
VITEST_MAX_WORKERS: 4
run: pnpm test:int -- --changed origin/${{ github.base_ref }} --passWithNoTests
- name: Prepare database URL (build/e2e service)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
run: pnpm migrate
- name: Seed minimal (build/e2e database)
if: steps.scope.outputs.build_mode != 'none' || steps.scope.outputs.e2e_mode == 'selected'
run: pnpm db:seed:minimal
- name: Build (against the local service database)
if: steps.scope.outputs.build_mode != 'none'
run: pnpm build
- name: Cache Playwright browsers
if: steps.scope.outputs.e2e_mode == 'selected'
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
if: steps.scope.outputs.e2e_mode == 'selected'
run: pnpm exec playwright install --with-deps chromium
- name: Build for e2e
# Selected mode needs the build too (the Playwright webServer serves
# the `.next-e2e` production build).
if: steps.scope.outputs.e2e_mode == 'selected'
run: pnpm build
env:
NEXT_DIST_DIR: .next-e2e
- name: E2E tests (blast radius — manifest-selected specs)
if: steps.scope.outputs.e2e_mode == 'selected'
# OPS72: the PR never runs e2e full — mode `full` (high-risk diff)
# skips this step by design (local mirror + deploy verify cover it).
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# 4 workers in one process replace the former 2 shards × 2 workers.
env:
PLAYWRIGHT_WORKERS: 4
# The `--` separates pnpm from the scope paths: pnpm consumes it and
# playwright receives the bare spec paths (no flag parsing drift).
run: pnpm test:e2e -- ${{ steps.scope.outputs.e2e_specs }}

View file

@ -1,170 +0,0 @@
# Manual production deploy (OPS71 — GitHub Actions). `workflow_dispatch`
# only: there is NO automatic main verifier anymore (ci.yml eliminated in the
# gate 2026-08-19) — publishing is a deliberate operator act, and this
# workflow runs the FULL suite (no scope skips, e2e included) before the
# deploy job touches the homeserver.
#
# TWO jobs, same workflow:
# verify — ubuntu-latest (hosted): full cascade (guards for PR contracts are
# left out — they gate PRs, not production).
# deploy — needs: [verify], runs-on [self-hosted, homeserver]: the GitHub
# self-hosted runner ON the homeserver executes
# scripts/deploy-homeserver.sh LOCALLY (no SSH; outbound-only
# runner, works behind the Cloudflare tunnel). The hosted job
# never reaches the homeserver; the self-hosted job never counts
# hosted minutes.
#
# The production build CANNOT leave the homeserver (migrate pre-build on the
# prod DB — OPS66 — and the compose network / local registry localhost:5000),
# which is exactly why the hosted job only verifies and the self-hosted job
# only builds + deploys. Deploy is idempotent ("already deployed" guard in
# deploy-homeserver.sh) and serialized by flock on the homeserver.
#
# The `deploy` job fetches the repo from github.com (public) — TEQO_REPO_URL
# in deploy-homeserver.sh now defaults there; the script's HEAD guard only
# deploys when the dispatched SHA is main's HEAD (a stale dispatch skips).
name: Deploy (manual)
on:
workflow_dispatch:
permissions:
contents: read
env:
PAYLOAD_SECRET: test-only-secret-not-used-in-production
NEXT_PUBLIC_SITE_URL: http://localhost:3000
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 50
services:
# OPS71 — GitHub hosted: jobs run on the runner VM, so services are
# reached at localhost on PUBLISHED host ports (fixed — each run is its
# own VM). Two isolated services — int on 5432, build+e2e on 5433 —
# keep e2e from inheriting int fixture residue.
postgres-int:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
postgres-build:
image: postgres:17-alpine
env:
POSTGRES_USER: teqo
POSTGRES_PASSWORD: teqo
POSTGRES_DB: teqo_test
ports:
- 5433:5432
options: >-
--health-cmd "pg_isready -U teqo"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Check test file locations
run: node scripts/check-test-locations.mjs
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
- name: Typecheck
run: pnpm typecheck
- name: Knip
run: pnpm knip
- name: Check cycles
run: pnpm check:cycles
- name: Unit tests (full suite)
run: pnpm test:unit
- name: Prepare database URL (int service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" >> "$GITHUB_ENV"
# vitest re-loads .env.test over the process env, so the service URL
# must win through the .env.test.local layer (same mechanism
# `pnpm worktree next` uses).
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test" > .env.test.local
- name: Migrate (int database)
run: pnpm migrate
- name: Seed minimal (int database)
run: pnpm db:seed:minimal
- name: Integration tests (full suite)
env:
# vitest forks ≈ CPUs and each fork holds a Payload pool (max 10) →
# overflows the service Postgres' 100 clients. 4 forks stay well
# under. The unit step above stays CPU-relative (no DB).
VITEST_MAX_WORKERS: 4
run: pnpm test:int
- name: Prepare database URL (build/e2e service)
run: |
set -euo pipefail
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgresql://teqo:teqo@localhost:5433/teqo_test" > .env.test.local
- name: Migrate (build/e2e database)
run: pnpm migrate
- name: Seed minimal (build/e2e database)
run: pnpm db:seed:minimal
- name: Build (against the service database)
run: pnpm build
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright chromium
run: pnpm exec playwright install --with-deps chromium
- name: Build for e2e
# Same dir the Playwright webServer serves (playwright.config.ts
# falls back to '.next-e2e'; gate-ci.mjs builds there too).
run: pnpm build
env:
NEXT_DIST_DIR: .next-e2e
- name: E2E tests (full suite, single job, 4 workers)
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
# No `--` separator: pnpm would forward it verbatim to playwright and
# the flag would be parsed as a file pattern ("No tests found").
env:
PLAYWRIGHT_WORKERS: 4
run: pnpm test:e2e
deploy:
name: deploy homeserver (teqo-1313)
# OPS53 + OPS71. Runs on the GitHub self-hosted runner ON the homeserver
# (labels self-hosted + homeserver): the script executes locally — no SSH
# hop, no workstation involvement. Deliberately wired ONLY here (manual
# dispatch), never in ci-pr.yml: a self-hosted job is code execution on
# the production machine, and only an explicit dispatch with the full
# verify suite green can reach it.
needs: [verify]
if: needs.verify.result == 'success'
runs-on: [self-hosted, homeserver]
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Deploy teqo-1313 on the homeserver
run: bash scripts/deploy-homeserver.sh "$GITHUB_SHA"

View file

@ -1,80 +0,0 @@
# Main merge flips claimed issues to done + in-prod.
# OPS71: the PR lives on GitHub; the ISSUES stay on the Forgejo tracker. The
# job passes the PR body via PR_BODY (GitHub event payload) and
# scripts/forgejo-issue-transition.mjs flips labels/comments and closes the
# issue on the Forgejo API with the FORGEJO_API_TOKEN PAT (the built-in
# GITHUB_TOKEN cannot touch Forgejo issues — OPS61). The fork guard is the
# same class as agent-pr-ready-automerge.yml: on a merged fork PR the checkout
# pulls the fork's own script, which would run with the repo PAT (RCE).
#
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path. The `closed`
# event does NOT create a run when the merge was performed by
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN —
# GitHub's anti-recursion; live finding PR #746). Dispatch with the PR number:
# the job fetches the PR via `gh` (same contract: merged + base main +
# same-repo) and runs the same flip.
name: Main merge flips claimed issues to done + in-prod
on:
pull_request:
types: [closed]
workflow_dispatch:
inputs:
pr:
description: 'PR number (manual recovery when the closed event did not create a run)'
required: true
type: number
permissions:
contents: read
pull-requests: read
jobs:
issue-done:
if: >-
(github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
runs-on: ubuntu-latest
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
# github.com and forgejo-api.mjs would resolve the wrong base (410).
FORGEJO_API_URL: https://git.solla.dev/api/v1
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable package-manager-cache:
# setup-node@v5 defaults to true and looks for pnpm before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
if: github.event_name == 'workflow_dispatch'
id: pr
run: |
PR="${{ inputs.pr }}"
STATE="$(gh pr view "$PR" --json state -q .state)"
if [ "$STATE" != "MERGED" ]; then
echo "PR #$PR não mergeada (state=$STATE) — skip"
exit 0
fi
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
exit 0
fi
BODY="$(gh pr view "$PR" --json body -q .body)"
{
echo "valid=true"
echo "pr_number=$PR"
echo "pr_body<<EOF"
echo "$BODY"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Flip Closes/Fixes #N issues to done and in-prod
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
env:
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
run: node scripts/forgejo-issue-transition.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"

View file

@ -1,88 +0,0 @@
# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues
# that are still awaiting their intention plan (`blocked` + docs/plans/ link)
# to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). OPS71:
# the PR lives on GitHub, the issues on the Forgejo tracker — the job passes
# the PR body via PR_BODY and scripts/agent-promote-related-on-merge.mjs flips
# labels on the Forgejo API (FORGEJO_API_TOKEN; the built-in GITHUB_TOKEN
# cannot touch Forgejo issues — OPS61).
#
# Does NOT run on open PRs — only after merge — so the claim race stays closed.
# Job is gated on Related # in the body so non-plan merges skip checkout/Node.
# Fork guard: on a merged fork PR the checkout pulls the fork's own script,
# which would run with the repo PAT (RCE — same class as
# agent-pr-ready-automerge.yml).
#
# OPS71-FLIP: `workflow_dispatch` is the MANUAL recovery path — the `closed`
# event does NOT create a run when the merge was performed by
# github-actions[bot] (auto-merge armed with the built-in GITHUB_TOKEN;
# GitHub's anti-recursion, live finding PR #746). Dispatch with the PR number:
# the job fetches the PR via `gh` (same contract: merged + base main +
# same-repo) and runs the same promotion.
name: Promote plan-issue Related on main merge
on:
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
inputs:
pr:
description: 'PR number (manual recovery when the closed event did not create a run)'
required: true
type: number
permissions:
contents: read
pull-requests: read
jobs:
plan-issue-ready:
if: >-
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && (contains(github.event.pull_request.body, 'Related #') || contains(github.event.pull_request.body, 'related #') || contains(github.event.pull_request.body, 'RELATED #'))) ||
(github.event_name == 'workflow_dispatch' && inputs.pr != '')
runs-on: ubuntu-latest
timeout-minutes: 5
env:
FORGEJO_API_TOKEN: ${{ secrets.FORGEJO_API_TOKEN }}
# Explicit base: in the GitHub Actions env, GITHUB_SERVER_URL points at
# github.com and forgejo-api.mjs would resolve the wrong base (410).
FORGEJO_API_URL: https://git.solla.dev/api/v1
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v5
# Script is plain Node (stdlib) — no pnpm. Disable
# package-manager-cache: setup-node@v5 defaults to true and looks for
# pnpm (packageManager in package.json) before pnpm/action-setup.
- uses: actions/setup-node@v5
with:
node-version: 24
package-manager-cache: false
- name: Fetch PR metadata (manual recovery — bot auto-merges skip the closed event)
if: github.event_name == 'workflow_dispatch'
id: pr
run: |
PR="${{ inputs.pr }}"
STATE="$(gh pr view "$PR" --json state -q .state)"
if [ "$STATE" != "MERGED" ]; then
echo "PR #$PR não mergeada (state=$STATE) — skip"
exit 0
fi
BASE="$(gh pr view "$PR" --json baseRefName -q .baseRefName)"
REPO="$(gh pr view "$PR" --json headRepository -q .headRepository.nameWithOwner)"
if [ "$BASE" != "main" ] || [ "$REPO" != "${{ github.repository }}" ]; then
echo "PR #$PR fora do contrato (base=$BASE repo=$REPO — precisa main + same-repo) — skip"
exit 0
fi
BODY="$(gh pr view "$PR" --json body -q .body)"
{
echo "valid=true"
echo "pr_number=$PR"
echo "pr_body<<EOF"
echo "$BODY"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Promote Related #N awaiting plan → ready
if: steps.pr.outputs.valid == 'true' || github.event_name == 'pull_request'
env:
PR_BODY: ${{ steps.pr.outputs.pr_body || github.event.pull_request.body }}
run: node scripts/agent-promote-related-on-merge.mjs --pr "${{ steps.pr.outputs.pr_number || github.event.pull_request.number }}"

4
.gitignore vendored
View file

@ -23,7 +23,6 @@
# next.js
/.next/
/.next-e2e/
/.next-e2e-check/
/out/
# production
@ -90,6 +89,3 @@ next-env.d.ts
# Proprietary design font files kept only in the local workspace
/public/arial-mt/
/data/encode/
/data/poster-candidates/

View file

@ -1,7 +1,6 @@
# Build outputs and tool caches
.next/
.next-e2e/
.next-e2e-check/
.pnpm-store/
out/
build/

File diff suppressed because one or more lines are too long

View file

@ -10,8 +10,8 @@ RUN apk add --no-cache libc6-compat && corepack enable
FROM base AS deps
WORKDIR /app
# pnpm-workspace.yaml carries onlyBuiltDependencies — pnpm 10 no longer reads the "pnpm" field.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install the pinned package manager and dependency graph.
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# One-shot image used to apply committed Payload migrations before a release.
@ -32,9 +32,7 @@ ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
# Static generation reads Payload data. BuildKit mounts keep credentials out of
# layers and image history; migrations run separately through the migrator —
# the deploy script applies them BEFORE this build (OPS66: static routes read
# the new schema, so the build must see a migrated DB).
# layers and image history; migrations run separately through the migrator.
RUN --mount=type=secret,id=database_url,env=DATABASE_URL \
--mount=type=secret,id=payload_secret,env=PAYLOAD_SECRET \
NEXT_OUTPUT_STANDALONE=1 \

View file

@ -6,16 +6,16 @@
> **Cheatsheet de operação agentic (humanos)** — o fluxo em 5 linhas:
>
> 1. O agente roda `pnpm agent:claim` (ou `pnpm worktree next --issue N`) e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`). O **tracker de Issues vive no Forgejo** (`git.solla.dev/fsolla/teqo`); o código/PR/CI vive no **GitHub**.
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e `pnpm push -u origin HEAD` — abre a PR para `main` (Ready, `Closes #N`).
> 3. CI verde (`CI (PR) / checks`) → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo** (rebase) — o servidor só mergea com o required check verde.
> 4. Publicar é **manual**: dispatch de `deploy.yml` roda a suíte full (`verify`) e o job `deploy` publica no homeserver (`jorgesolla1313.com.br`, runner self-hosted) — migrações aplicadas antes do rollout. Nada é automático pós-merge.
> 5. Secrets humanos (uma vez): `FORGEJO_API_TOKEN` no GitHub (flips pós-merge no Forgejo); envs de prod em `~/stack/teqo-1313.env` no homeserver. Branch protection de `main` já aplicada (reaplicar: `pnpm configure:branch-protection`).
> 1. O agente roda `pnpm agent:claim` e pega a próxima Issue `ready` por prioridade (`prio:P0..P3`).
> 2. Ele implementa, roda o fast gate (`lint + typecheck + unit`) e abre **PR para `main`** — sozinho.
> 3. CI verde → o PR mergeia em `main` automaticamente; o verificador `ci.yml` roda a suíte completa.
> 4. Suíte verde → Action dispara `vercel deploy --prod` (Vercel Git builds estão desligados).
> 5. Secrets humanos (uma vez): `VERCEL_*`, `POOL_GITHUB_TOKEN`; `pnpm configure:branch-protection`.
>
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | worktree next` e `pnpm db:seed:minimal`.
> Comandos: `pnpm agent:claim | agent:register | agent:prioritize | agent:file-miss | agent:pool` e `pnpm db:seed:minimal`.
> Labels: estado `ready|in-progress|blocked|done|in-prod`, `prio:P0..P3`, `kind:*`, `needs:migration|consent`, `requirements-changed`.
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** deploy (dispatch), envs do homeserver, branch protection, runbook de rollback.
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` · Deploy: `.github/workflows/deploy.yml`.
> **Agente faz sozinho:** claim → implementar → PR → main. **Só humano:** secrets Vercel/pool, branch protection, envs Neon.
> Tudo em detalhe: [`docs/AGENT-OPS.md`](docs/AGENT-OPS.md) · CI: `.github/workflows/ci-pr.yml` + `ci.yml`.
Teqo starts as the official digital platform for **deputado Jorge Solla** and evolves into a **white-label civic engagement platform** for politicians in Brazil.
@ -35,7 +35,7 @@ Initial delivery focuses on Jorge Solla's public website, editorial CMS, and the
- Public-facing content and updates (news/`post` + `tag` live)
- Institutional pages and biography (still pending a `Pages` collection)
- Internal campaign operations: municípios (435, pré-definidos), leaderships, estimates, updates, WhatsApp invites
- Internal campaign operations: electoral nuclei, leaderships, estimates, updates, WhatsApp invites
- Editorial operations through Payload CMS
### Phase 2: White-Label Platform
@ -49,7 +49,7 @@ Teqo then becomes a configurable base product for other politicians in Brazil:
## Local Development
> **Production safety:** the production database is a live Postgres on the homeserver (`teqo_1313`) holding real citizens' data. Local development and tests run against a **local** Postgres and must never point at production. `pnpm dev` refuses to start against a non-local database, and the test suite refuses any database whose name doesn't end in `_test`.
> **Production safety:** the production database is a live Neon Postgres holding real citizens' data. Local development and tests run against a **local** Postgres and must never point at production. `pnpm dev` refuses to start against a non-local database, and the test suite refuses any database whose name doesn't end in `_test`.
### First-time setup
@ -62,8 +62,8 @@ Teqo then becomes a configurable base product for other politicians in Brazil:
DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo
```
5. Load a schema + content into the local database — pick one:
- **Mirror production content (recommended):** `PROD_DATABASE_URL="<prod connection string>" pnpm db:pull`
Copies production's schema and content into local. It only reads production and **excludes supporter PII** (`contact`/`signature`/`subscription` data). The prod string is the `DATABASE_URL` of `~/stack/teqo-1313.env` on the homeserver (Postgres `teqo_1313`).
- **Mirror production content (recommended):** `PROD_DATABASE_URL="<unpooled Neon URL>" pnpm db:pull`
Copies production's schema and content into local. It only reads production and **excludes supporter PII** (`contact`/`signature`/`subscription` data).
- **Empty schema from migrations:** `pnpm migrate`
6. Start the dev server: `pnpm dev`
7. Open: `http://localhost:3000`
@ -84,7 +84,7 @@ curl -X POST "https://<prod-domain>/api/revalidate" -H "x-revalidate-secret: $RE
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" -H "x-revalidate-secret: $REVALIDATE_SECRET"
```
`REVALIDATE_SECRET` must be set in the production env (`~/stack/teqo-1313.env` on the homeserver — see `.env.example`). See the "Posts & Tags" section of `AGENTS.md` for the full model.
`REVALIDATE_SECRET` must be set in the Vercel production env (see `.env.example`). See the "Posts & Tags" section of `AGENTS.md` for the full model.
### Database migrations
@ -97,7 +97,7 @@ To change the schema:
3. Review and commit the generated files in `src/migrations/` (both `.ts` and `.json`, plus `index.ts`).
4. Apply it locally: `pnpm migrate` (check status anytime with `pnpm migrate:status`).
**Deploying to production:** merges to `main` with production changes are deployed by the `deploy` job of `ci.yml` (windowed — see `docs/AGENT-OPS.md`); the remote script (`scripts/deploy-homeserver.sh`) builds on the homeserver and applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout. Do not run migrations against production by hand. Runbook (rollback, known failures): `docs/ops/teqo-1313-deploy.md`.
**Deploying to production:** `pnpm build` runs `payload migrate` before building, so every Vercel deploy automatically applies pending migrations to the production database. Do not run migrations against production by hand.
### Running tests
@ -113,7 +113,7 @@ Then run:
- `pnpm test:e2e` — Playwright (requires the test DB schema and a free port)
- `pnpm test:all` — unit + integration + E2E
Quality gates (also enforced by CI on the Forgejo Actions): `pnpm lint` (zero warnings — `--max-warnings=0`), `pnpm typecheck`, and `pnpm exec knip` (dead files/dependencies fail; delete what your change orphaned). Standards: `.agents/rules/engineering-standards.mdc`.
Quality gates (also enforced by CI on the GitHub mirror): `pnpm lint` (zero warnings — `--max-warnings=0`), `pnpm typecheck`, and `pnpm exec knip` (dead files/dependencies fail; delete what your change orphaned). Standards: `.agents/rules/engineering-standards.mdc`.
## Tech Stack
@ -124,10 +124,10 @@ Quality gates (also enforced by CI on the Forgejo Actions): `pnpm lint` (zero wa
## Campaign (`/campanha`)
Internal campaign tool for municípios (435 pré-definidos), local leaderships, vote estimates, field updates, and WhatsApp invites. Authenticated separately from `/admin` via the `campaignUser` collection (`coordinator` / `advisor` / `leader` / `candidate`). Operational status and decisions: [`.agents/rules/projects/nucleos-eleitorais.mdc`](.agents/rules/projects/nucleos-eleitorais.mdc). Conventions and deploy checklist: `AGENTS.md` (“Campaign auth” / “Campaign Municípios model”).
Internal campaign tool for electoral nuclei, local leaderships, vote estimates, field updates, and WhatsApp invites. Authenticated separately from `/admin` via the `campaignUser` collection (`geral` / `coordenador` / `lideranca`). Operational status and decisions: [`.agents/rules/projects/nucleos-eleitorais.mdc`](.agents/rules/projects/nucleos-eleitorais.mdc). Conventions and deploy checklist: `AGENTS.md` (“Campaign auth” / “Campaign nuclei MVP”).
**Production blocker:** do not load real leadership data or enable invites until counsel-approved `Consent.key = 'lideranca-autopreenchimento'` exists. Absolute invite URLs require `NEXT_PUBLIC_SITE_URL` as an exact HTTPS DNS origin in production.
## Roadmap
Backlog and future plans live as tracked [Forgejo Issues](https://git.solla.dev/fsolla/teqo/issues) (spec + deps + prio + model per issue; flow skills: `plan-issue` → `work-issue` → `project-status`; `docs/roadmap.md` is a frozen legacy stub).
Backlog and future plans live as tracked [GitHub Issues](https://github.com/fsolla/teqo/issues) (spec + deps + prio + model per issue; flow skills: `plan-issue` → `work-issue` → `project-status`; `docs/roadmap.md` is a frozen legacy stub).

View file

@ -1,27 +1,27 @@
# AGENT-OPS — operação do paradigma de agentes paralelos
Uma página. Norte: simplicidade (Forgejo = tracker, GitHub = Actions), agente entrega em `main`, deploy de produção é **manual** com verificação full (OPS71).
Uma página. Norte: simplicidade (só GitHub + Cursor), agente entrega em `main`, deploy de produção só após o verificador full-suite verde.
## Ambientes de dados (test ladder — nunca misturar)
| Ambiente | Conteúdo | Quem usa |
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------- |
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `deploy.yml` (OPS53 + OPS71) |
| Ambiente | Conteúdo | Quem usa |
| ------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
| **Mínimo** (`teqo_test` local / service CI) | schema migrado + `pnpm db:seed:minimal` (sintético, sem PII) | agentes, Cursor Cloud, CI |
| **Prod** (`teqo_1313`, homeserver) | real | job `deploy` do `ci.yml` (OPS53) |
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
Agentes **nunca** recebem `DATABASE_URL` de prod e nunca setam `ALLOW_REMOTE_DB`. Não há mais smoke Neon `stage` / `ALLOW_STAGE_TEST_DB` / `ci-stage.yml`.
## Fluxo
```text
claim → feature branch → PR --base main (GitHub) → CI PR green (cascade + skips) → auto-merge nativo em main (rebase)
main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full suite verde → job deploy no homeserver (OPS53)
claim → feature branch → PR --base main → CI PR green (cascade + skips) → auto-merge em main
main → ci.yml full suite verde → job `deploy` (homeserver, OPS53 — merge em main == site atualizado)
```
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` (GitHub) → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = Issues do Forgejo (`git.solla.dev/fsolla/teqo`).
**Skills:** `plan-issue` (intenção + Issues) → `work-issue` (humano: Issue já claimada → impl plan → confirmação → execução) ou `agent-work-issue` (pool: já claimada → impl plan → execução sem pausa) → `/simplify` → `capture-review-debts` → PR `--base main` → `project-status`. `docs/roadmap.md` = legado congelado; fonte canônica = GitHub Issues.
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **e2e local afetado** (OPS72, discricionário: roda os e2e que criou + os da mesma superfície — `pnpm test:e2e:affected`; ver skill) → **`pnpm push`** (origin = GitHub) → PR via `node scripts/github-pr.mjs` **Ready** (nunca draft) com `Closes #N` → o safety net `agent-pr-ready-automerge.yml` arma o **auto-merge nativo do GitHub** (`enablePullRequestAutoMerge`, rebase) — o servidor mergea quando o required check `CI (PR) / checks` fica verde. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false` (o default draft da tool **não** vale neste repo) — o merge continua pelo safety net.
- **Só humano:** envs de produção no homeserver (`~/stack/teqo-1313.env`, fora do repo — o deploy as lê via BuildKit secrets), `GITHUB_TOKEN` local (PAT) para os scripts GitHub, secrets no repo GitHub, `pnpm configure:branch-protection` (idempotente; reaplicar se o drift voltar), runbook manual/rollback (`docs/ops/teqo-1313-deploy.md`).
- **Agente faz sozinho:** claim (pool-supervisor no pool; humano via `pnpm agent:claim` fora da sessão — OPS33: `worktree next` claima) → implementa → **`pnpm push`** → PR **Ready** (nunca draft) com `Closes #N` → `gh pr merge --auto --rebase` → `gh pr checks --watch --required`. Regra always-on: `.agents/rules/agent-pr-workflow.mdc`. Em Cursor Cloud: `ManagePullRequest` com `draft: false`, depois armar auto-merge via `gh pr merge --auto --rebase` (o default draft da tool **não** vale neste repo).
- **Só humano:** secrets Vercel (`VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`), `POOL_GITHUB_TOKEN`, `pnpm configure:branch-protection`, editar envs Neon/Vercel.
### Dono do PR, dono do CI
@ -30,35 +30,34 @@ main → (opcional, deliberado) workflow_dispatch deploy.yml → verify full sui
### Bypass de CI (cutover / transição)
- **Local:** se `gate:push` ainda espelha pipeline antigo e bloqueia push legítimo da PR que o muda → `git push --no-verify` permitido como escape de transição (não WIP permanente). Documentado aqui; após o cutover, entregas passam no CI novo sem bypass.
- **Cutover humano:** pode relaxar temporary required checks se o flip de proteção travar merges; reverter para `CI (PR) / checks` em `main` assim que o CI novo estiver verde.
- **Cutover humano:** pode relaxar temporary required checks se o flip de proteção travar merges; reverter para `checks` em `main` assim que o CI novo estiver verde.
## Comandos
| Comando | Faz |
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief (Forgejo) |
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue no Forgejo (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
| `pnpm agent:status` | Overview / fila / mermaid |
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud **dormente (OPS65)** — o workflow `agent-pool.yml` foi removido; dispatch falha 404; scripts/skill ficam para histórico |
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push para o **GitHub** (origin) |
| `GITHUB_TOKEN=… node scripts/github-pr.mjs --head <b> --title <t> --body-file <f>` | Abre PR no **GitHub** (Ready, base `main`, nunca draft) |
| `GITHUB_TOKEN=… pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main` no **GitHub**: required check `CI (PR) / checks` (idempotente — cria/atualiza/no-op) |
| `pnpm db:seed:minimal` | DB mínimo sintético |
| Comando | Faz |
| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
| `pnpm agent:status` | Overview / fila / mermaid |
| `pnpm agent:prioritize -- <issue> <P0..P3>` | Troca `prio:*` |
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks`, `strict=false`, 0 reviews |
| `pnpm db:seed:minimal` | DB mínimo sintético |
Labels (Forgejo): `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
**plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`.
## Contrato de PR
1. Schema → atualizar `db:seed:minimal` no mesmo PR (`needs:migration`).
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR; rebase antes de `migrate:create` continua obrigatório.
3. Ready + auto-merge: `pnpm push -u origin HEAD` (GitHub) → PR via `node scripts/github-pr.mjs` (Ready, base `main`, `Closes #N`) → o safety net `agent-pr-ready-automerge.yml` (`scripts/github-pr-automerge.mjs`) arma o **auto-merge nativo do GitHub por rebase** — o servidor espera o required check `CI (PR) / checks` e mergea; nada a armar por PR, não há `gh` (OPS50) e não há poll (OPS71). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo do auto-merge.
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). **Desde OPS63 os mesmos três checks rodam também no pre-push local** (`gate:push` → `gate:ci`, fase 1 — mesmos scripts, default `origin/main`). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). **O escape é CI-only (body do PR):** no pre-push local a restauração legítima falha com a mesma mensagem do CI — bypass documentado é `git push --no-verify` direto (o CI, com o escape no body, continua sendo a via da restauração).
2. Migrations NÃO são serializadas entre PRs: o CI (migrate + int, incl. `campaignMigrationReconciliation`) valida a cadeia em todo PR e na main; rebase antes de `migrate:create` continua obrigatório.
3. Ready + auto-merge: `gh pr create --base main` → `gh pr merge --auto --rebase`; `gh pr checks --watch --required` (Vercel Git não é gate). **Merge é por rebase** (nunca merge commit): preserva a linha histórica limpa do `main` e é o modo que o CI guarda contra conflitos latentes.
4. **Changelog da entrega (OPS44):** toda entrega escreve `docs/changelog/<data>-<id>.md` (ex. `2026-08-13-ops44.md`) e roda `pnpm changelog:build` antes do push — o agregado `docs/CHANGELOG-AGENTS.md` é **insert-only** (nunca remove/reescreve entradas; entradas históricas não são migradas). Guard de CI `docs-guards`: (a) agregado é append-only (multiset sobre blobs — perda de linha falha); (b) `docs/changelog/` é additions-only (M/D falham); (c) agregado está up to date com `docs/changelog/` (`changelog:check`); (d) nenhum marcador de conflito em diffs de markdown (docs/, AGENTS.md, .agents/ — cobre PRs docs-only, onde a suíte unit não roda). Restauração legítima (header muda, restauração estilo D8) escapa escrevendo **`changelog-rewrite: <motivo>` como linha própria** no body do PR — definição canônica aqui; o texto do checkbox do PR template nunca ativa (linha-ancorado). A própria troca `--merge` → `--rebase` deste doc usou o escape — header do agregado mudou.
## Registries compartilhados (serialização por arquivo, não por PR)
@ -68,54 +67,38 @@ Arquivos de "lista" que N agentes escrevem em paralelo (changelog, plans, migra
- **`serializes` no plano de intenção** nomeia o registro exato que a Issue toca (ex. `serializes: 'docs/plans/'`), para o executor agendar com clareza — é dica de ordem, não lock (rebase continua obrigatório).
- O rebase (Contrato de PR item 3) é o mecanismo real de conciliação: conflitos de append resolvem com `git checkout --ours/--theirs` por lado.
## CI por alvo (GitHub Actions, OPS71)
## CI por alvo
| Workflow | Trigger | Banco | Passos |
| ------------------------------------ | --------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ci-pr.yml` | PR → `main` (same-repo) | 2× service Postgres 17 | Job único `checks` sequencial (OPS62 X1): guards → lint→format→typecheck→knip→cycles→unit→int→build→e2e; skips via `ci-scope.mjs` (step `scope`); **fail-fast estrutural** — step vermelho interrompe o job, o run falha; `concurrency` nativo cancela o run em push novo; e2e **apenas blast radius** (`selected`, nunca `full` — OPS72) em 1 processo com `PLAYWRIGHT_WORKERS=4`; 2 services por nome na rede do job (`postgres-int`/`postgres-build`, sem publish host); fork-PR excluída no `if:` do job (repo público — não queima minutes) |
| `deploy.yml` | **`workflow_dispatch` manual** | 2× service Postgres 17 | **Dois jobs:** `verify` (hosted, suíte **full** sem skips — lint→format→typecheck→knip→cycles→unit→int→build→e2e full) → `deploy` (`needs: [verify]`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no homeserver (sem SSH; idempotente; HEAD guard; flock; migrator→migrate→runner→rollout→smoke) |
| `issue-done-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Closes`/`Fixes` → `done` + `in-prod` + **close** **no Forgejo** (PAT `FORGEJO_API_TOKEN`; body do PR via `PR_BODY` do evento GitHub — no dispatch, busca via `gh` com o mesmo contrato: merged + main + same-repo) |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` (same-repo) + **dispatch manual (recovery)** | — | `Related #N` aguardando plano → `ready` **no Forgejo** (OPS18; soft-skip; mesmo PAT) |
| `agent-pr-ready-automerge.yml` | PR same-repo → `main` (open/sync/…) | — | Safety net via `github-pr-automerge.mjs` (plain Node, REST+GraphQL): draft `cursor/*` → Ready (PATCH); arm `enablePullRequestAutoMerge` (rebase) **com o `AUTOMERGE_PAT`** (fail-closed — nunca o `GITHUB_TOKEN` nativo, cujo merge como `github-actions[bot]` não cria runs no `closed`; OPS71-FLIP) — o **servidor** espera o required check `CI (PR) / checks` (o OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto); draft não-`cursor/*` = veto, skip |
| `archive-cursor-agent.yml` | dispatch manual (ops) | — | Helper: cancela run + arquiva um agente Cursor Cloud (`CURSOR_API_KEY`) — **dormente (OPS65)** como o pool |
| Workflow | Trigger | Banco | Passos |
| ------------------------------------ | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` |
| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho; rollup `checks`; job `deploy` (runs-on `host`) publica no homeserver via `scripts/deploy-homeserver.sh` (OPS53) |
| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` |
| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) |
| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --rebase` (safety net; audit incluso) |
| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) |
`agent-pool.yml` foi **removido no OPS65** (pool dormente; o tick `*/10` custava 144 `pnpm install`/dia na workstation). O verificador de main (`ci.yml`) foi **eliminado no OPS71** (gate 2026-08-19): só o `verify` do dispatch manual roda a suíte full.
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `actions/cache@v4`.
Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`.
Fast gate: `pnpm gate:fast`. Push: `pnpm push`.
### Secrets (humano, uma vez — repo GitHub)
### Secrets (humano, uma vez)
| Secret | Uso |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FORGEJO_API_TOKEN` | Scripts Forgejo (`forgejo-api.mjs`) e flips pós-merge (`issue-done`, `plan-issue-ready`) — o `GITHUB_TOKEN` nativo do GitHub não toca Issues do Forgejo (OPS61) |
| `AUTOMERGE_PAT` | **Arm do auto-merge nativo** (`agent-pr-ready-automerge.yml`) — PAT de usuário real (fsolla) com `pull-requests: write` + `contents: write` no repo. **Sem ele o job falha fail-closed**: o `GITHUB_TOKEN` nativo armado faria o merge como `github-actions[bot]` e o `closed` do PR não criaria runs (anti-recursão do GitHub) — os flips pós-merge ficariam silenciosamente mortos (OPS71-FLIP, PR #746) |
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) — **dormente (OPS65)**; só o helper `archive-cursor-agent.yml` a usa hoje |
| Secret | Uso |
| ------------------- | ------------------------------------------------------------------------------------ |
| `FORGEJO_API_TOKEN` | Supervisor do pool e scripts Forgejo (`forgejo-api.mjs`) |
| `CURSOR_API_KEY` | Supervisor do pool (Cursor Cloud) |
| `POOL_GITHUB_TOKEN` | PAT `actions:write` + `issues:write` (variables do pool; `GITHUB_TOKEN` costuma 403) |
Local: `GITHUB_TOKEN` (PAT com escopo `repo`) para `node scripts/github-pr.mjs`, `pnpm configure:branch-protection` e qualquer script GitHub — o push SSH usa a chave `~/.ssh/id_ed25519` registrada no GitHub. Envs de produção (DB, S3, VAPID, `REVALIDATE_SECRET`, `NEXT_PUBLIC_SITE_URL`) vivem em `~/stack/teqo-1313.env` no homeserver — nunca como secrets do GitHub. `POOL_GITHUB_TOKEN` é **legado** (era da transição GitHub pré-OPS50).
### Flips pós-merge (OPS71-FLIP): o evento `closed` e a recuperação manual
O flip `issue-done`/`plan-issue-ready` roda no `pull_request: closed` do PR no GitHub — mas **o merge feito pelo auto-merge armado com o `GITHUB_TOKEN` nativo é atribuído a `github-actions[bot]` e NÃO cria workflow runs** (anti-recursão documentada do GitHub: eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline do PR com 0 runs — achado ao vivo no PR #746). Isso só não acontece porque o arm agora usa o `AUTOMERGE_PAT` (merge atribuído a usuário real → runs criados, comportamento do PR #742).
**Se mesmo assim o flip não rodou** (evento perdido, run falhou, PR mergeado por bot antes do fix): o workflow tem **`workflow_dispatch` de recuperação** — dispatch de `issue-done-on-main-merge.yml` (ou `plan-issue-ready-on-main-merge.yml`) com o input `pr` = número do PR; o job busca o PR via `gh` e aplica o mesmo contrato do evento (merged + base main + same-repo, senão skip). Fallback local (sem GitHub): `PR_BODY='Closes #N' node scripts/forgejo-issue-transition.mjs --pr <N>` com o `GITHUB_TOKEN` do ambiente (PAT). O flip agora também **fecha** a Issue (`PATCH state=closed` — o merge no GitHub não fecha Issues do Forgejo como o merge nativo da era Forgejo fazia). Falha em qualquer passo = job vermelho (OPS61, nunca mentir); o `forgejo-api.mjs` retenta 410/5xx transitórios (OPS67 + OPS71-FLIP: 410 do proxy retenta em qualquer método — a origem nunca viu o request).
Branch protection de `main` no **GitHub** (aplicada no cutover do OPS71, via `pnpm configure:branch-protection`): required check `CI (PR) / checks` (a UI exibe workflow/job; o **literal de match é `checks`** — o GitHub casa pelo nome do check-run — pin ao vivo PR #742), `strict=false`, 0 reviews, `enforce_admins: true` (nem admin mergea com CI vermelho — mesmo invariante do rule-based Forgejo). O auto-merge nativo espera esse mesmo check antes de mergear; a regra no servidor é o gate real de qualquer caminho de merge, inclusive API manual. Reaplicar/consertar drift: `pnpm configure:branch-protection` (idempotente).
Deploy (OPS53 + OPS71): **manual** via `workflow_dispatch` no `deploy.yml` — o job `verify` (hosted, suíte full incl. e2e full) roda **antes** do job `deploy` (`needs: [verify]`), que executa `scripts/deploy-homeserver.sh <sha>` no runner self-hosted do GitHub **no homeserver** (conecta outbound ao GitHub — funciona atrás do Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não conta minutos hosted). O script: HEAD guard (`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público) → flock → guard "already deployed" (revision do container) → build do migrator (BuildKit, secrets de `~/stack/teqo-1313.env`) → push/tag em `localhost:5000` → swap dos tags no compose (backup) → **migrations** (`teqo-1313-migrate` — antes do build do runner, OPS66) → build do runner (contra o banco JÁ migrado) → rollout → healthcheck → smoke. Falha = job vermelho; nada é publicado pela metade. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel foi removido no OPS50; o verificador automático de main foi eliminado no OPS71 — se um dia o merge em main quiser rede de regressão, o `ci.yml` volta com 1 linha de trigger (sem deploy).
Deploy (OPS53): o job `deploy` do `ci.yml` roda no host do runner (workstation) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH — build no homeserver (secrets de `~/stack/teqo-1313.env`), registry `localhost:5000`, migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout, smoke pós-deploy. Nenhum secret novo no Forgejo. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. O deploy Vercel (`vercel-promote.yml`, scripts, `vercel.json`) foi removido no OPS50.
## Cursor Cloud
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima. PR via `ManagePullRequest` (draft: false) — o merge continua pelo safety net do GitHub.
`.cursor/environment.json` instala deps + Postgres nativo + `db:seed:minimal`. Sem secrets de prod. Preferir `pnpm push`; escape `git push --no-verify` só no cutover documentado acima.
## Agent pool
**Dormente (OPS65)** — o workflow `agent-pool.yml` foi removido e o dispatch
via CLI falha 404; scripts/skills ficam para histórico. O que foi:
Supervisor remoto (`agent-pool.yml`): schedule + replenish em merge → `main` +
dispatch. Workers PR `--base main`. Default model `composer-2.5`. Doctor:
`pnpm agent:pool -- doctor`. Skill: `agent-pool`.
Supervisor remoto (`agent-pool.yml`): schedule + replenish em merge → `main` + dispatch. Workers PR `--base main`. Default model `composer-2.5`. **Secret `POOL_GITHUB_TOKEN`** obrigatório se `GITHUB_TOKEN` 403 em `/actions/variables`. Doctor: `pnpm agent:pool -- doctor`. Skill: `agent-pool`.
## Guardrails / leitura

View file

@ -4,108 +4,14 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).
**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill).
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. O diagnóstico mostrou que a premissa do plano (fix nunca tinha chegado a prod) estava errada em dois pontos: (1) o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap no histórico, mas o commit S9 (`8d2352c0`) regenerou o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo `getFromImportMap: PayloadComponent not found` (admin em branco de novo); (2) o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71) — o GitHub tinha o fix + OPS71, mas não as features S8 ("Nossa história") e S9 (captura de novidades) que produção já rodava. A entrega (PR #751): re-home do S8+S9 no GitHub main (cherry-pick `8d2352c0`+`488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); deploy via `deploy.yml` no runner self-hosted do homeserver (`dba75ff8`), após 2 "stale run" por merges paralelos no verify; migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: revision `dba75ff8`, healthy, **log sem `getFromImportMap`**, `/admin` renderizando o login (2 inputs + botão, título "Login - Payload", 0 erros de console), home mantendo S8/S9. Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições: regeneração do importMap sem envs `S3_*` re-órfão o handler (classe do guard OPS70/#87); divergência de plataformas pós-cutover (workspace homeserver ainda aponta Forgejo local; S10 pixel pendente de re-home).
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs envs de S3 (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH*RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs `S3*\*` (classe OPS69 — o guard do #77 cobre; restaurado na entrega com envs dummy).
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, HIGH_RISK → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc/unit verdes; `test:e2e:affected` com modo `none` (diff sem blast radius e2e); CI do PR valida (e2e não roda — docs/skills/workflow não são superfície e2e). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`.
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.
**S9 — captura de novidades da campanha na home pública (2026-08-19):** a home de campanha (`jorgesolla1313.com.br`) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora `#novidades`, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é `Contact` + `Subscription` em transação com consentimento NOVO por chave estável `campanha-novidades` (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como `subscription.campaignLevel` (`time|esporadico`, coluna própria na lista do admin). Migração `20260819_192605_add_campaign_newsletter_capture` (DROP NOT NULL em `contact.state` — estado opcional no form, ficha staff via zod segue exigindo — + coluna `campaign_level` com enum). Precedente `submitWhatsapp` preservado; action nova usa `withPayloadTransaction` (o dono do concern de transação, não o hand-roll gêmeo — `submitWhatsapp`/`submitPetitionSignature` ficam como débito de alinhamento). S10 (pixel Meta) dispara `Lead` na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.
**S8** — seção "Nossa história" na home de campanha (jorgesolla1313.com.br): o vídeo da trajetória do Dep. Jorge Solla (~1:40) ganha lugar próprio na home, após a seção "Bandeiras" e antes do rodapé — eyebrow "Nossa história" + título "Conheça a trajetória de Jorge Solla" + frame vertical 9:16 (desktop 2 colunas, copy à esquerda e frame 300px à direita centralizados; mobile empilhado, frame full-width cap 420px). **Divergência aprovada em execução pelo humano:** o vídeo é embutido do canal do deputado no YouTube (Short `i_fbclWWC5o`) em vez do asset self-hosted do plano de intenção — cada play embutido conta view no canal; `youtube-nocookie` (LGPD), `loading="lazy"` nativo (zero bytes até o frame se aproximar do viewport), `playsinline`, `allowfullscreen`, `title` a11y, `allow` com `compute-pressure` (silencia o console.error do player — o guard do e2e falha em console.error); CTA "Assistir à história" rola até o player (play cross-origin não é disparável). Os encodes one-off H.264/AV1 + poster da foto JOA00327 (14.9 MB, validados por design-vision contra o orçamento de 15 MB da intenção) ficam como artefato local gitignored em `data/encode/` — fallback pronto se um dia se voltar a self-hosted. Zero schema/migration/Consent; nenhuma seção existente tocada. e2e novo (posição, copy, atributos do embed, CTA→viewport, layout desktop 2 colunas com centro vertical, 9:16 mobile sem overflow) + abort global do `youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI). Verificação: unit 2416 + int 791 verdes, tsc/lint/format/cycles/build verdes; e2e frontend 20/20; knip = limitação P3 pré-existente. /simplify: 3 revisores paralelos absorvidos (JSDoc enxuto, e2e sem assert redundante, plano sem conteúdo morto, desktop asserts); 2 débitos deferidos no impl plan (rótulo do CTA se a assessoria reabrir copy; dedupe da classe do CTA com `CampaignContentSection` quando S5 #33 terminar).
**OPS69** — o admin do Payload abria **em branco em produção** (P0, 2026-08-19): o `importMap` commitado (`src/app/(payload)/admin/importMap.js`) ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — o commit OPS52 (`dabc00f4`) removeu o handler do Vercel Blob e **não** adicionou o do S3, porque o `pnpm generate:importmap` rodou **sem** as envs `S3_*`: com o plugin `s3Storage` condicional, sem as envs o importMap sai consistente (dev/test, storage local) e com elas (produção, Garage) o admin precisa do handler e o montava → `getFromImportMap: PayloadComponent not found in importMap` → login nunca renderizava. Fix mínimo (opção A do plano, aprovado no gate): regenerar o importMap com as 4 `S3_*` dummy inline (a mecânica do CLI: `iterateConfig` sem DB; `loadEnvConfig` do `@next/env` não sobrescreve envs já no processo) → diff de **exatamente +2 linhas** (import + entrada do `S3ClientUploadHandler`), nenhum outro drift. Verificação: smoke headless com S3 ativo no dev local — `/admin/login` renderiza (6 inputs, formulário visível, **zero erros de console**) vs. o branco reproduzido no diagnóstico; gates completos verdes (tsc/lint/format/knip/cycles, unit+int 791, build). Sem migration/access/Consent/UI. Docs: linha no AGENTS.md (checklist local: `generate:importmap` precisa das `S3_*` setadas — valores dummy ok — enquanto o plugin for condicional; sem elas o importMap fica órfão, classe OPS69) + item sucessor registrado (guard de CI que regenera com envs dummy e falha o gate se o arquivo commitado divergir — `depends` #77). Verificação pós-deploy: `/admin` abre o login e o log do container não re-emite o `getFromImportMap` para o handler (runbook `docs/ops/teqo-1313-deploy.md`).
**OPS62-FOLLOWUP** — flake do `municipalitiesWithoutUpdateTool.unit.spec.ts` (2 ocorrências 2026-08-18: gate local 19:22 e CI run 779 23:53) eliminado pela raiz, com a hipótese original da Issue refutada por reprodução. Causa raiz: o fixture do teste (não o ambiente nem locale/ICU): `daysAgo()` chamava `Date.now()` **por chamada**; as duas construções `daysAgo(40)` de `Beta`/`Alpha` podiam cair em milissegundos diferentes sob carga (GC/preempção do fork do vitest na máquina compartilhada), deixando o timestamp de `Alpha` estritamente mais novo — em `sortStale` (`getMunicipalitiesWithoutUpdate.ts`) o sort por timestamp vencia o tie-break por nome e o teste recebia `[Beta, Alpha]` no lugar de `[Alpha, Beta]` (só o par trocado, resto correto — "sort não atuou" estava errado; o sort atuou sobre timestamps acidentalmente desiguais). Repro mecânica (sort de produção verbatim + fixture): fixture atual 0.08% de falhas em 200k runs sem pausa e **100%** com pausa de 1 ms injetada; fixture ancorado 0 em 2000 runs. Fix (test-only, nada em produção — o sort real é determinístico sobre timestamps fixos do DB): âncora `fixtureNowMs = Date.now()` no load do módulo (padrão do `prioritiesTool.unit.spec.ts`) + hoist do timestamp empatado (`const stale40 = daysAgo(40)` compartilhado por `Beta`/`Alpha` — igualdade estrutural, imune até a regressão da âncora) + comentário explicando a classe de flake. Scan da mesma classe: `prioritiesTool` (âncora no load) e `suggestionCatalog` (now fixo) já seguros; nenhum outro spec combina `Date.now()` por chamada com assunção de igualdade. Verificação: 30/30 runs do spec, unit full (249 arquivos/2405 testes), int (89/791), tsc, lint, format, check:cycles verdes (knip só com o P3 ledgered de sempre). /simplify: 3 reviewers aprovaram; débito absorvido no próprio teste (comentário do porquê). Impl plan: `docs/plans/ops62-followup-ci-flake-fixture-timestamps-impl.md`.
**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução).
**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live`para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper`relative`(botão dentro do anchor seria HTML inválido); o conteúdo do popover usa`ui/Popover`(Radix, portal — escapa o`overflow-x-auto`do carrossel mobile que cliparia um menu absoluto), sem roles`menu`/`menuitem`(a semântica honesta de popover com 2 ações — Radix já emite`aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts`(prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta,`target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo).
**S6-FOLLOWUP** — CLI Playwright 1.58.2 e filtros e2e locais: `pnpm test:e2e:affected` rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e `-g`/`--list` funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (`setup ← campaign ← frontend ← admin` em `playwright.config.ts`): `collectProjectsAndTestFiles` filtra os arquivos por project, mas `buildProjectsClosure` repõe todos os arquivos de cada project-dependência — filtrar um spec do `frontend` puxava `campaign` (160) + `setup` (2), 177 no total com o próprio arquivo (15); `admin` puxava a suíte inteira. Em prod mode (`CI=1`) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: `run-e2e-affected.mjs` agora gera `test:e2e <passthrough verbatim> --no-deps -- <paths>` via helpers puros novos em `scripts/lib/playwright-e2e-args.mjs` (unit-testados, `tests/unit/playwrightE2eArgs.unit.spec.ts`) — `--no-deps` desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (`pnpm test:e2e:affected -- tests/x.spec.ts` descartava o path — pnpm consome o `--` e o argv nunca o continha; agora `parsePassthroughArgs` só remove um `--` líder e repassa o resto verbatim, preservando `-g grade`). Hardening: `scripts/run-e2e-affected.mjs` entrou em `HIGH_RISK_EXACT` (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no `playwright.config.ts` (posicional exige `--no-deps`; `--project=<família> --no-deps`; `-g`/`--list` funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (`--shard`) ou `gate:ci`. Verificação: unit 2343+ verdes; contagem por `--list` do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca `playwright.config.ts` e `run-e2e-affected.mjs`, high-risk). Débito absorvido: hang do `pnpm test:e2e` via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: `docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md`.
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.
**S3** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão `src/utilities/socialFeed/instagramFeed.ts` (os feeds externos ganharam o subfolder `socialFeed/` — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; `git mv` do youtubeFeed + imports atualizados): Graph API (`GET /{ig-user-id}/media` com `children{media_url,thumbnail_url}` p/ carrossel + `GET /{ig-user-id}?fields=username`), `unstable_cache` tag `social-feed` + `revalidate: 300`, snapshot cru (`{ username, posts }`, pré-exclusão) persistido via SQL direto (padrão anti-`revalidateTag`-no-cache da S2), exclusões aplicadas na leitura, **refresh de token automático** (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global `SocialFeedSettings` ganha `instagramEnabled/AccessToken/UserId/MaxItems` + `instagramFeedSnapshot` (hidden) — `excludedItems` já cobria `platform: 'instagram'` desde a S2, sem migration no mecanismo; migration `20260818_192948_add_instagram_social_feed_fields` não destrutiva (DDL revisado, sem o drift do `migrate:create` da S2). **Picker de exclusão no admin** (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (`src/components/admin/InstagramPostExclusionPicker.tsx`, campo `ui` na global, path string + importmap regenerado — o import direto de `@payloadcms/ui` na config quebra o `generate:importmap` no tsx por causa do `.css` do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em `excludedItems` via `useField`. UI: `CampaignContentCardData.coverAspect` (`'video'` default — S1/S2 intactos; `'square'` 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no `permalink` (nova aba, `noopener`), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha `https://*.cdninstagram.com/**`. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com `fetchImpl`); e2e: stub irmão `tests/e2e/instagram-stub.mjs` (porta dev+2000; a derivação da porta virou `tests/helpers/socialStub.ts` — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do `excludedItems`). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com `--no-deps` o `seedTestUser` dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — `el.click()` e o runner real funcionam).
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções `toHaveCSS` no e2e full-state da seção (fundo `#ebe9e9` exato — pega token quebrado em vez de só "≠ branco", hairline top `1px` com cor `rgb(0 0 0 / 12%)` exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções de estilo computado no e2e full-state da seção (fundo ≠ branco ≠ prova, hairline top presente, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts`; a home ganhou `revalidate = 300` para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção).
**PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate.
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.
**Recently resolved (2026-08-19):** **OPS68** — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. **12 arquivos, 78+/82−, edições linha-a-linha:** `docs/AGENT-OPS.md` (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem `gh` — criação via API/MCP + merge pelo safety net `agent-pr-ready-automerge.yml`, o único que espera o rollup `CI (PR) / checks`; "Só humano" = envs de `~/stack/teqo-1313.env` + branch protection + runbook; tabela de secrets sem `POOL_GITHUB_TOKEN` (legado — o script o ignora) e com nota de pool dormente; linha `archive-cursor-agent.yml` na tabela CI; "smoke Neon stage" → sem smoke stage), `README.md` (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; `PROD_DATABASE_URL` = `DATABASE_URL` de `~/stack/teqo-1313.env`; migrações no deploy via `teqo-1313-migrate`; "GitHub mirror" → Forgejo Actions; papéis `coordinator/advisor/leader/candidate`; "electoral nuclei" → municípios; Issues → link Forgejo), `docs/roadmap.md` (só o ponteiro da fonte canônica — o stub continua congelado), `AGENTS.md` (refs Neon → homeserver `teqo_1313`; job do gate `check`→`gate`, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), `.agents/rules/agent-pr-workflow.mdc` (comandos `gh pr create/merge/checks` morrem — `gh` não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no `ci.yml`), e as skills `work-issue` (SKILL + `execution-pipeline`), `plan-issue` (frontmatter, gate "nada no Forgejo", dedup via `pnpm issue all`), `project-status`, `capture-review-debts`, `engineering-audit` (`gh issue list` → `pnpm issue all`/API; `issue-done-on-stage-merge.yml`→`on-main`; merge `stage`→`main`), `worktree-next-issue` ("resto do Forgejo") e `agent-pool` (descrição Forgejo Actions; workflow marcado removido OPS65; estado em `pool-state.json`, não mais variables). **Mantido como registro histórico** (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: `git.solla.dev/fsolla/teqo`. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes `gh pr`/`gh issue`/`github.com/fsolla`/`GitHub Issues`/`GitHub mirror`/`POOL_GITHUB`/Neon-em-uso; docs-only → CI roda os docs-guards.
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.
**Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/<filename>` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte.
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local; `--verify` dispensa S3 — só HEAD de URLs públicas), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint/origin), timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) e nos fetches (30s/60s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan), e covers constrangidas a http(s) no host do WordPress com só `image/*` armazenado (sem SSRF/objeto não-imagem no proxy). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`; exit 1 se qualquer checagem falhar), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.
**Recently resolved (2026-08-18):** **OPS54** — fim do warning do pnpm sobre o campo `pnpm` ignorado em `package.json`. A allowlist `onlyBuiltDependencies` (`sharp`, `esbuild`, `unrs-resolver`) migrou para o home canônico do pnpm 10: novo `pnpm-workspace.yaml` na raiz (sem `packages` — repo single-package, default `['.']`), e o bloco `"pnpm"` saiu do `package.json` (pnpm 10 não o lê mais; sem a remoção o warning persiste). `Dockerfile` estágio `deps` agora copia `pnpm-workspace.yaml` junto (`COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./`) — sem isso a allowlist não chegaria ao install da imagem e local×Docker divergiriam nos build scripts. Validado com a versão pinada (10.11.0) e no estágio deps real (`docker build --target deps`): nenhum `[WARN]` do campo `pnpm` em install/CI/Docker, `pnpm config get onlyBuiltDependencies` → os 3 pacotes, `--frozen-lockfile` limpo (lockfile e grafo intocados). Guardrails respeitados: `.npmrc` (`legacy-peer-deps`, `include`) e `packageManager` intocados; nada além do que o warning citava foi migrado (o aviso informativo de `msw`/`onnxruntime-node` é comportamento padrão do pnpm 10 — fora de escopo por anti-goal). Sem migration, sem UI, sem testes de domínio (config de ferramenta; gates completos verdes). Repo: `docs/plans/ops54-pnpm-warning-onlybuiltdependencies-impl.md`.
**Recently resolved (2026-08-18):** **OPS56**: o `forgejo-pr-automerge` nunca mais reporta falso positivo "já mergeado" com PR aberto. Raiz confirmada no source do servidor (Forgejo 9.0.3, tag v9.0.3): o endpoint `POST /pulls/{n}/merge` responde **200 com corpo vazio** no sucesso (falhas são 404/405/409/500), e o `request()` do `forgejo-api.mjs` convertia isso em `null` — o `autoMerge()` devolvia `null` cru e o CLI imprimia "já mergeado" (exit 0) independente do estado real do PR; o incidente (PR #27 da OPS52, aberto das 01:59 às 02:45) deixou o merge falhar silenciosamente com o PR aberto. Correção: o veredito de `autoMerge` agora é **re-read do PR** após a tentativa (`{ attempted, merged, pr }`) — 200-vazio → confirma no `GET /pulls/{n}`; POST falhou e o PR segue aberto → **re-throw** (falha ruidosa, exit 1); raça perdida (outro ator mergeou) → "já mergeado" só com o PR realmente mergeado; PR fechado sem merge → "fechado sem merge — skip" honesto. `waitForChecks` passou a esperar o `mergeable` amadurecer (false → throw imediato de conflito; null → segue no poll), mesmo timeout de 30 min. CLI sem shadowing de `pr` (const redeclarada). Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (veredito por re-read, POST 405 + PR aberto → reject, raça concorrente, no-op de já-mergeado, poll de mergeable null, fail-fast de mergeable false). Gates: 2318 unit + 791 int + tsc/lint/prettier/knip/check:cycles verdes. Débito deferido: alargar o gatilho do `agent-pr-ready-automerge.yml` para branches não-`cursor/*` (decisão de ops separada).
**Recently resolved (2026-08-18):** **OPS52-media-guard** — o modo reconcile (escrita) do `pnpm media:recover` ganhou guard de intenção explícita (achado do /simplify da #10): exige `MEDIA_RECOVER_CONFIRM=1` para armar a escrita no bucket — antes, bastava o conjunto completo de `S3_*` (cópia all-or-nothing de credenciais de prod num worktree + rows locais via db:pull → escrevia covers no bucket de prod; o echo do alvo mostrava, não impedia). `--dry-run`/`--verify` seguem sem flag (não escrevem). Sem a flag, reconcile recusa com exit 1 e a mensagem exibe o comando correto. Implementação no dono dos guards: `isTruthyEnv` novo em `scripts/lib/cli.mjs` (semântica exata `true|1` do `ALLOW_REMOTE_DB` — sem trim/case; `isRemoteDbOverrideSet` passou a delegar, sem twin), unit-testado com pin do comportamento preservado (`TRUE`/`yes` continuam recusados). Runbook do homeserver atualizado com a flag; nota no AGENTS.md.
**Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: nenhum novo (lógica de veredito já coberta pelos specs da OPS56; branch de draft é 3 linhas no CLI); gates unit/int verdes.
**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`.
**Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: spec nova em `forgejoApi.unit.spec.ts` (waitForChecks para quando o PR vira draft no meio do wait — veto garantido mesmo em conversão mid-run; autoMerge idempotente); gates unit/int verdes. **Validação ao vivo na própria entrega:** o Forgejo roda o workflow do head da PR (não o de main), então a PR #48 foi mergeada pelo safety net alargado no primeiro uso real — o `if:` com `head.repo.full_name` avaliou true em PR real e o script mergeou por rebase. Fixes pós-review do /simplify: `waitForChecks`/`autoMerge` param em PR draft (antes: 405 ruidoso na conversão mid-run, contradizendo o "veto nunca tocado"); CLI valida `base.ref === 'main'` (invocação manual); comentário do fork guard documenta por que ele não pode descer pro CLI (checkout de fork roda o script do fork = RCE com o token do Actions); docs sem o mecanismo fantasma `gh pr` (era GitHub, OPS50 trocou por CLI plain-Node).
**Recently resolved (2026-08-18):** **OPS59** — o pre-push local não roda mais e2e: ele roda **uma vez, no CI** (PR e main — gateia merge e deploy). O espelho `scripts/gate-ci.mjs` cortou o bloco e2e da cascata (playwright install, build `.next-e2e`, `test:e2e`) e o preflight de DB passou a depender só de test/build; o readout do classificador mantém `e2e=<mode>` e imprime o que o CI rodará ("e2e runs in CI, not in this gate — OPS59"), com o check `unmapped` duplicado removido (`ci-scope.mjs` já o imprime no stderr). `package.json`, `.husky/pre-push`, `git-push.mjs` e os workflows `.forgejo/*` intocados (CI verificado, não alterado: `ci-pr.yml` e2e → `checks`; `ci.yml` e2e → `checks` → `deploy`). `tests/unit/ciSkipInvariants.unit.spec.ts` tirou `gate-ci.mjs` da lista de arquivos que devem conter `.next-e2e` (o gate local não builda mais e2e; `run-e2e-affected.mjs`/`playwright.config.ts` seguem cobertos — e2e local opcional intacto). Docs sincronizadas: `AGENTS.md` (checklist local item 3 — `test:e2e`/`test:all` saem da obrigatória; "e2e = CI, local opcional `pnpm test:e2e:affected`"), `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e"). Verificado por grep: `AGENT-OPS.md` e skills (`work-issue`, `agent-work-issue`, `worktree-next-issue`, `local-database`) não citavam e2e no pre-push — sem mudança. Anti-goals respeitados: sem flag de "pular e2e" (bypass continua só via `--no-verify` documentado), política "Dono do PR, dono do CI" inalterada. Sem migration/access/Consent/UI. Verificação: `pnpm gate:ci` local (cascata nova full — lint/format/typecheck/knip/cycles/unit/int/build em `teqo_test`; e2e vira print), unit 2286+ verdes; o e2e desta PR roda full no CI por HIGH_RISK (`gate-ci.mjs`).
**Recently resolved (2026-08-18):** **OPS60** — o sync do `pnpm db:seed:posts` ganhou guard de intenção explícita para escrita no bucket (achado do /simplify da #37): com as envs `S3_*` ativas, o modo sync exige `SEED_MEDIA_CONFIRM=1` (mesmo padrão `isTruthyEnv` do `MEDIA_RECOVER_CONFIRM` da OPS52-media-guard) e recusa com exit 1 antes de qualquer escrita sem a flag — o risco fechado: um worktree com cópia de credenciais `S3_*` de prod (all-or-nothing do `worktree-env.mjs`) rodando sync contra DB local sobrescrevia covers no bucket de **prod** (o seed é idempotente por slug e sobrescreve o key determinístico). Storage local (sem `S3_*` → disk) e `--dry-run` seguem sem flag (não escrevem); echo do alvo ganhou `Bucket`/`Endpoint` quando S3 ativo (paridade com o `media:recover`). Implementação no dono: `isTruthyEnv` (já existente da #37) + `resolveS3StorageEnv` — sem twin. Runbook do OPS58 atualizado com o comando real; nota no AGENTS.md.
**Recently resolved (2026-08-18):** **OPS61** — contratos de merge/flip do Forgejo consertados. **(S1)** Os workflows pós-merge (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`) rodavam com o `GITHUB_TOKEN` nativo, que 403 em escrita de Issues — o flip `Closes #N → done+in-prod` (PRs #42/#48) quebrava em silêncio (o script engolia o 403 e o job terminava success; flip manual necessário). Agora injetam `FORGEJO_API_TOKEN` (o PAT que o pool já usa para o mesmo tipo de escrita) via `env:` no job, e o `forgejo-issue-transition.mjs` sai **exit 1** se qualquer flip falhar — o job vermelha em vez de mentir. **Fork guard** nos dois workflows (`head.repo.full_name == github.repository`, a mesma classe do `agent-pr-ready-automerge`): PR de fork mergeada faria checkout do script do fork rodando com o PAT do repo (RCE). **(S3)** A branch protection de `main` **não existia no servidor** (0 regras no `GET /branch_protections` — o `pnpm configure:branch-protection` era passo humano pendente do OPS50 e nunca rodou; as docs diziam "aplicado" e mentiam), e o valor `checks` do script **nunca casaria** os contextos reais do Forgejo (casamento por glob contra `CI (PR) / checks (pull_request)` — o sufixo ` (evento)` faz parte do contexto; literal `checks` = todo merge bloqueado). Script corrigido: contexto `CI (PR) / checks*`, idempotente (GET → POST/PATCH/no-op, verifica após aplicar, dry-run honesto) com decisão pura em `scripts/lib/branch-protection.mjs` + spec nova. **(2b — achado ao vivo na própria entrega, PR #67)** O safety net tinha **dois** bloqueios independentes. (1) O gate antigo do `waitForChecks` (`pending.length === 0`) era **insatisfazível**: o Forgejo posta o status do próprio job do safety net como `pending` durante o run inteiro. (2) O merge POST faz um push do branch rebaseado para `main` e o hook pre-receive do Forgejo rejeita esse push quando o usuário do token não tem write no repo — o `GITHUB_TOKEN` nativo age como o usuário sistema do Actions (o mesmo que 403 em Issues), então o merge do CLI morria com 409 "User permission denied for writing" (confirmado na fonte 9.0.3: o hook cai no `AssertCanWriteCode` antes do caminho de merge). Resultado: o CLI **nunca auto-mergeou nenhuma PR** — todo merge Forgejo até aqui foi manual (`merged_by` do dono), inclusive o da PR #52 com o CI vermelho (a narrativa da "raça do snapshot" da intenção estava errada: o merge vermelho foi manual; e runs que "exitaram 0" eram leituras de PR já mergeada). Gate novo: espera o rollup `CI (PR) / checks` presente+verde (único contexto que só existe quando a cascata inteira do ci-pr assentou) e **ignora o próprio contexto** do safety net; `mergeable === false` só conta como conflito após a cascata assentar; timeouts sobem (CLI 30→45 min, workflow 40→55 min); e o workflow do safety net agora injeta `FORGEJO_API_TOKEN` (mesmo PAT dos pós-merge) — o merge com o PAT (admin) passa no hook. A regra no servidor é o gate real para todo mundo: o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — inclusive merges manuais via API (a PR #52 não teria entrado vermelha). **Validação ao vivo na própria entrega:** a regra foi criada e verificada no servidor (re-run = no-op), e a PR #67 (`Closes #53`) mergeou pelo novo gate do rollup com a regra aplicada; o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (rollup não-posto continua poll, rollup failure lança, próprio status do safety net não bloqueia, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado).
**Recently resolved (2026-08-18):** **OPS62** — fail-fast estrutural do CI: `ci-pr.yml` e `ci.yml` deixaram de ter jobs irmãos. Cada workflow agora tem **um único job `checks` sequencial** (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do `ci-scope.mjs` agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então **step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente** — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI _verifica_ permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id `checks` mantém o contexto `CI (PR) / checks` que o automerger/`waitForChecks` e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por `PLAYWRIGHT_WORKERS=4` num único processo (substitui os 2 shards × 2 workers; mesma carga); **2 services Postgres por nome na rede do job** (`postgres-int`/`postgres-build`, **sem publish host** — o job único seguraria portas fixas pelo run inteiro e runs concorrentes colidiriam; o Forgejo 9 não expande expressões em `services.ports`, medido ao vivo no run 730 falhando em 1s com a string literal; o DNS da rede do job mata a colisão de raiz — os guards fail-closed `LOCAL_HOSTS`/`assertTestDatabase` admitem os nomes dos services, ainda rejeitando hosts remotos) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 `pnpm install` por run (antes ~10). Plumbing morto removido: `e2eShardConfig` e o output `e2e_shards` do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). `/simplify` (3 reviewers): P1 aplicados — `set -euo pipefail` no step `scope` (crash do classifier com outputs vazios silenciaria skips → false green) e services por nome (acima); P2 aplicados — pins de invariantes ancorados em chaves YAML (comentários não satisfazem `needs: [checks]`/`matrix:`/`fail-fast:`), pin do wiring crítico `id: scope` + `if: e2e_mode == 'full'` (perdê-lo pula a suíte em silêncio), comentário morto do sharding em `playwright.config.ts` e `docs/TESTING.md` corrigidos (ainda citavam `.github/workflows` e jobs paralelos); P3 — JSDoc do `ci-scope.mjs` e redação do impl plan; o P2 do `waitForChecks` (timeout 30 min vs job 50 min) fica como débito de CI anotado no impl. Docs: tabela CI do AGENT-OPS reescrita + linha morta do `styfle/cancel-workflow-action` (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em `ciSkipInvariants.unit.spec.ts` (um único job `checks`, sem `matrix:`/`fail-fast:`/`runs-on: host` no ci-pr, `PLAYWRIGHT_WORKERS: 4`, guards antes do lint, deploy gated em `needs: [checks]`, services por nome sem `ports:`); `localHosts.unit.spec.ts` com os nomes dos services; `testAffected.unit.spec.ts` sem o describe do shard config. **Validação ao vivo na própria entrega:** o ci-pr deste PR já é o envelope novo — o primeiro run (730) pegou a expressão de porta não suportada (achado → corrigido para DNS de service), o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: `docs/plans/ops62-ci-fail-fast-forgejo-impl.md` (a intenção fica como histórico do gate).
**Recently resolved (2026-08-18):** **OPS63** — os `docs-guards` (OPS44) rodam também no pre-push local: o espelho `scripts/gate-ci.mjs` ganhou os três checks na fase 1 (changelog append-only, agregado sync `--check`, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job `docs-guards`; header do arquivo e linha final atualizados. `package.json`, `.husky/pre-push`, `git-push.mjs` e workflows `.forgejo/*` intocados: `gate:push` = `gate:ci` continua a cadeia única, o job `docs-guards` segue dono do escape `changelog-rewrite:` (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado `git push --no-verify`, com o CI como via do escape). Docs sincronizadas: `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), `docs/AGENT-OPS.md` (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: `AGENTS.md` e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem `plans-only-closes` local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); `pnpm gate:ci` local full; CI roda full nesta PR por HIGH_RISK (`gate-ci.mjs`).
**Recently resolved (2026-08-18):** **OPS65** — CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`gate`, runs-on `host`, main-only, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho), diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout silencioso); deploy duplo-gated (`needs: [checks, gate]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do `ciSkipInvariants` (janela 30, sem `push`, ref guard, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`.
**Recently resolved (2026-08-18):** **OPS64** — o safety net `ready-automerge` deixa de se auto-bloquear e o gate do merge fica estruturalmente honesto. O sintoma primário da Issue (deadlock: o `waitForChecks` contava o próprio status `pending` do job → timeout 30 min → merge nunca acontecia) **já tinha sido entregue pelo OPS61** (filtro do próprio contexto + gate do rollup + PAT; provado vivo: runs 710/712 mergearam os PRs #67/#70). O secundário — o rollup `checks` reportava SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o resultado do **último** shard como o resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; o `needs.<job>.results` do GitHub não existe neste runner) — **foi eliminado estruturalmente pelo OPS62** (mergeado em main durante a execução deste item): o ci-pr.yml e o ci.yml viraram **jobs únicos sequenciais** (sem matrix, sem shards), então o status do job `checks` já é o veredito honesto da cascata inteira. Este item entregou o que restava de valor: (a) **teste-pino no `waitForChecks`** (`forgejoApi.unit.spec.ts`, cenário run 607) — o CLI **nunca confia no rollup sozinho**: varre os statuses individuais e lança "Checks falharam" com rollup verde + shard vermelho (invariante de defesa que continua válido e pegaria qualquer matrix que volte); (b) **docs honestas** (`agent-pr-workflow.mdc`, `AGENT-OPS.md`) registrando o veredito do rollup, o pin do CLI e a eliminação estrutural do bug pelo OPS62. O script `ci-rollup-check.mjs` e os edits de workflow planejados foram **descartados na execução** (redundantes pós-OPS62 — decisão do gate com o humano; os workflows em main são os do OPS62, intocados). Sem migration/access/Consent/UI/schema. Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o `ready-automerge` mergea no fim). Impl plan: `docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md`.
**Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte.
**Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- <sha>"`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-<sha>`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte.

View file

@ -3,7 +3,7 @@
Completed 2026-08-01. Branch `stage` and GitHub Environment `stage` are deleted. Production deploys only from `ci.yml` after the full verifier is green.
- [x] Code PR opened (main-only cutover) — merged to `main`
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` (+ `migration-lock` until 2026-08-12), `strict=false`, 0 reviews) — **GitHub-era; reaplicado no Forgejo em 2026-08-18 (OPS61) com o contexto real `CI (PR) / checks` (glob `CI (PR) / checks*`)**
- [x] `pnpm configure:branch-protection` applied on `main` (required: `checks` (+ `migration-lock` until 2026-08-12), `strict=false`, 0 reviews)
- [x] No open PRs targeting `stage` at cutover time
- [x] Repo secrets present (`VERCEL_*`, `POOL_GITHUB_TOKEN`)
- [x] `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` synced from local `.vercel/project.json` (`vercel link`)

View file

@ -8,9 +8,9 @@ Three layers, all local-database-only (see `local-database` skill; tests refuse
- **Integration** (`tests/int/**/*.int.spec.ts`, 71 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Since the miss #73 guardrail (Pass 4), specs that mutate municipalities must use the allocator (no pinned slugs) — enforced by `testMunicipalityAllocatorConventions`. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 17 specs / 51 cases (Pass 5, 2026-08-03; was 48 at Pass 4)): smoke-level; most campaign behavior is pinned at the int layer.
CI (`ci-pr.yml`) is a **single sequential job `checks`** (OPS62 X1): guards → lint (zero warnings) → format → typecheck → knip → cycles → unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths) → build → e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; **only in mode `selected` — OPS72: the PR never runs full e2e**; production build + `list`/`github` reporters in CI) — against two isolated Postgres 17 services (int / build+e2e) with **`db:seed:minimal`**. Fail-fast is structural: a failed step stops the run; e2e runs with `PLAYWRIGHT_WORKERS=4`. There is no main verifier (`ci.yml` eliminated in OPS71): the full net including e2e runs in the deploy `verify` job before publishing (manual dispatch).
CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e.
**Local e2e (OPS72 — skill step, discretionary):** before push, run the e2e specs you created + the same affected surface — `pnpm test:e2e:affected` runs `e2e-affected.mjs` against `origin/main`, migrates/seeds, then `pnpm test:e2e` with the selected specs (full suite on high-risk diffs). High-risk diffs diverge from PR CI by design: the PR runs no e2e (selected only, never full) and the local mirror runs the full suite. Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode with `E2E_PROD=1 pnpm test:e2e:affected` (CI and prod mode serve the production build via `pnpm start`; plain local runs use `pnpm dev` — dev-mode flakes like cold-compile timeouts and dev-only console warnings do not reproduce in prod mode).
**Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`.
**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat (closed 2026-08-10, P4-L/OPS30):** `campaignLeaderships`' chip-cell spec was failing deterministically under `pnpm dev` before B34 rewrote it; with the OPS30 prewarm/login hardening it ran ×3 green in dev + ×1 `E2E_PROD=1` (evidence in TECH-DEBT).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS52-media-guard** — o modo reconcile (escrita) do `pnpm media:recover` ganhou guard de intenção explícita (achado do /simplify da #10): exige `MEDIA_RECOVER_CONFIRM=1` para armar a escrita no bucket — antes, bastava o conjunto completo de `S3_*` (cópia all-or-nothing de credenciais de prod num worktree + rows locais via db:pull → escrevia covers no bucket de prod; o echo do alvo mostrava, não impedia). `--dry-run`/`--verify` seguem sem flag (não escrevem). Sem a flag, reconcile recusa com exit 1 e a mensagem exibe o comando correto. Implementação no dono dos guards: `isTruthyEnv` novo em `scripts/lib/cli.mjs` (semântica exata `true|1` do `ALLOW_REMOTE_DB` — sem trim/case; `isRemoteDbOverrideSet` passou a delegar, sem twin), unit-testado com pin do comportamento preservado (`TRUE`/`yes` continuam recusados). Runbook do homeserver atualizado com a flag; nota no AGENTS.md.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/<filename>` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `<slug>.<ext>`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local; `--verify` dispensa S3 — só HEAD de URLs públicas), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint/origin), timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) e nos fetches (30s/60s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan), e covers constrangidas a http(s) no host do WordPress com só `image/*` armazenado (sem SSRF/objeto não-imagem no proxy). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`; exit 1 se qualquer checagem falhar), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS54** — fim do warning do pnpm sobre o campo `pnpm` ignorado em `package.json`. A allowlist `onlyBuiltDependencies` (`sharp`, `esbuild`, `unrs-resolver`) migrou para o home canônico do pnpm 10: novo `pnpm-workspace.yaml` na raiz (sem `packages` — repo single-package, default `['.']`), e o bloco `"pnpm"` saiu do `package.json` (pnpm 10 não o lê mais; sem a remoção o warning persiste). `Dockerfile` estágio `deps` agora copia `pnpm-workspace.yaml` junto (`COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./`) — sem isso a allowlist não chegaria ao install da imagem e local×Docker divergiriam nos build scripts. Validado com a versão pinada (10.11.0) e no estágio deps real (`docker build --target deps`): nenhum `[WARN]` do campo `pnpm` em install/CI/Docker, `pnpm config get onlyBuiltDependencies` → os 3 pacotes, `--frozen-lockfile` limpo (lockfile e grafo intocados). Guardrails respeitados: `.npmrc` (`legacy-peer-deps`, `include`) e `packageManager` intocados; nada além do que o warning citava foi migrado (o aviso informativo de `msw`/`onnxruntime-node` é comportamento padrão do pnpm 10 — fora de escopo por anti-goal). Sem migration, sem UI, sem testes de domínio (config de ferramenta; gates completos verdes). Repo: `docs/plans/ops54-pnpm-warning-onlybuiltdependencies-impl.md`.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS56**: o `forgejo-pr-automerge` nunca mais reporta falso positivo "já mergeado" com PR aberto. Raiz confirmada no source do servidor (Forgejo 9.0.3, tag v9.0.3): o endpoint `POST /pulls/{n}/merge` responde **200 com corpo vazio** no sucesso (falhas são 404/405/409/500), e o `request()` do `forgejo-api.mjs` convertia isso em `null` — o `autoMerge()` devolvia `null` cru e o CLI imprimia "já mergeado" (exit 0) independente do estado real do PR; o incidente (PR #27 da OPS52, aberto das 01:59 às 02:45) deixou o merge falhar silenciosamente com o PR aberto. Correção: o veredito de `autoMerge` agora é **re-read do PR** após a tentativa (`{ attempted, merged, pr }`) — 200-vazio → confirma no `GET /pulls/{n}`; POST falhou e o PR segue aberto → **re-throw** (falha ruidosa, exit 1); raça perdida (outro ator mergeou) → "já mergeado" só com o PR realmente mergeado; PR fechado sem merge → "fechado sem merge — skip" honesto. `waitForChecks` passou a esperar o `mergeable` amadurecer (false → throw imediato de conflito; null → segue no poll), mesmo timeout de 30 min. CLI sem shadowing de `pr` (const redeclarada). Testes: 6 specs novas em `forgejoApi.unit.spec.ts` (veredito por re-read, POST 405 + PR aberto → reject, raça concorrente, no-op de já-mergeado, poll de mergeable null, fail-fast de mergeable false). Gates: 2318 unit + 791 int + tsc/lint/prettier/knip/check:cycles verdes. Débito deferido: alargar o gatilho do `agent-pr-ready-automerge.yml` para branches não-`cursor/*` (decisão de ops separada).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS57** — o safety net do `forgejo-pr-automerge` agora cobre **todas as PRs same-repo para `main`**, não só `cursor/*`. Motivação (débito deferido na OPS56): dos 25 PRs mergeados no repo, **zero** eram `cursor/*` — 9 de `plans/*`, 11 de worktrees humanos, 5 de `refs/pull/N/head` (era da migração) — o filtro antigo nunca cobriu nenhum PR real, e o incidente OPS52 (PR #27, humano, merge falhou silenciosamente) era exatamente a classe excluída. Mudanças: `if:` do job em `agent-pr-ready-automerge.yml` → `state == 'open' && head.repo.full_name == github.repository` (guarda de fork — o repo é público e uma PR de fork mergeada dispararia o deploy de prod; campos já usados no payload, sem dependência de `is_draft` do webhook); política de draft movida para o CLI `forgejo-pr-automerge.mjs` com o head ref do **re-read da API** — draft `cursor/*` → Ready (pool inalterado); draft não-`cursor/*` → skip exit 0 (**draft = veto do ator, PR humana nunca é forçada**); PRs ready não-`cursor/*` (humanas/plans) passam a mergear com CI verde — redundante no caminho feliz (o fluxo humano já arma `--auto --rebase`) e é o que pega a classe OPS52; `ready_for_review` em PR draft re-dispara o job e mergea. Docs sincronizadas (`docs/AGENT-OPS.md` tabela CI por alvo, `.agents/rules/agent-pr-workflow.mdc` seção Safety net). Rejeitado: `!is_draft` no YAML (campo do payload não verificado, falha fail-open) e allowlist de prefixos (códigos humanos sem prefixo comum — OPS/S1/PUB5). A própria PR desta entrega ainda usa o workflow de `main` (antigo); a próxima PR exercita o gatilho alargado. Sem migration/access/Consent/UI. Testes: spec nova em `forgejoApi.unit.spec.ts` (waitForChecks para quando o PR vira draft no meio do wait — veto garantido mesmo em conversão mid-run; autoMerge idempotente); gates unit/int verdes. **Validação ao vivo na própria entrega:** o Forgejo roda o workflow do head da PR (não o de main), então a PR #48 foi mergeada pelo safety net alargado no primeiro uso real — o `if:` com `head.repo.full_name` avaliou true em PR real e o script mergeou por rebase. Fixes pós-review do /simplify: `waitForChecks`/`autoMerge` param em PR draft (antes: 405 ruidoso na conversão mid-run, contradizendo o "veto nunca tocado"); CLI valida `base.ref === 'main'` (invocação manual); comentário do fork guard documenta por que ele não pode descer pro CLI (checkout de fork roda o script do fork = RCE com o token do Actions); docs sem o mecanismo fantasma `gh pr` (era GitHub, OPS50 trocou por CLI plain-Node).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS58** — o sync de posts de produção ficou pronto para executar com runbook documentado: `scripts/seed-posts.mjs` ganhou o modo **`--dry-run`** (plan-only: fetch ao vivo + echo do alvo + relatório do que SERIA criado/saltado — zero escrita, pre-flight padrão OPS52-media antes de tocar o DB de prod; argumento desconhecido morre) e a **taxonomy `CLASSIFICATION` foi atualizada** com os 4 slugs novos do jorgesolla.com.br (43 posts no WP vs 39 mapeados): os 3 de campanha/pré-campanha (convenção estadual do PT, plenária "PT tem que disputar o programa de governo", caravana da Chapada) entraram com `tags: ['eleitoral']` para ficarem sob o controle de visibilidade eleitoral do repo (`isPostVisible` fail-closed — critério do tag documentado no seed), e o embate com Bruno Reis em `politica`. A execução em prod (pós-merge, homeserver, env do stack, `ALLOW_REMOTE_DB=true`) segue o runbook: dry-run → sync (create-only, idempotente por slug) → bust do cache `posts` via `/api/revalidate` → verify (`media:recover --verify` para capas + curl nas páginas dos novos). Runbook para as próximas sincronizações: `docs/plans/ops58-sincronizar-posts-prod-impl.md`.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS59** — o pre-push local não roda mais e2e: ele roda **uma vez, no CI** (PR e main — gateia merge e deploy). O espelho `scripts/gate-ci.mjs` cortou o bloco e2e da cascata (playwright install, build `.next-e2e`, `test:e2e`) e o preflight de DB passou a depender só de test/build; o readout do classificador mantém `e2e=<mode>` e imprime o que o CI rodará ("e2e runs in CI, not in this gate — OPS59"), com o check `unmapped` duplicado removido (`ci-scope.mjs` já o imprime no stderr). `package.json`, `.husky/pre-push`, `git-push.mjs` e os workflows `.forgejo/*` intocados (CI verificado, não alterado: `ci-pr.yml` e2e → `checks`; `ci.yml` e2e → `checks` → `deploy`). `tests/unit/ciSkipInvariants.unit.spec.ts` tirou `gate-ci.mjs` da lista de arquivos que devem conter `.next-e2e` (o gate local não builda mais e2e; `run-e2e-affected.mjs`/`playwright.config.ts` seguem cobertos — e2e local opcional intacto). Docs sincronizadas: `AGENTS.md` (checklist local item 3 — `test:e2e`/`test:all` saem da obrigatória; "e2e = CI, local opcional `pnpm test:e2e:affected`"), `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e"). Verificado por grep: `AGENT-OPS.md` e skills (`work-issue`, `agent-work-issue`, `worktree-next-issue`, `local-database`) não citavam e2e no pre-push — sem mudança. Anti-goals respeitados: sem flag de "pular e2e" (bypass continua só via `--no-verify` documentado), política "Dono do PR, dono do CI" inalterada. Sem migration/access/Consent/UI. Verificação: `pnpm gate:ci` local (cascata nova full — lint/format/typecheck/knip/cycles/unit/int/build em `teqo_test`; e2e vira print), unit 2286+ verdes; o e2e desta PR roda full no CI por HIGH_RISK (`gate-ci.mjs`).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS60** — o sync do `pnpm db:seed:posts` ganhou guard de intenção explícita para escrita no bucket (achado do /simplify da #37): com as envs `S3_*` ativas, o modo sync exige `SEED_MEDIA_CONFIRM=1` (mesmo padrão `isTruthyEnv` do `MEDIA_RECOVER_CONFIRM` da OPS52-media-guard) e recusa com exit 1 antes de qualquer escrita sem a flag — o risco fechado: um worktree com cópia de credenciais `S3_*` de prod (all-or-nothing do `worktree-env.mjs`) rodando sync contra DB local sobrescrevia covers no bucket de **prod** (o seed é idempotente por slug e sobrescreve o key determinístico). Storage local (sem `S3_*` → disk) e `--dry-run` seguem sem flag (não escrevem); echo do alvo ganhou `Bucket`/`Endpoint` quando S3 ativo (paridade com o `media:recover`). Implementação no dono: `isTruthyEnv` (já existente da #37) + `resolveS3StorageEnv` — sem twin. Runbook do OPS58 atualizado com o comando real; nota no AGENTS.md.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS61** — contratos de merge/flip do Forgejo consertados. **(S1)** Os workflows pós-merge (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`) rodavam com o `GITHUB_TOKEN` nativo, que 403 em escrita de Issues — o flip `Closes #N → done+in-prod` (PRs #42/#48) quebrava em silêncio (o script engolia o 403 e o job terminava success; flip manual necessário). Agora injetam `FORGEJO_API_TOKEN` (o PAT que o pool já usa para o mesmo tipo de escrita) via `env:` no job, e o `forgejo-issue-transition.mjs` sai **exit 1** se qualquer flip falhar — o job vermelha em vez de mentir. **Fork guard** nos dois workflows (`head.repo.full_name == github.repository`, a mesma classe do `agent-pr-ready-automerge`): PR de fork mergeada faria checkout do script do fork rodando com o PAT do repo (RCE). **(S3)** A branch protection de `main` **não existia no servidor** (0 regras no `GET /branch_protections` — o `pnpm configure:branch-protection` era passo humano pendente do OPS50 e nunca rodou; as docs diziam "aplicado" e mentiam), e o valor `checks` do script **nunca casaria** os contextos reais do Forgejo (casamento por glob contra `CI (PR) / checks (pull_request)` — o sufixo ` (evento)` faz parte do contexto; literal `checks` = todo merge bloqueado). Script corrigido: contexto `CI (PR) / checks*`, idempotente (GET → POST/PATCH/no-op, verifica após aplicar, dry-run honesto) com decisão pura em `scripts/lib/branch-protection.mjs` + spec nova. **(2b — achado ao vivo na própria entrega, PR #67)** O safety net tinha **dois** bloqueios independentes. (1) O gate antigo do `waitForChecks` (`pending.length === 0`) era **insatisfazível**: o Forgejo posta o status do próprio job do safety net como `pending` durante o run inteiro. (2) O merge POST faz um push do branch rebaseado para `main` e o hook pre-receive do Forgejo rejeita esse push quando o usuário do token não tem write no repo — o `GITHUB_TOKEN` nativo age como o usuário sistema do Actions (o mesmo que 403 em Issues), então o merge do CLI morria com 409 "User permission denied for writing" (confirmado na fonte 9.0.3: o hook cai no `AssertCanWriteCode` antes do caminho de merge). Resultado: o CLI **nunca auto-mergeou nenhuma PR** — todo merge Forgejo até aqui foi manual (`merged_by` do dono), inclusive o da PR #52 com o CI vermelho (a narrativa da "raça do snapshot" da intenção estava errada: o merge vermelho foi manual; e runs que "exitaram 0" eram leituras de PR já mergeada). Gate novo: espera o rollup `CI (PR) / checks` presente+verde (único contexto que só existe quando a cascata inteira do ci-pr assentou) e **ignora o próprio contexto** do safety net; `mergeable === false` só conta como conflito após a cascata assentar; timeouts sobem (CLI 30→45 min, workflow 40→55 min); e o workflow do safety net agora injeta `FORGEJO_API_TOKEN` (mesmo PAT dos pós-merge) — o merge com o PAT (admin) passa no hook. A regra no servidor é o gate real para todo mundo: o merge POST é bloqueado com 405 enquanto o contexto exigido não estiver verde — inclusive merges manuais via API (a PR #52 não teria entrado vermelha). **Validação ao vivo na própria entrega:** a regra foi criada e verificada no servidor (re-run = no-op), e a PR #67 (`Closes #53`) mergeou pelo novo gate do rollup com a regra aplicada; o `issue-done` flipou a #53 para `done`+`in-prod` com o PAT. Sem migration/access/Consent/UI. Testes: `forgejoApi.unit.spec.ts` (rollup não-posto continua poll, rollup failure lança, próprio status do safety net não bloqueia, mergeable pré-settle — fixtures dos waitForChecks/autoMerge existentes atualizados para o contexto real), `branchProtection.unit.spec.ts` nova (create/update/noop, glob casa o contexto observado).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS62** — fail-fast estrutural do CI: `ci-pr.yml` e `ci.yml` deixaram de ter jobs irmãos. Cada workflow agora tem **um único job `checks` sequencial** (ci-pr: guards de conteúdo → lint → format → typecheck → knip → cycles → unit → int → build → e2e, com skips do `ci-scope.mjs` agora como step com outputs; ci.yml: mesma mecânica full + deploy OPS53 intacto), então **step vermelho interrompe o job, o run falha e o slot do runner é liberado imediatamente** — sem API de cancel (o Forgejo 9.0.3 não tem), sem watchdog, sem upgrade do Forgejo no homeserver (decisão X1 do gate de execução substitui o watchdog+upgrade da intenção; o aceite "API de cancel/upgrade" e "mesmos jobs, mesma cascata" foi revisado pelo humano — o que o CI _verifica_ permanece idêntico, só o envelope muda). Diagnóstico preservado: steps têm logs próprios. O job id `checks` mantém o contexto `CI (PR) / checks` que o automerger/`waitForChecks` e a branch protection esperam (OPS61). Paralelismo do e2e recuperado por `PLAYWRIGHT_WORKERS=4` num único processo (substitui os 2 shards × 2 workers; mesma carga); **2 services Postgres por nome na rede do job** (`postgres-int`/`postgres-build`, **sem publish host** — o job único seguraria portas fixas pelo run inteiro e runs concorrentes colidiriam; o Forgejo 9 não expande expressões em `services.ports`, medido ao vivo no run 730 falhando em 1s com a string literal; o DNS da rede do job mata a colisão de raiz — os guards fail-closed `LOCAL_HOSTS`/`assertTestDatabase` admitem os nomes dos services, ainda rejeitando hosts remotos) preservam a topologia de hoje (e2e nunca herda resíduo de fixtures do int); 1 checkout + 1 `pnpm install` por run (antes ~10). Plumbing morto removido: `e2eShardConfig` e o output `e2e_shards` do classifier (sem consumidor). Guards de conteúdo movidos para o início do pipeline (falham rápido e são PR-scoped). `/simplify` (3 reviewers): P1 aplicados — `set -euo pipefail` no step `scope` (crash do classifier com outputs vazios silenciaria skips → false green) e services por nome (acima); P2 aplicados — pins de invariantes ancorados em chaves YAML (comentários não satisfazem `needs: [checks]`/`matrix:`/`fail-fast:`), pin do wiring crítico `id: scope` + `if: e2e_mode == 'full'` (perdê-lo pula a suíte em silêncio), comentário morto do sharding em `playwright.config.ts` e `docs/TESTING.md` corrigidos (ainda citavam `.github/workflows` e jobs paralelos); P3 — JSDoc do `ci-scope.mjs` e redação do impl plan; o P2 do `waitForChecks` (timeout 30 min vs job 50 min) fica como débito de CI anotado no impl. Docs: tabela CI do AGENT-OPS reescrita + linha morta do `styfle/cancel-workflow-action` (removida no OPS50) apagada. Sem migration/access/Consent/UI. Testes: invariantes novos em `ciSkipInvariants.unit.spec.ts` (um único job `checks`, sem `matrix:`/`fail-fast:`/`runs-on: host` no ci-pr, `PLAYWRIGHT_WORKERS: 4`, guards antes do lint, deploy gated em `needs: [checks]`, services por nome sem `ports:`); `localHosts.unit.spec.ts` com os nomes dos services; `testAffected.unit.spec.ts` sem o describe do shard config. **Validação ao vivo na própria entrega:** o ci-pr deste PR já é o envelope novo — o primeiro run (730) pegou a expressão de porta não suportada (achado → corrigido para DNS de service), o run verde prova a suíte inteira no job único; a medição do tempo de parede fica registrada no run. Custo aceito (X1): run verde ~21 min esperados (vs ~15–18 com jobs paralelos) e e2e vermelho é o caso mais lento de veredito; débito registrado no impl plan (matrix fail-fast Y) se a fila do runner apertar. Impl: `docs/plans/ops62-ci-fail-fast-forgejo-impl.md` (a intenção fica como histórico do gate).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS63** — os `docs-guards` (OPS44) rodam também no pre-push local: o espelho `scripts/gate-ci.mjs` ganhou os três checks na fase 1 (changelog append-only, agregado sync `--check`, marcadores de conflito em markdown) como chamadas diretas dos mesmos scripts do CI — labels espelhando os steps do job `docs-guards`; header do arquivo e linha final atualizados. `package.json`, `.husky/pre-push`, `git-push.mjs` e workflows `.forgejo/*` intocados: `gate:push` = `gate:ci` continua a cadeia única, o job `docs-guards` segue dono do escape `changelog-rewrite:` (body do PR — CI-only; restauração legítima D8 falha local com a mesma mensagem do CI e usa o bypass documentado `git push --no-verify`, com o CI como via do escape). Docs sincronizadas: `.agents/rules/engineering-standards.mdc` e `agent-pr-workflow.mdc` (gate descrito como "espelho do ci-pr.yml sem e2e, com docs-guards locais"), `docs/AGENT-OPS.md` (contrato item 4 — OPS44: checks no pre-push + nota do escape CI-only). Verificado por grep: `AGENTS.md` e skills não descreviam o gate sem os checks de docs — sem mudança. Anti-goals respeitados: sem bypass novo (falha local honesta), sem `plans-only-closes` local (contrato de PR body). Sem migration/access/Consent/UI/schema. Verificação: três checks green na branch; caminho de falha validado (commit descartável removendo linha do agregado → append-only falha com mensagem acionável); `pnpm gate:ci` local full; CI roda full nesta PR por HIGH_RISK (`gate-ci.mjs`).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS64** — o safety net `ready-automerge` deixa de se auto-bloquear e o gate do merge fica estruturalmente honesto. O sintoma primário da Issue (deadlock: o `waitForChecks` contava o próprio status `pending` do job → timeout 30 min → merge nunca acontecia) **já tinha sido entregue pelo OPS61** (filtro do próprio contexto + gate do rollup + PAT; provado vivo: runs 710/712 mergearam os PRs #67/#70). O secundário — o rollup `checks` reportava SUCCESS com um shard de e2e vermelho porque o Forgejo 9.0.3 reporta o resultado do **último** shard como o resultado do parent de matrix no contexto `needs` (runs 604/607/610: e2e (1) vermelho + e2e (2) verde → `checks` verde; o `needs.<job>.results` do GitHub não existe neste runner) — **foi eliminado estruturalmente pelo OPS62** (mergeado em main durante a execução deste item): o ci-pr.yml e o ci.yml viraram **jobs únicos sequenciais** (sem matrix, sem shards), então o status do job `checks` já é o veredito honesto da cascata inteira. Este item entregou o que restava de valor: (a) **teste-pino no `waitForChecks`** (`forgejoApi.unit.spec.ts`, cenário run 607) — o CLI **nunca confia no rollup sozinho**: varre os statuses individuais e lança "Checks falharam" com rollup verde + shard vermelho (invariante de defesa que continua válido e pegaria qualquer matrix que volte); (b) **docs honestas** (`agent-pr-workflow.mdc`, `AGENT-OPS.md`) registrando o veredito do rollup, o pin do CLI e a eliminação estrutural do bug pelo OPS62. O script `ci-rollup-check.mjs` e os edits de workflow planejados foram **descartados na execução** (redundantes pós-OPS62 — decisão do gate com o humano; os workflows em main são os do OPS62, intocados). Sem migration/access/Consent/UI/schema. Verificação: unit 2360 verdes; lint/format/typecheck/knip/cycles no gate local; a própria PR prova o ciclo (o `ready-automerge` mergea no fim). Impl plan: `docs/plans/ops64-forgejo-pr-automerge-waitforchecks-impl.md`.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-18):** **OPS65** — CI do `main` em **janela fixa de 30 min** + **agent-pool morto**. O `ci.yml` não dispara mais a cada push: roda por `schedule */30` + `workflow_dispatch`, e um gate barato (`gate`, runs-on `host`, main-only, sem pnpm — `scripts/ci-classify-production.mjs` + matcher puro `scripts/lib/dockerignore.mjs`) lê a **revision do container rodando** no homeserver (label `org.opencontainers.image.revision` — o compose pode mentir após rollback falho), diffa contra o HEAD de `main` e classifica "mudança de produção" usando o `.dockerignore` como fonte da verdade (o que não entra no build context não justifica build novo; `docs/`/`.agents`/`.env*` viram skip; `scripts/`/`tests`/`.forgejo`/`AGENTS.md` contam como produção — conservador, qualquer dúvida roda a suite). Deployed == HEAD → suite e deploy skipped, run verde (o rollup `checks` já aceita skipped); qualquer outra coisa → suite full + deploy na mesma janela; dispatch sempre roda tudo; o `deploy-homeserver.sh` ganhou guard de idempotência "already deployed" (revision do container já no SHA → skip verde). Fail-open: revision desconhecida → suite roda (main sempre verificada); gate incapaz de classificar → suite roda e o run fica **vermelho** (nunca blackout silencioso); deploy duplo-gated (`needs: [checks, gate]` + `if` explícito — nunca deploy às cegas nem com suite vermelha). `agent-pool.yml` removido (o tick `*/10` custava 144 `pnpm install`/dia na workstation; scripts/skills do pool ficam dormentes). Testes: `dockerignoreMatch` + `ciClassifyProduction` unit novos (matcher contra o `.dockerignore` real, fail-open, dispatch, caminho git com repo scratch, exit codes), pins do `ciSkipInvariants` (janela 30, sem `push`, ref guard, ifs fail-open) e do `deployScript` (guard), `HIGH_RISK_EXACT` += classificador. Docs: runbook `docs/ops/teqo-1313-deploy.md` (janela/gate/"already deployed"/falhas), `AGENTS.md`, `docs/AGENT-OPS.md`.

View file

@ -1 +0,0 @@
**S2** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os vídeos do canal oficial do YouTube (`@JorgeSollaDep`), estendendo o board da S1. Fonte automática servidor-side: global `SocialFeedSettings` nova (grupo `Configurações`, read/update admin-only — a chave da API nunca vaza pelo REST), com kill switch `enabled` ("Pausar feed"), toggle `youtubeEnabled`, chave + channel ID, `youtubeMaxItems` (1–5, default 3) e `excludedItems` (platform+id+motivo opcional — mesmo mecanismo/lugar da S3), migration `20260818_111748_add_social_feed_settings` não destrutiva (editada para conter só o DDL — o `migrate:create` regenerava drift de migrations hand-written sem sidecar JSON de 20260811). Loader `src/utilities/youtubeFeed.ts` (irmão de `posts.ts`, pinado no spec de convenções): parsers/format puros (`formatYouTubeViews` pt-BR "12,4 mil"/"1,2 mi", thumbnails maxres??high??medium), fetch Data API v3 em dois passos (search → statistics, `fetchImpl`/`YOUTUBE_API_BASE_URL` injetáveis), `unstable_cache` tag `social-feed` + `revalidate: 300` (TTL da entrada, não da página — o bug do S1 era page-level); **fail-closed com snapshot persistido por SQL direto** (`payload.db.drizzle`, sem hooks — `revalidateTag` dentro de `unstable_cache` LANÇA no Next 15.4, verificado no source; o snapshot cru pré-exclusão sobrevive a edições de settings por merge do update), exclusões aplicadas na leitura. Card generalizado `CampaignArticleCard` → `CampaignContentCard` (git mv; `external?: boolean` → `<a target="_blank" rel="noopener noreferrer">`); a seção mescla artigos visíveis + vídeos elegíveis por recência (grande = mais recente — aceite S2; degrade para o comportamento S1 sem config), meta "há X · N visualizações" (relativa < 48h, absoluta além), link "YouTube →" derivado do channelId; carrossel mobile renomeado para "Conteúdos recentes". next.config ganha `i.ytimg.com` + host local do stub no remotePatterns. Testes: 15 units novas (formatters, parsers, eligibility, fetch com fetchImpl fake); e2e com stub HTTP node (`tests/e2e/youtube-stub.mjs`, webServer do Playwright vira array, porta = dev+1000; `POST /__stub/state` alterna ok/fail; thumbnails servidas localmente) — 4 testes novos no describe serial da seção (full-state com exclusão + link externo + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down, kill switch) + reset de settings no beforeAll; conventions specs atualizados (pin do youtubeFeed, settle após about:blank). Verificação: unit 2306 + int 791 verdes; e2e frontend 14/14 dev e 6/6 prod-mode (`E2E_PROD=1` — valida TTL+tags+snapshot no build real); lint/format/tsc/knip/cycles/build verdes. `/simplify` 3 reviewers (sem P0): P1 corrigido — label do kill switch invertido ("Pausar feed" marcado = ativo → "Feed ativo"); P2 absorvidos (className do link do header extraída, `Promise.all` posts+feed no cache frio, comentário do sentinela 0 do merge, helper único `tests/helpers/youtubeStub.ts` para a porta do stub, `waitForHomeSectionState` fundido no `waitForHomeHTML` com orçamento 12s do S1, precondição do persist documentada); débito REGISTRADO (#40, prio:P2, depends #18): o `validate` do `itemId` lê `data.platform` que é sempre undefined (Payload passa o doc inteiro; verificado no source) — produção sem validação de ID de vídeo, e o e2e depende dessa mortandade (ids do stub de 20 chars); correção em dupla documentada na issue. Descartados com justificativa: ramo maxres do search (pinned por teste), tag `social-feed` fora da convenção `global_*` (precedente election-tse/municipality-catalog), remotePattern `localhost/thumbs` incondicional (decisão deliberada dos 3 modos e2e), persist não-atômico (comentado); achados visuais do design-vision mapeados para a Issue #33 (separação visual branco-sobre-branco, fora de escopo por decisão do gate).

View file

@ -1 +0,0 @@
**S3** — seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br) passa a incluir os posts do Instagram oficial, completando o board do plano-site §4.2 (artigos S1 + YouTube S2 + Instagram). Fonte automática servidor-side: loader irmão `src/utilities/socialFeed/instagramFeed.ts` (os feeds externos ganharam o subfolder `socialFeed/` — o pin da S2 predizia exatamente isso quando a segunda plataforma entrasse; `git mv` do youtubeFeed + imports atualizados): Graph API (`GET /{ig-user-id}/media` com `children{media_url,thumbnail_url}` p/ carrossel + `GET /{ig-user-id}?fields=username`), `unstable_cache` tag `social-feed` + `revalidate: 300`, snapshot cru (`{ username, posts }`, pré-exclusão) persistido via SQL direto (padrão anti-`revalidateTag`-no-cache da S2), exclusões aplicadas na leitura, **refresh de token automático** (refresh-on-failure + retry 1x, token renovado persistido por SQL direto; nota honesta no admin: o refresh endpoint só renova tokens de Instagram Login — page tokens business caem no fail-closed + rotação manual). Global `SocialFeedSettings` ganha `instagramEnabled/AccessToken/UserId/MaxItems` + `instagramFeedSnapshot` (hidden) — `excludedItems` já cobria `platform: 'instagram'` desde a S2, sem migration no mecanismo; migration `20260818_192948_add_instagram_social_feed_fields` não destrutiva (DDL revisado, sem o drift do `migrate:create` da S2). **Picker de exclusão no admin** (requisito nº 1 da intenção — posts de grade): primeiro componente custom do repo (`src/components/admin/InstagramPostExclusionPicker.tsx`, campo `ui` na global, path string + importmap regenerado — o import direto de `@payloadcms/ui` na config quebra o `generate:importmap` no tsx por causa do `.css` do react-image-crop; com path string o scanner resolve sem carregar o módulo); lista os posts do snapshot (thumbnail + legenda + data + ID) com toggle "Não exibir"/"Reexibir" + motivo opcional que escreve em `excludedItems` via `useField`. UI: `CampaignContentCardData.coverAspect` (`'video'` default — S1/S2 intactos; `'square'` 1:1 do rascunho aprovado para IG), badge "Instagram", título = legenda (fallback "Publicação no Instagram" para post sem legenda — entra no automático para exclusão manual), meta = data relativa, clique no `permalink` (nova aba, `noopener`), link do header "Seguir no Instagram →" (copy do rascunho aprovado; username derivado da API — com API fora e sem snapshot o link some junto com os cards, divergência documentada do "YouTube →" que sobrevive por ter o channelId configurado). next.config ganha `https://*.cdninstagram.com/**`. Testes: 16 units novas (parsers com caption null/carrossel/item sem permalink, thumbnail por media_type, eligibility, fetch + refresh-retry com `fetchImpl`); e2e: stub irmão `tests/e2e/instagram-stub.mjs` (porta dev+2000; a derivação da porta virou `tests/helpers/socialStub.ts` — rename do youtubeStub.ts mantendo UMA spelling), 3 testes novos no describe serial (full-state com exclusão + fallback de legenda + aspect 1:1 + link do perfil + carrossel, fail-closed sem snapshot, snapshot persistente com maxItems mudando na API down) + kill switch estendido com IG configurado + smoke do picker no admin (seed do snapshot via REST, toggle, Salvar, verificação do `excludedItems`). Verificação: unit 2351 + int 791 verdes; gate:fast/lint/format/tsc/knip (exit 0 — o erro de load do payload.config segue o ledger P3)/cycles/build verdes; e2e local: describe serial 9/9 + smoke admin 1/1 (rodando os projetos em sequência — em paralelo com `--no-deps` o `seedTestUser` dos dois beforeAll colide no mesmo usuário e quebra o run, artefato da invocação local, não do produto; o CI roda na cadeia de dependências). Detalhe do debug: o clique via CDP do MCP de browser não dispara eventos de input (ambiente do MCP, não do app — `el.click()` e o runner real funcionam).

View file

@ -1 +0,0 @@
**S5** — separação visual da seção "Acompanhe de perto" da prova social na home de campanha: o `<section>` sai de `bg-white` (idêntico à faixa de números, seções coladas) para `border-y border-(--campaign-line) bg-(--campaign-band)` — banda `#ebe9e9` da paleta + hairlines de 12%, o desenho aprovado no wireframe (seção 7: zona de cor própria com `border-top/bottom: 1px solid var(--line)`) e no rascunho UI do gate (Cena 1/3 recomendadas). Zero mudança de conteúdo/dados/comportamento: cards continuam brancos e saltam na banda; fail-closed intacto (sem posts visíveis a seção some e Prova → Problema voltam contíguos — teste e2e existente segue verde); nenhum token novo; sem migration/Consent/access. Única mudança de runtime: 1 linha de className; + asserções `toHaveCSS` no e2e full-state da seção (fundo `#ebe9e9` exato — pega token quebrado em vez de só "≠ branco", hairline top `1px` com cor `rgb(0 0 0 / 12%)` exata, cards brancos) — sem screenshot diff (repo não usa regressão visual). Verificação: tsc/lint/format/unit 2318 verdes; e2e frontend: empty-state (contiguidade) e bento passaram com as asserções novas (2/2 runs que as alcançaram); o teste full-state falhou localmente no clique pós-minhas-asserções por cold-compile dev sob load ~68 — baseline com as mudanças stashed falha idêntico (falha pré-existente do ambiente, não regressão); build verde (migrations ok). knip local = limitação P3 conhecida (não carrega `payload.config.ts`); CI valida.

View file

@ -1 +0,0 @@
**S6-FOLLOWUP** — CLI Playwright 1.58.2 e filtros e2e locais: `pnpm test:e2e:affected` rodava a suíte completa silenciosamente em runs selecionados. Causa raiz (medida com o binário do repo, config real): o commander parseia posicionais e `-g`/`--list` funcionam — quem arrastava a suíte era o closure de dependências dos projects no dev mode (`setup ← campaign ← frontend ← admin` em `playwright.config.ts`): `collectProjectsAndTestFiles` filtra os arquivos por project, mas `buildProjectsClosure` repõe todos os arquivos de cada project-dependência — filtrar um spec do `frontend` puxava `campaign` (160) + `setup` (2), 177 no total com o próprio arquivo (15); `admin` puxava a suíte inteira. Em prod mode (`CI=1`) os projects não têm deps e o filtro sempre funcionou (por isso o CI selecionado estava correto). Fix: `run-e2e-affected.mjs` agora gera `test:e2e <passthrough verbatim> --no-deps -- <paths>` via helpers puros novos em `scripts/lib/playwright-e2e-args.mjs` (unit-testados, `tests/unit/playwrightE2eArgs.unit.spec.ts`) — `--no-deps` desliga o closure em runs filtrados (suíte completa mantém a cadeia OPS34 de prewarm); de quebra, o passthrough do script foi corrigido (`pnpm test:e2e:affected -- tests/x.spec.ts` descartava o path — pnpm consome o `--` e o argv nunca o continha; agora `parsePassthroughArgs` só remove um `--` líder e repassa o resto verbatim, preservando `-g grade`). Hardening: `scripts/run-e2e-affected.mjs` entrou em `HIGH_RISK_EXACT` (era o espelho local do job e2e sem gate — um PR só de runner não rodaria e2e). Docs: receitas de filtro no `playwright.config.ts` (posicional exige `--no-deps`; `--project=<família> --no-deps`; `-g`/`--list` funcionam), header do script e nota no AGENTS.md. Sem mudança de specs, CI (`--shard`) ou `gate:ci`. Verificação: unit 2343+ verdes; contagem por `--list` do binário com a forma final (18); CI do PR roda o e2e completo (o diff toca `playwright.config.ts` e `run-e2e-affected.mjs`, high-risk). Débito absorvido: hang do `pnpm test:e2e` via pnpm neste worktree (pré-existente, ambiente — binário direto funciona; não escopo). Impl plan: `docs/plans/s6-followup-playwright-filtros-cli-ignorados-impl.md`.

View file

@ -1 +0,0 @@
**S6** — home de campanha (jorgesolla1313.com.br) sem carrossel no desktop: as seções "Por que essa eleição importa" (3 cards) e "Nossa caminhada" (6 cards) mostram agora todo o conteúdo de uma vez em ≥1024px — grade estática 3 colunas (problem: 3×437px, bandeiras: 3×2 com altura por conteúdo, padding `p-4`), sem auto-avanço, sem chips/indicadores e sem `aria-current`. Mobile (<1024px) 100% preservado: carrossel com auto-avanço ~4s, chips sincronizados, swipe — componente e geometria mobile intocados. Padrão S1 (renderização-irmã com toggle CSS `hidden lg:grid`/`lg:hidden`): grade server-rendered (zero JS no desktop) + carrossel client embrulhado; os dois cards extraídos do `CampaignCarousel` para `CampaignProblemCard`/`CampaignFlagCard` (presentacionais, fonte única do markup, `size='spacious'` só na grade) e o contrato da grade (data-grid + aria-label + 3 colunas) vive num `CampaignCardGrid` local do page. O carrossel ficou byte-equivalente (só re-roteia os cards; unit tests intocados) e ganhou guard de `display:none` no auto-avanço — o timer nunca roda no desktop (chain morto numa subárvore oculta). Geometria: a seção flags recalibrada no desktop (`height: clamp(418px, calc(807.7px - 28.13vw), 520px)`, âncoras medidas em 1024/1280/1440+, respiro ≥30px, `overflow: hidden` para zoom de texto ≥125% não pintar sobre o footer); a seção problem manteve a geometria (respiro ~31px). Zero schema/migration/Consent; mesma copy/imagens/ordem; S1 e o resto da home intocados. Verificação: unit 2318 + int 791 verdes, e2e frontend 11/11 (novo teste de grade desktop com invariantes 3×1/3×2, ausência de regiões/chips/`aria-current`, respiro ≥24 e `scrollHeight ≤ clientHeight` em 1024/1280/1440/1920; teste de carrossel migrado para viewport mobile 390px com auto-avanço + chips; swipe/mobileSpacing intactos), lint/format/typecheck/knip/cycles/build verdes. `/simplify` 3 reviewers: `lg:` órfãos removidos do carrossel, grade extraída em `CampaignCardGrid`, `overflow: hidden` (zoom ≥125%), guard do timer, `document.fonts.ready` + eixo x + pino de clip no e2e, âncoras no comentário do clamp. Débitos: Issue #58 registrada (S6-FOLLOWUP — CLI do Playwright 1.58.2 ignora `--grep`/posicionais e o `test:e2e:affected` roda a suíte completa; `depends: [S6]`); defer com gatilho para os literais de gap da grade (recalibração Penpot do carrossel); descartados contratos de tipo dos cards (armadilha knip), aria-labels duplicados (copy pinada no e2e) e pino de tablet 768–1023. Impl plan: `docs/plans/home-campanha-sem-carrossel-desktop-impl.md`.

View file

@ -1 +0,0 @@
**C141 — perfis de permissão por assessor: Visão × Edição configuráveis (2026-08-19):** o coordenador/candidato define por assessor, em `/campanha/assessores` (coluna "Permissão" com badge + popover, seção no detalhe e no draft de novo assessor), o que ele **vê** (`carteira|tudo`) e o que ele **edita** (`carteira|tudo|somente_leitura`) — contas novas e existentes seguem no padrão Carteira·Edita carteira até configurarem. Enforcement fail-closed no servidor: a leitura alarga com Visão "Tudo" via prólogo novo `resolveProfileScopedRead` (municípios, lideranças, updates, pledges, atividades, contatos derivados), a escrita segue o eixo de Edição (`advisorEditingAccess`) nos `canUpdate*`/`canCreate*` de todos os domínios, no check do giro e no create individual de atividade (ambos contra `getWritableMunicipalityIds`), e a ficha de contatos passou a ser autorizada pelo escopo de escrita (`assertPersonContactWritable` — antes era "você edita o que vê", que a Visão "Tudo" alargaria em escrita de PII). Caps mantidos: demandas seguem a regra própria (C143), apoiadores (PII) e telefones de contas ficam na carteira mesmo com Tudo, e escritas de coordenação (contas, `municipality.advisors`, nível de envolvimento E14, `allocationDecision`) continuam restritas. Combinação incoerente (Edição "Tudo" sem Visão "Tudo") é rejeitada no zod, na action e no hook `enforceCoherentAdvisorProfile` (REST/admin incluídos). Migration `20260819_214755_add_campaign_user_advisor_permission_profile` (duas colunas select com default `'carteira'`, sem backfill).

View file

@ -1 +0,0 @@
**C143** — demandas visíveis apenas para responsáveis explícitos + candidato/coordenador. Novo campo `campaignDemand.responsibles` (hasMany → `campaignUser`, staff elegível via `eligibleCampaignStaffWhere` com hook `beforeValidate` que descarta ids não-staff, fail-closed) e regra de leitura/atualização por membership: assessor só vê demandas onde é responsável (`{ responsibles: { contains } }`), candidato/coordenador/admin sempre, leader nunca — a regra substitui o escopo municipal de demandas em `src/utilities/access/demands.ts` e todas as superfícies (lista, URL, busca global, demandas de atividade, C90) herdam via `overrideAccess: false`; criação sem responsáveis fica visível só a unrestricted (fail-closed). Criador entra automaticamente como responsável no hook `enforceDemandWorkflow` (cobre form, wizard e rascunhos C90); migration `20260819_233503_add_campaign_demand_responsibles` adiciona `campaign_user_id` a `campaign_demand_rels` (FK cascade) e faz backfill de `created_by_id` → responsável para demandas pré-C143. UI: picker multi-select `DemandResponsibleMultiSelect` (chips com avatar, chip do criador rotulado "(criador)" e travado na criação, sugestões = assessores do município) na criação (`/demandas/nova` em 2 colunas + wizard "Registrar pedido" empilhado) e card de gestão `DemandResponsiblesCard` no detalhe (add/remove com save full-replace + advisory lock, rótulo "Visível para: Responsáveis · Candidato · Coordenador"); busca por `searchDemandResponsibleOptions` (query curta → assessores do município; senão → staff por nome). Testes: int reescritos para a regra nova (workflow/homeSearch) + 4 casos novos (membership, fail-closed sem responsáveis, gestão add/remove/self-removal, responsável fora da carteira, descarte de id não-staff); e2e novo `campaignDemandVisibility.e2e.spec.ts` (assessor do mesmo município não vê lista/URL; hand-off de acesso add/revoke pela UI); fixture e2e ganhou `createCampaignDemand`. Verificação: unit 2444 + int 795 verdes, e2e 5/5 local (visibility + wizard + demanda do municipalidades), lint/format/knip/cycles/tsc/build limpos; importMap regenerado com `S3_*` dummy (OPS69). Nota de deploy: conferir na primeira aplicação em prod `SELECT count(*) FROM campaign_demand_rels WHERE path='responsibles'` = nº de demandas com `created_by_id`. Plano de implementação: `docs/plans/demandas-responsaveis-impl.md`.

View file

@ -1 +0,0 @@
**OPS62-FOLLOWUP** — flake do `municipalitiesWithoutUpdateTool.unit.spec.ts` (2 ocorrências 2026-08-18: gate local 19:22 e CI run 779 23:53) eliminado pela raiz, com a hipótese original da Issue refutada por reprodução. Causa raiz: o fixture do teste (não o ambiente nem locale/ICU): `daysAgo()` chamava `Date.now()` **por chamada**; as duas construções `daysAgo(40)` de `Beta`/`Alpha` podiam cair em milissegundos diferentes sob carga (GC/preempção do fork do vitest na máquina compartilhada), deixando o timestamp de `Alpha` estritamente mais novo — em `sortStale` (`getMunicipalitiesWithoutUpdate.ts`) o sort por timestamp vencia o tie-break por nome e o teste recebia `[Beta, Alpha]` no lugar de `[Alpha, Beta]` (só o par trocado, resto correto — "sort não atuou" estava errado; o sort atuou sobre timestamps acidentalmente desiguais). Repro mecânica (sort de produção verbatim + fixture): fixture atual 0.08% de falhas em 200k runs sem pausa e **100%** com pausa de 1 ms injetada; fixture ancorado 0 em 2000 runs. Fix (test-only, nada em produção — o sort real é determinístico sobre timestamps fixos do DB): âncora `fixtureNowMs = Date.now()` no load do módulo (padrão do `prioritiesTool.unit.spec.ts`) + hoist do timestamp empatado (`const stale40 = daysAgo(40)` compartilhado por `Beta`/`Alpha` — igualdade estrutural, imune até a regressão da âncora) + comentário explicando a classe de flake. Scan da mesma classe: `prioritiesTool` (âncora no load) e `suggestionCatalog` (now fixo) já seguros; nenhum outro spec combina `Date.now()` por chamada com assunção de igualdade. Verificação: 30/30 runs do spec, unit full (249 arquivos/2405 testes), int (89/791), tsc, lint, format, check:cycles verdes (knip só com o P3 ledgered de sempre). /simplify: 3 reviewers aprovaram; débito absorvido no próprio teste (comentário do porquê). Impl plan: `docs/plans/ops62-followup-ci-flake-fixture-timestamps-impl.md`.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-19):** **OPS66** — deadlock de migrations em caminho estático: o deploy buildava o runner **antes** do migrate e, se uma migration criasse tabela lida em geração estática (caso S2, 2026-08-18: `social_feed_settings` na home), o `next build` falhava para sempre (`relation ... does not exist`) e o migrate nunca rodava — 4 deploys seguidos vermelhos até o migrator manual. Fix em `scripts/deploy-homeserver.sh` (reordenação de fases, sem custo novo — migrate e build do migrator já rodavam em todo deploy): **build do migrator** (o estágio não roda `next build` — builda contra o schema antigo) → push/tag do migrator → swap do compose (backup + trap na mesma posição) → **migrate** via `docker compose run teqo-1313-migrate` (caminho canônico do serviço de maintenance, agora com a imagem do SHA novo) → **build do runner** contra o banco JÁ migrado → push/tag → rollout → healthcheck → smoke. Alternativas rejeitadas: `payload migrate` dentro do build do runner (efeito colateral de escrita em prod a partir de um processo de build; contraria o desenho deliberado do Dockerfile) e `docker run` direto do migrator com env-file (duplicaria a definição do serviço de maintenance — caminho paralelo ao canônico). Caveat do rollback ajustado no runbook: migrate antes do build do runner deixa o banco à frente do código se o build falhar depois (mesma classe do caveat atual — migrations append-only e revisadas). Testes: `tests/unit/deployScript.unit.spec.ts` com pin de ordem completo (migrator build < swap < migrate < runner build < rollout) + invariante novo do Dockerfile (estágio migrator nunca roda `next build`). Runbook `docs/ops/teqo-1313-deploy.md` atualizado (fluxo, caveat, falha conhecida nova). Dockerfile/AGENTS.md com a precisão build-after-migrate.

View file

@ -1 +0,0 @@
**OPS67** — o cliente `scripts/lib/forgejo-api.mjs` ganhou **retry com backoff** nos fetchs: um `fetch failed` transitório (rede/DNS/reset — o sintoma que matava o run do safety net `agent-pr-ready-automerge.yml` no meio da espera de checks) agora retenta em **qualquer método**, e a leitura de body interrompida no meio (mesma classe de falha de rede) retenta **em GET**; 5xx do Cloudflare (502/503/504) retenta **só em GET** (protege o poll de 45 min do `waitForChecks` sem arriscar duplicar efeitos colaterais de escrita — body do 5xx cancelado antes do retry para liberar o socket); 4xx e body-drop em escrita continuam falhando na hora, fail-closed intacto. Política: 3 retries, base 300 ms ×2, jitter ±20%, `console.warn` pt-BR em cada retry (`tentativa N/4`), validação `retries >= 0`/`backoffMs >= 0`. Opções flat no `createApi` (`retries`, `backoffMs`, `jitter`, `sleepImpl`) — defaults preservam o comportamento de todos os 19 call sites (safety net, claim/status, issue-transition, pool, worktree) sem mudança de chamador; zero-dep mantido. Testes: 12 specs novas em `forgejoApi.unit.spec.ts` (rede GET/POST → sucesso, body-drop GET retenta + POST fail-closed, exaustão de rede/5xx, `retries: 0`, backoff exponencial [100, 200, 400], jitter ±20% com `Math.random` pinado, 4xx sem retry, 5xx-GET retry/exaustão, 5xx-POST sem retry) + pin no spec 403 existente (1 fetch por invocação). Verificação: unit 2403 verdes; lint/format/typecheck/knip/cycles verdes. Impl plan: `docs/plans/ops67-forgejo-api-retry-backoff-impl.md` (aprovado no gate humano; refinamentos do /simplify absorvidos na execução).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-19):** **OPS68** — fechamento da parte 2 do OPS50: reescrita dos docs canônicos operacionais que ainda descreviam a era GitHub/Vercel. **12 arquivos, 78+/82−, edições linha-a-linha:** `docs/AGENT-OPS.md` (norte "só Forgejo + Cursor"; fonte canônica = Issues do Forgejo; fluxo do agente e Contrato de PR sem `gh` — criação via API/MCP + merge pelo safety net `agent-pr-ready-automerge.yml`, o único que espera o rollup `CI (PR) / checks`; "Só humano" = envs de `~/stack/teqo-1313.env` + branch protection + runbook; tabela de secrets sem `POOL_GITHUB_TOKEN` (legado — o script o ignora) e com nota de pool dormente; linha `archive-cursor-agent.yml` na tabela CI; "smoke Neon stage" → sem smoke stage), `README.md` (cheatsheet de 5 linhas reescrito: claim → push → PR via API/MCP → safety net mergea → ci.yml em janela de 30 min + deploy homeserver; Neon → homeserver Postgres; `PROD_DATABASE_URL` = `DATABASE_URL` de `~/stack/teqo-1313.env`; migrações no deploy via `teqo-1313-migrate`; "GitHub mirror" → Forgejo Actions; papéis `coordinator/advisor/leader/candidate`; "electoral nuclei" → municípios; Issues → link Forgejo), `docs/roadmap.md` (só o ponteiro da fonte canônica — o stub continua congelado), `AGENTS.md` (refs Neon → homeserver `teqo_1313`; job do gate `check`→`gate`, key real do YAML; "Vercel preview" → host genérico na rationale do WebAuthn), `.agents/rules/agent-pr-workflow.mdc` (comandos `gh pr create/merge/checks` morrem — `gh` não existe em nenhuma máquina (OPS50); regra descreve API/MCP + safety net; "There is no deploy from CI" corrigido — OPS53 colocou o deploy no `ci.yml`), e as skills `work-issue` (SKILL + `execution-pipeline`), `plan-issue` (frontmatter, gate "nada no Forgejo", dedup via `pnpm issue all`), `project-status`, `capture-review-debts`, `engineering-audit` (`gh issue list` → `pnpm issue all`/API; `issue-done-on-stage-merge.yml`→`on-main`; merge `stage`→`main`), `worktree-next-issue` ("resto do Forgejo") e `agent-pool` (descrição Forgejo Actions; workflow marcado removido OPS65; estado em `pool-state.json`, não mais variables). **Mantido como registro histórico** (exclusões explícitas do plano): menções de Vercel/Neon/POOL_GITHUB que documentam remoções (AGENTS.md L15/L105/L167, AGENT-OPS L94/L98, "Vercel/Git-integration deploy gate" como guardrail no rule). Fonte canônica única: `git.solla.dev/fsolla/teqo`. Sem migration/access/Consent/UI. Gate: grep de saída zero nas classes `gh pr`/`gh issue`/`github.com/fsolla`/`GitHub Issues`/`GitHub mirror`/`POOL_GITHUB`/Neon-em-uso; docs-only → CI roda os docs-guards.

View file

@ -1 +0,0 @@
**OPS69** — o admin do Payload abria **em branco em produção** (P0, 2026-08-19): o `importMap` commitado (`src/app/(payload)/admin/importMap.js`) ficou sem a entrada `@payloadcms/storage-s3/client#S3ClientUploadHandler` — o commit OPS52 (`dabc00f4`) removeu o handler do Vercel Blob e **não** adicionou o do S3, porque o `pnpm generate:importmap` rodou **sem** as envs `S3_*`: com o plugin `s3Storage` condicional, sem as envs o importMap sai consistente (dev/test, storage local) e com elas (produção, Garage) o admin precisa do handler e o montava → `getFromImportMap: PayloadComponent not found in importMap` → login nunca renderizava. Fix mínimo (opção A do plano, aprovado no gate): regenerar o importMap com as 4 `S3_*` dummy inline (a mecânica do CLI: `iterateConfig` sem DB; `loadEnvConfig` do `@next/env` não sobrescreve envs já no processo) → diff de **exatamente +2 linhas** (import + entrada do `S3ClientUploadHandler`), nenhum outro drift. Verificação: smoke headless com S3 ativo no dev local — `/admin/login` renderiza (6 inputs, formulário visível, **zero erros de console**) vs. o branco reproduzido no diagnóstico; gates completos verdes (tsc/lint/format/knip/cycles, unit+int 791, build). Sem migration/access/Consent/UI. Docs: linha no AGENTS.md (checklist local: `generate:importmap` precisa das `S3_*` setadas — valores dummy ok — enquanto o plugin for condicional; sem elas o importMap fica órfão, classe OPS69) + item sucessor registrado (guard de CI que regenera com envs dummy e falha o gate se o arquivo commitado divergir — `depends` #77). Verificação pós-deploy: `/admin` abre o login e o log do container não re-emite o `getFromImportMap` para o handler (runbook `docs/ops/teqo-1313-deploy.md`).

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-19):** **OPS71 Fase 2** — remoção dos arquivos do fluxo Forgejo após validação ao vivo do GitHub Actions (PRs #742/#743: CI verde, auto-merge nativo, flips via API). **14 arquivos deletados:** `.forgejo/workflows/` (6 — ci-pr.yml, ci.yml, agent-pr-ready-automerge.yml, issue-done-on-main-merge.yml, plan-issue-ready-on-main-merge.yml, archive-cursor-agent.yml), `scripts/forgejo-pr-automerge.mjs`, `scripts/forgejo-dispatch.mjs`, `scripts/ci-classify-production.mjs`, `scripts/lib/dockerignore.mjs`, `scripts/lib/branch-protection.mjs` e as specs órfãs `ciClassifyProduction`/`dockerignoreMatch`/`branchProtection` `.unit.spec.ts`. **Consumidores funcionais achados pela varredura (fora do escopo literal do body) e ajustados na mesma mudança:** `scripts/lib/test-affected-core.mjs` perdeu as 2 entries de `HIGH_RISK_EXACT` dos scripts mortos; `tests/unit/ciSkipInvariants.unit.spec.ts` tirou os 2 `.forgejo/workflows/*` do loop `.next-e2e`, os 2 paths do pin de high-risk e **reescreveu o teste da janela de 30 min (OPS65) para o invariante vivo da era GitHub**: `deploy.yml` é `workflow_dispatch`-only (sem `push:`/`schedule:` — deploy nunca automático) e `ci-pr.yml` não referencia o gate morto. **Docs:** `AGENTS.md` (frase "preservados até Fase 2" → removidos; rollback = revert do PR ou clone do main congelado do Forgejo), runbook `docs/ops/teqo-1313-deploy.md` (Rollback do cutover idem), `README.md` (cheatsheet de 5 linhas era-Forgejo com janela de 30 min + deploy automático → era-GitHub: auto-merge nativo + deploy manual dispatch), comentários com referência morta em `scripts/gate-ci.mjs`, `scripts/github-pr-automerge.mjs` e `.github/workflows/agent-pr-ready-automerge.yml`. **Intocados (anti-goal tracker):** `forgejo-api.mjs`, `agent-*.mjs`, `issue.mjs`, `forgejo-issue-transition.mjs`, `agent-promote-related-on-merge.mjs`, `.forgejo/worktree.env` (consumido pelo provisionamento de worktree), `.dockerignore` (o arquivo — `docker build` do deploy o consome; só o lib de parse morreu). `docs/AGENT-OPS.md` já era era-GitHub (zero referências). Histórico (plans/changelogs antigos) congelado. Sem migration/access/Consent/UI. Gate: tsc/lint/format/knip/cycles/unit+int/build verdes; grep de saneamento `forgejo-pr-automerge|forgejo-dispatch|ci-classify|dockerignore.mjs|branch-protection.mjs` → só histórico.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-19):** **OPS71** — CI/PR/deploy do Forgejo Actions de volta para o **GitHub Actions**, com o **tracker de Issues permanecendo no Forgejo** (labels, claims, `pnpm issue`/`agent:*` e flips pós-merge continuam via API do Forgejo; o GitHub é só o host do Actions). O PR CI (`ci-pr.yml`, job único `checks` — OPS62) roda no GitHub com a mesma cascata (guards → lint → format → typecheck → knip → cycles → unit → int → build → e2e selected/afetado via `ci-scope.mjs`), com `concurrency` nativo cancelando o run em push novo e `if:` same-repo (repo público — fork não queima minutes). Seu check-run `CI (PR) / checks` é o required check do `main` e o gate do **auto-merge nativo** (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`: draft `cursor/*` → Ready via PATCH, arm `enablePullRequestAutoMerge` GraphQL com REBASE — o servidor espera o required check verde; sem poll; o pin OPS64 "rollup pode mentir" é estrutural aqui: job único posta um check-run honesto). **`ci.yml` (verificador de main) eliminado** — publicar é **dispatch manual** (`deploy.yml`): `verify` (hosted, suíte full incl. e2e full) → `deploy` (`needs`, `runs-on: [self-hosted, homeserver]`) executa `scripts/deploy-homeserver.sh <sha>` **localmente** no runner do GitHub instalado no homeserver (sem SSH; outbound-only; o hosted nunca toca o homeserver); o script re-aponta o workspace `~/teqo-deploy` para `TEQO_REPO_URL` (default `https://github.com/fsolla/teqo.git`, público). `pnpm push` passa a empurrar para `origin` = **GitHub** (cutover manual de remote documentado; o repo Forgejo congela em main, tracker vivo por API). Novos helpers plain-Node zero-dep: `scripts/lib/github-api.mjs` (REST+GraphQL, retry OPS67), `scripts/lib/github-pr-flow.mjs` (decisão draft/veto/skip, pura), `scripts/lib/github-branch-protection.mjs` (regra desejada + drift, `enforce_admins: true`), `scripts/github-pr.mjs` (criação de PR Ready — nunca draft), `scripts/configure-branch-protection.mjs` reescrito para a REST do GitHub; `forgejo-issue-transition.mjs` aceita `PR_BODY` (body do PR via evento GitHub; flip continua no Forgejo por `FORGEJO_API_TOKEN`). **Cutover em duas fases (gate):** nada do Forgejo foi apagado (`.forgejo/workflows/`, `forgejo-pr-automerge.mjs`, `forgejo-dispatch.mjs`, `ci-classify-production.mjs` preservados como rollback) — a remoção é entrega sucessora pós-validação. Testes: `githubApi` (10), `githubBranchProtection` (10), `githubPrFlow` (9) unit novos. Docs: `AGENTS.md`, `docs/AGENT-OPS.md`, `.agents/rules/agent-pr-workflow.mdc`, `work-issue/execution-pipeline.md`, `agent-work-issue`, `capture-review-debts`, runbook `docs/ops/teqo-1313-deploy.md` (dispatch manual, runner no homeserver, cutover com ordem, rollback).

View file

@ -1 +0,0 @@
**OPS72** — política de e2e: **CI de PR sem e2e full; local afetado como passo da skill; full só antes do deploy**. Decisão do gate (2026-08-19): (1) **local = discricionário** — o agente roda os e2e que criou + os da mesma superfície afetada, decide quais (`pnpm test:e2e:affected` é a ferramenta, não um gate mecânico; não está no `gate:push`); (2) **CI de PR = blast radius** — `ci-pr.yml` perdeu o step `E2E tests (full suite)`; o e2e roda só em modo `selected` (specs que o classifier detectou como tocadas); diff high-risk (schema/lockfile/harness) classifica `full` e o PR simplesmente não roda e2e — deliberado: o espelho local roda full nesse mesmo diff e o `verify` do deploy roda full antes de publicar; (3) **main = full no deploy** — inegociável, já existia no `deploy.yml` (nada mudou lá). Docs: `work-issue` e `agent-work-issue` (checklist + Passo 4), `execution-pipeline.md` (nova seção "E2E local afetado" com o critério + a **limitação da #72**: `--no-deps` + projetos paralelos colide no `seedTestUser` → `--workers=1` ou a cadeia padrão de projetos), `AGENTS.md` (parágrafo OPS59 → política OPS72), `docs/AGENT-OPS.md` (fluxo do agente + linha do `ci-pr.yml` na tabela), `.agents/rules/agent-pr-workflow.mdc` (descrição do `gate:push`), mensagens informativas do `gate-ci.mjs` (texto-only — e2e continua fora do gate) e comentários do workflow. Mecânica intocada por escopo (`ci-scope.mjs`, manifest, `run-e2e-affected.mjs`); o fix da #72 fica na fila (só documentado). Verificação: format/lint/tsc verdes, unit 2444 + int 791 verdes; o e2e local afetado rodou a suíte **full** (o diff toca `scripts/gate-ci.mjs`, high-risk → modo `full` — a política em ação): 154 passed em dev mode; 8 falhas de ambiente (timeouts/cold-compile sob carga) triadas — 5/8 passaram em re-run e as 3 restantes passaram em `E2E_PROD=1` (o modo do CI/verify; 41 passed + 2 flaky que passaram isoladas), zero regressão de produto; o CI deste PR não roda e2e (modo `full` por design desta mesma política). Impl plan: `docs/plans/ops72-e2e-local-afetado-ci-sem-full-impl.md`. Nota: `E2E_PROD=1` local regenera o `importMap.js` **sem** as envs de S3 (classe OPS69 — o guard do OPS70/#87 cobre; restaurado na entrega com envs dummy).

View file

@ -1 +0,0 @@
**S10 — Pixel do Meta (Facebook) na página pública de campanha (2026-08-19):** o time de campanha paga pode configurar o ID numérico do Pixel (Dataset do Events Manager) no admin — global `SiteSettings` → group "Rastreamento / Ads" → "ID do Pixel do Meta (Facebook)", mesma validação/normalização do precedente dos abaixo-assinados (5–20 dígitos, sem HTML/JS; regras agora vivem num único dono em `facebookPixel.ts`, com o `Petition` refatorado para o helper compartilhado). Com o ID configurado, a home pública de campanha (`/`) carrega o base code + `PageView` do Meta (injeção no layout do grupo `(home)` via `next/script`, escopada só à página de campanha — nada de PageView vazando para notícias/etc.); sem ID, nada é carregado (fail-closed). A conclusão da captura de novidades (S9) dispara exatamente um `Lead` (`content_name` "novidades-da-campanha", `eventID` UUID) no padrão do `PetitionForm` — nunca no page load. Leitura via `getCachedGlobal('site-settings')` (ISR sob a tag `global_site-settings`; a edição do admin já revalida a home). Migração `20260819_213947_add_site_settings_facebook_pixel_id` (colunas `tracking_facebook_pixel_id` + versão, sem backfill). Fora da fatia (gatilhos herdados do plano da petição): CAPI/Advanced Matching, dedup `eventID`, fila/retry de `Lead` e cookie banner/CMP. E2e novo `campaignHomePixel` (script `#meta-pixel-*` presente só com ID configurado, CDN stubada; `mode: serial` por precedente).

View file

@ -1 +0,0 @@
**S11** — o feed do Instagram configurado **sem aparecer na home** fica diagnosticável: fim do fail-closed silencioso da S3. A assessoria vê, na mesma global onde preenche o token, o estado da sincronização (`instagramSyncStatus` jsonb hidden na `social-feed-settings`) e o **porquê** de os posts não aparecerem — o painel `InstagramSyncStatusPanel` (campo `ui` irmão do picker) mostra 5 estados fiéis ao rascunho aprovado (desativado / ainda não configurado / falha com motivo em linguagem de produto + "Tentar sincronizar de novo" / sincronizado · há X min · N posts / aguardando 1ª sync). A causa da ocorrência de produção (board vazio com token+ID preenchidos) é agora confirmável em 1 clique após o deploy. Engenharia: `loadInstagramFeed` passou a parsear o body de erro da Graph API e lançar `InstagramApiError` (`status` + `{message,type}`); `describeInstagramError` mapeia para copy pt-BR com a correção — OAuthException/expired → "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login" (o refresh endpoint da Graph API só renova tokens de Instagram Login), "Invalid user id" → "confira o ID da conta Business/Creator", 5xx → indisponível, `TypeError` → rede. Estado escrito por 3 caminhos: render da home (`getInstagramFeed`, dentro do `unstable_cache`, SQL direto sem hooks — padrão anti-`revalidateTag`-no-cache da S2), `afterChange` da global (sync **awaited** quando a credencial IG muda — `instagramCredentialsChanged(doc, previousDoc)`, gate que pula save de exclusões/YouTube; o sync é importado lazy no hook para o `payload.config.ts` não puxar o módulo do feed no load, que é o que todo spec int importa) e a rota `POST /api/social-feed/sync` (botão; `payload.auth` sessão admin exigindo `users` + `isSameOriginRequest` CSRF — allowlistada no spec de convenção de POST routes com razão, é auth de admin ≠ envelope `/campanha`). **Bug real descoberto e corrigido na entrega:** o `afterChange` roda DENTRO da transação do save (a row lock fica presa até os hooks retornarem), então escrever o status/snapshot via pool numa segunda conexão **deadlockava no lock da própria linha** (~80-230s por save, transações-zumbi segurando o lock). A correção roteia os persists do sync-hook pela **mesma transação** (`getPostgresTransactionDatabase` — o helper transacional da C6) com um `req` opcional no `syncInstagramFeed`; o render e a rota sem transação aberta seguem no pool. Diagnóstico reproduzido em `pg_stat_activity` (UPDATE `transactionid` Lock vs `idle in transaction`) e fix verificado (45ms vs 232s). Stub IG ganha estado `invalid-token` (400 OAuthException) para o e2e da cena de falha; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra, validação via curl `/me` e `refresh_access_token`, geração pelo Instagram Login, o que NÃO fazer). Migration `20260820_003426_add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva). **/simplify (3 reviewers):** absorvi a correção P1 de diagnóstico — o mapeamento de ID exigia só "user" (regex frouxo) e mensagens reais de token OAuthException ("the **user** must be logged in", "user changed password") caíam na branch de ID, mostrando a razão errada; passei a exigir "user" E "id" juntos (`isInvalidUserIdMessage`) e adicionei o mapeamento de `AbortError` (timeout → "demorou demais"). Painel: gate passou a respeitar o master `enabled` (master off → "Instagram desativado", nunca "Sincronizado no board"), rótulo do botão no estado de primeira sync ("Tentar sincronizar" vs "de novo"), `outcome` transiente limpo ao editar credencial e `aria-live` nos boxes interativos. Testes: caminho transacional do `syncInstagramFeed` (persist na mesma transação) + fallback pool cobertos por unit; e2e do painel ganhou as cenas fail-500 e "desativado"; cleanup do stub global virou `afterEach`. Débitos registrados: #124 (janela de lock: fetch de rede no sync-hook segura a row lock até 10s, acoplando a home ao save) e #125 (polish P3 — tipo de persist duplicado, rota 200 vs 400, last-writer-wins stale, `5 posts` hardcoded). Verificação: unit 2463 + int 791 verdes; lint/format/tsc/cycles verdes; `pnpm build` ok; e2e local — smoke admin 5/5 (painel ok → falha token → retry → falha 500 → desativado → não configurado) + describe serial frontend 19/19.

View file

@ -1 +0,0 @@
**S4** — botão discreto de compartilhar no WhatsApp por card da seção "Acompanhe de perto" da home (jorgesolla1313.com.br): cada card (Artigo/YouTube/Instagram, o board S1+S2+S3) ganha um botão redondo no canto superior direito (visual 28px sobre área de toque 44px, `aria-label` "Compartilhar", sem competir com o CTA do card) que abre um popover de 2 ações: **"Compartilhar no WhatsApp"** — `wa.me` sem destinatário (WhatsApp do próprio remetente; o `buildWhatsAppTextShareUrl` do S6 foi movido para `src/lib/phone.ts` — dono do wa.me, re-exportado no `homeSearchShare.ts` — e o `contentShare.ts` reusa, sem twin) com mensagem pré-configurada **por fonte** (decisão B do gate: "Olha isso do Solla: {título} — {link}" / "Olha esse vídeo…" / "Olha esse post…"; link = página canônica do artigo resolvida contra `window.location.origin` no cliente, ou URL da plataforma para YT/IG; nova aba `noopener noreferrer") — e **"Copiar link"** (clipboard com "Link copiado" + `aria-live`para leitores de tela, fallback honesto em falha). Estrutura: o card inteiro é um anchor — o botão vive como **irmão** absoluto num wrapper`relative`(botão dentro do anchor seria HTML inválido); o conteúdo do popover usa`ui/Popover`(Radix, portal — escapa o`overflow-x-auto`do carrossel mobile que cliparia um menu absoluto), sem roles`menu`/`menuitem`(a semântica honesta de popover com 2 ações — Radix já emite`aria-expanded`). Zero migration/schema/Consent/escrita no servidor — montagem de URL 100% cliente, nada coletado (anti-goal da intenção). Lib pura `src/lib/contentShare.ts`(prefixos por fonte, resolução de link fail-soft contra permalink malformado, URL wa.me) + 11 units; e2e novo no describe serial da seção (cadeia 19/19 verde local): botão por card do bento, mensagem por fonte com URL absoluta,`target/rel`, copiar→clipboard real (`expect.poll`), Escape/clique-fora, mobile ≥44px + 28px visual + portal dentro do viewport, card ativo pinado por identidade. Verificação: unit 2402 + int 791 verdes, tsc/lint/format/cycles/build verdes; knip = limitação P3 pré-existente (não carrega `payload.config.ts`); CI valida. /simplify: 3 revisores paralelos absorvidos (twin do wa.me resolvido pelo owner move, máquina de feedback de copiar simplificada para effect único, foco/roles honestos, min-h-11 nos itens, `aria-live`, origin lazy sem guard morto, malformed-href fail-soft, e2e endurecido: pin de identidade do card ativo + unmount do popover antes do próximo trigger + helper de decode `+`); 1 débito deferido (dedupe do blob de 15 campos do `updateSocialFeedSettings` nos 5 testes do describe — quando os testes S2/S3 forem tocados de novo).

View file

@ -1 +0,0 @@
**S7** — polimento dos cards da seção "Acompanhe de perto" da home de campanha (jorgesolla1313.com.br), look editorial e direto no lugar do "card de app": cantos retos (**sem `rounded-xl` no card e `rounded-lg` na capa**), capa de **borda a borda** (o `p-3` saiu do contêiner do card e foi para o bloco de texto — título/subtítulo/meta continuam respirando `p-3` das bordas; `mt-auto` da meta e `overflow-hidden` do zoom hover intactos) e, por decisão do humano posterior ao gate, **sem borda (`border --campaign-line`) e sem sombra** (`shadow-sm`/`hover:shadow-md`) — o card branco lê contra a banda do S5 só pela forma. **Chips de badge removidos de todos os cards** (decisão B do gate): nem os tipos de post do site (`POST_TYPE_BADGE_LABELS` deletado de `src/utilities/posts.ts`, junto com o describe no unit test) nem as origens externas YouTube/Instagram (sinalização externa segue por `target="_blank"`/`rel="noopener"`); `badgeLabel` saiu do tipo `CampaignContentCardData`. Superfície única no componente dono (`CampaignContentCard.tsx`) — bento e carrossel herdam; `ContentShareButton` (S4), banda do S5, carrossel e fail-closed da seção intocados. Zero migration/schema/Consent. e2e do describe da seção atualizados para o novo estado: loop de badges → ausência (`toHaveCount(0)` para os 4 tipos + YouTube/Instagram), pins CSS do card (`border-radius`/`border-width`/`box-shadow` + raio da capa) e pin geométrico da capa de borda a borda (boundingBox do card × capa). Verificação: gate:fast verde, unit 2415 + int 791 verdes, e2e frontend 19/19 local, format/knip/cycles limpos. Plano de implementação: `docs/plans/cards-acompanhe-de-perto-polimento-impl.md`.

View file

@ -1 +0,0 @@
**S8** — seção "Nossa história" na home de campanha (jorgesolla1313.com.br): o vídeo da trajetória do Dep. Jorge Solla (~1:40) ganha lugar próprio na home, após a seção "Bandeiras" e antes do rodapé — eyebrow "Nossa história" + título "Conheça a trajetória de Jorge Solla" + frame vertical 9:16 (desktop 2 colunas, copy à esquerda e frame 300px à direita centralizados; mobile empilhado, frame full-width cap 420px). **Divergência aprovada em execução pelo humano:** o vídeo é embutido do canal do deputado no YouTube (Short `i_fbclWWC5o`) em vez do asset self-hosted do plano de intenção — cada play embutido conta view no canal; `youtube-nocookie` (LGPD), `loading="lazy"` nativo (zero bytes até o frame se aproximar do viewport), `playsinline`, `allowfullscreen`, `title` a11y, `allow` com `compute-pressure` (silencia o console.error do player — o guard do e2e falha em console.error); CTA "Assistir à história" rola até o player (play cross-origin não é disparável). Os encodes one-off H.264/AV1 + poster da foto JOA00327 (14.9 MB, validados por design-vision contra o orçamento de 15 MB da intenção) ficam como artefato local gitignored em `data/encode/` — fallback pronto se um dia se voltar a self-hosted. Zero schema/migration/Consent; nenhuma seção existente tocada. e2e novo (posição, copy, atributos do embed, CTA→viewport, layout desktop 2 colunas com centro vertical, 9:16 mobile sem overflow) + abort global do `youtube-nocookie` no fixture `e2eTest.ts` (hermeticidade CI). Verificação: unit 2416 + int 791 verdes, tsc/lint/format/cycles/build verdes; e2e frontend 20/20; knip = limitação P3 pré-existente. /simplify: 3 revisores paralelos absorvidos (JSDoc enxuto, e2e sem assert redundante, plano sem conteúdo morto, desktop asserts); 2 débitos deferidos no impl plan (rótulo do CTA se a assessoria reabrir copy; dedupe da classe do CTA com `CampaignContentSection` quando S5 #33 terminar).

View file

@ -1 +0,0 @@
**S9 — captura de novidades da campanha na home pública (2026-08-19):** a home de campanha (`jorgesolla1313.com.br`) ganhou a seção "Receba as novidades da campanha" logo acima do rodapé — nome + WhatsApp obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado "Quero fazer parte do time" (frequência + grupos de WhatsApp vs. comunicações esporádicas) e confirmação in-place com atalho para as bandeiras. O CTA secundário do hero virou o atalho da seção (âncora `#novidades`, scroll suave no container da home; "Conhecer bandeiras" sai, as bandeiras seguem no rodapé e no estado de sucesso). O registro é `Contact` + `Subscription` em transação com consentimento NOVO por chave estável `campanha-novidades` (fail-closed: sem o texto aprovado no admin, o form recusa a captura — nada é gravado); a escolha do toggle sobrevive como `subscription.campaignLevel` (`time|esporadico`, coluna própria na lista do admin). Migração `20260819_192605_add_campaign_newsletter_capture` (DROP NOT NULL em `contact.state` — estado opcional no form, ficha staff via zod segue exigindo — + coluna `campaign_level` com enum). Precedente `submitWhatsapp` preservado; action nova usa `withPayloadTransaction` (o dono do concern de transação, não o hand-roll gêmeo — `submitWhatsapp`/`submitPetitionSignature` ficam como débito de alinhamento). S10 (pixel Meta) dispara `Lead` na costura do sucesso do form. Chave de consentimento pendente de aprovação jurídica antes da ativação.

View file

@ -1 +0,0 @@
**Recently resolved (2026-08-20):** **OPS71-FLIP** — flip pós-merge falhou/silencioso no fluxo GitHub, validado e endurecido (Issue #119, achado ao vivo no merge do PR #746). **Investigação (GitHub API pública):** o `closed` do PR #746 (merge por `github-actions[bot]`, auto-merge armado com o `GITHUB_TOKEN` nativo) **não criou run nenhum** — anti-recursão documentada do GitHub (eventos acionados por ações do `GITHUB_TOKEN` não geram runs; o `closed` fica no timeline com 0 runs; comparação com o #742, mergeado por `fsolla`, cujos runs nasceram 3s após o merge). O 410 do run do #742 é falha separada (API do Forgejo respondeu 410 Gone nginx, transitório). **Endurecimento:** (1) **fix de raiz** — o arm do auto-merge (`agent-pr-ready-automerge.yml` → `scripts/github-pr-automerge.mjs`) agora usa o secret novo **`AUTOMERGE_PAT`** (PAT real do fsolla; decisão pura `automergeArmingToken` em `github-pr-flow.mjs` com fail-closed — sem o secret o job sai 1, nunca fallback pro bot token; o merge passa a ser atribuído a usuário real e o `closed` volta a gerar runs); (2) **recuperação manual** — `workflow_dispatch` com input `pr` nos dois flips (`issue-done-on-main-merge.yml`, `plan-issue-ready-on-main-merge.yml`): o job busca o PR via `gh` com o mesmo contrato do evento (merged + base main + same-repo, senão skip) e repassa o body como `PR_BODY`; (3) **close da Issue** — `forgejo-issue-transition.mjs` agora `PATCH state=closed` via novo `closeIssue` do `forgejo-api.mjs` (era-Forgejo o merge fechava nativo; era-GitHub o PR não toca o tracker — antes, toda Issue mergeada ficava `OPEN` com `done`+`in-prod`); (4) **410 no retry** — `forgejo-api.mjs` retenta **410 em qualquer método** (proxy respondeu antes da origem — sem efeito duplicado; 5xx segue fail-closed em escrita) e corpo nginx vira marker compacto `corpo não-JSON (proxy/nginx…)` no erro. **Testes:** 8 specs novas (retry 410 GET/write, marker após 410 esgotado, `closeIssue` PATCH pin, `automergeArmingToken` ok/fail-closed/vazio) — 50 unit verdes nas specs tocadas. **Docs:** `AGENTS.md` (frase do auto-merge com o PAT + porquê), `docs/AGENT-OPS.md` (tabelas de workflows/secrets + runbook de recuperação manual com fallback local `PR_BODY`). Sem migration/access/Consent/UI. Impl: [`ops71-flip-pos-merge-falhou-silencioso-impl.md`](plans/ops71-flip-pos-merge-falhou-silencioso-impl.md).

View file

@ -1 +0,0 @@
**OPS71-INFRA (2026-08-19):** runner **self-hosted do GitHub instalado no homeserver** (Issue #113) — o job `deploy` do `deploy.yml` (dispatch manual) agora roda localmente no homeserver, sem SSH. Detalhes: `~/actions-runner` v2.336.0 linux-x64 (sha256 conferido contra a API de releases), nome `teqo-1313-runner`, labels `self-hosted` + `homeserver`, registrado com o PAT do OPS71 (registration token TTL 1h gerado e aplicado na sequência). Roda como **systemd user** `actions.runner.fsolla-teqo.teqo-1313-runner.service` (linger já ativo; `svc.sh install` exige root — unit user criado manualmente do template oficial), usuário `fsolla` (docker + `~/stack` envs), workdir `_work`, outbound-only (funciona atrás do Cloudflare tunnel). Verificado `online` na API do GitHub com as labels corretas. Registrado em infra-solla `STATE.md` (seção Teqo: runner + fluxo deploy manual atualizado; linha do repo/CI corrigida para o pós-cutover GitHub). Workspace `~/teqo-deploy` re-aponta para o GitHub no primeiro deploy via `set-url` idempotente do script. Pendência registrada: secret `CURSOR_API_KEY` no GitHub fica dormente (helper `archive-cursor-agent.yml`; a key não existe no ambiente — pool morto OPS65) até o usuário pedir a reativação.

View file

@ -1 +0,0 @@
**OPS73 — deploy do fix do admin em produção (2026-08-20):** o `/admin` do Payload (jorgesolla1313.com.br) voltou a renderizar o formulário de login em produção — o bug de "tela branca" que travava o CMS do time editorial/campanha está resolvido e verificado. Durante o diagnóstico descobriu-se que a premissa do plano ("produção nunca recebeu o fix OPS69") estava errada: o deploy das 23:09 de 2026-08-19 (revision `8c734ba`) já continha o fix do importMap **no histórico**, mas o commit S9 (`8d2352c0`, captura de novidades na home) tinha regenerado o importMap **sem** as envs `S3_*`, removendo o handler `@payloadcms/storage-s3/client#S3ClientUploadHandler` e reintroduzindo o erro `getFromImportMap: PayloadComponent not found in importMap` — o admin abria em branco de novo ("funcionou e no deploy seguinte parou"). Verificado também que o **main do GitHub havia divergido do Forgejo** (pós-cutover OPS71): o GitHub tinha o fix + OPS71, mas não as features S8 (seção "Nossa história") e S9 (captura de novidades) que produção já rodava — deployar o main como estava regrediria as duas. A entrega: PR #751 re-homou no GitHub main as features S8+S9 (cherry-pick dos commits aprovados no Forgejo `8d2352c0` + `488dcf7d`), mantendo o handler no importMap (prova: `generate:importmap` com envs `S3_*` dummy → diff vazio vs main); CI verde; aguardando o merge. Deploy pós-merge via o pipeline canônico do OPS71 (dispatch manual do `deploy.yml`: `verify` full → job `deploy` no runner self-hosted recém-instalado no homeserver — 2 tentativas "stale run" por merges paralelos no meio do verify, terceira aplicou `dba75ff8`); migração C143 (responsáveis de demandas, aditiva+backfill) aplicada. Verificação pós-deploy: container na revision `dba75ff8`, healthy, **log sem nenhum `getFromImportMap`**, `/admin` renderizando o formulário de login (2 inputs email/senha + botão Login, título "Login - Payload", 0 erros de console), e a home pública mantendo "Nossa história" (S8) e o form de novidades (S9). Evidência: `docs/plans/ops73-deploy-admin-prod-fixed-evidence.png`. Lições registradas: a regeneração do importMap sem envs `S3_*` re-órfão o handler (classe exata que o guard #87 deve impedir no CI); divergência de plataformas pós-cutover — workspace do homeserver ainda apontava para o Forgejo local (`localhost:3000`) e o S10 (pixel) segue pendente de re-home no GitHub.

View file

@ -1,71 +0,0 @@
# Runbook — Feed do Instagram: validar/gerar o token (S11)
Issue: #115 · Entrega: S11 (painel de status da sincronização no admin)
## O que o painel mostra
Na global **Configurações → Feed de redes sociais**, o bloco "Instagram — estado
da sincronização" diz o estado da última tentativa:
| Estado | Significado | Ação |
| --------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **Sincronizado · há X min · N posts** | Credenciais aceitas; posts no board "Acompanhe de perto" | Nenhuma |
| **Falha na última sincronização** | A Graph API recusou a última tentativa; o texto diz o motivo e a correção | Corrigir conforme o motivo e clicar **Tentar sincronizar de novo** |
| **Instagram ainda não configurado** | Falta token ou ID | Preencher os dois campos |
| **Aguardando a primeira sincronização** | Configurado, nenhuma tentativa ainda | Clicar **Tentar sincronizar de novo** |
O estado é atualizado no salvar (quando as credenciais mudam), em cada render da
home (cache de 5 min) e no clique do botão. A home pública nunca quebra quando a
API falha — o board segue com artigos + YouTube.
## Diagnóstico da ocorrência de 2026-08-19 (causa provável)
Produção mostrava picker vazio e nenhum card IG com token + ID preenchidos — o
fail-closed silencioso da S3 não dizia o porquê. A causa mais provável, na ordem:
1. **Token emitido via Facebook Login** (page token) — a `graph.instagram.com`
recusa; o refresh automático não renova (o endpoint `refresh_access_token`
só aceita tokens do Instagram Login).
2. **Token expirado/revogado**.
3. **ID do usuário errado** (não é o da conta Business/Creator vinculada).
**Confirmação em 1 clique:** no admin, **Tentar sincronizar de novo** — o painel
mostra o motivo exato devolvido pela API. Se a causa for a 1, aparece a mensagem
de token recusado com a correção; se for a 3, a mensagem de ID não reconhecido.
## Validar um token sem o admin
```bash
# O endpoint /me responde com o username e o tipo de conta se o token é válido
# para a Graph API do Instagram (Business/Creator):
curl -s "https://graph.instagram.com/me?fields=user_id,username&access_token=SEU_TOKEN"
# Teste o refresh (só tokens emitidos pelo Instagram Login passam):
curl -s "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=SEU_TOKEN"
```
- `/me` com `OAuthException` → token inválido/expirado ou emitido via
**Facebook Login** (a Graph API do Instagram só aceita tokens do Instagram
Login).
- `/me` ok mas o ID configurado não bate com `user_id` → corrigir o campo
"ID do usuário".
## Gerar um token de longa duração (Instagram Login)
1. Acessar `developers.facebook.com` com a conta que administra a página →
criar/usar um app do tipo **Business** (qualquer tipo serve para o fluxo de
negócios, mas o app precisa estar em modo Live para uso fora do sandbox).
2. Produto **Instagram** → **Instagram Login with Basic Display** → **Gerar
token** com a conta Business/Creator do @depjorgesolla.
3. Trocar o token de curta duração pelo de longa duração usando o
**Long-Lived Token Exchange** da documentação do Instagram Basic Display
(ou usar o endpoint `refresh_access_token` do passo anterior após a troca).
4. Colar o token e o ID numérico da conta no admin e clicar
**Tentar sincronizar de novo**.
## O que NÃO fazer
- Não usar token da API Graph do Facebook (página) — o app recusa.
- Não colar o token em arquivos, Issues ou chat — o campo é admin-only e deve
continuar.
- Não zerar o token para "testar" — o painel de status dispensa tentativa-e-erro.

View file

@ -1,100 +1,44 @@
# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 — dispatch manual)
# Runbook: deploy do site 1313 no homeserver (OPS53)
O deploy de produção é uma **action manual** (`workflow_dispatch` no GitHub
Actions, `.github/workflows/deploy.yml`): mergir em `main` **não** publica
nada. O operador dispara o deploy quando decide publicar; antes de tocar o
homeserver, o job `verify` (hosted) roda a suíte **full** (incl. e2e full).
O deploy de produção é uma etapa do CI (`ci.yml` no Forgejo): mergir em `main`
com a suite verde publica o site em `jorgesolla1313.com.br` — sem passo manual.
## Gatilho e fluxo
1. GitHub → Actions → **Deploy (manual)** → Run workflow (ref: `main`).
2. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
check-test-locations → lint → format → typecheck → knip → cycles → unit →
int (migrate+seed nos services) → build → e2e full.
3. Se `verify` verde, o job `deploy` (`needs: [verify]`,
`runs-on: [self-hosted, homeserver]`) roda no **runner do GitHub instalado
no homeserver** e executa `scripts/deploy-homeserver.sh <sha>` **localmente**
(sem SSH; o runner conecta outbound ao GitHub — funciona atrás do
Cloudflare tunnel; o hosted nunca toca o homeserver; o self-hosted não
conta minutos hosted).
4. O script, no **homeserver**: guarda de HEAD (`git ls-remote` de
`TEQO_REPO_URL` — default `https://github.com/fsolla/teqo.git`, público —
== SHA do job, senão "stale run" skip) → `flock` (serializa) → guard
"already deployed" (revision do container rodando) → **build do migrator**
(o estágio migrator não roda `next build` — builda mesmo contra o schema
antigo; BuildKit, secrets do `~/stack/teqo-1313.env`) → push/tag do
migrator em `localhost:5000` → swap dos tags de imagem no
1. `git push` para `main` (via PR merge) — ou `workflow_dispatch` no `ci.yml`
para re-deployar a HEAD de `main` manualmente.
2. Suite completa verde (static/int/build/e2e/checks).
3. Job `deploy` (runs-on `host`, na **workstation**) roda
`ssh homeserver "bash -s -- <sha>" < scripts/deploy-homeserver.sh`.
4. O script, no **homeserver**: guarda de HEAD (`main` remoto == SHA do job,
senão skip) → `flock` (serializa) → build da imagem standalone + migrator
(BuildKit, secrets do `~/stack/teqo-1313.env`, `--network host` com proxy
socat `teqo-1313-build-proxy` na `stack_default` para o build alcançar o
`postgres` — o proxy é criado idempotentemente pelo script) →
push em `localhost:5000` → swap dos tags de imagem no
`~/stack/docker-compose.yml` (backup antes) → **migrations**
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`,
já com a imagem do SHA novo) → **build do runner** (contra o banco JÁ
migrado — o `next build` da geração estática lê o schema novo, OPS66;
`--network host` com proxy socat `teqo-1313-build-proxy` na `stack_default`
para alcançar o `postgres`; o proxy é criado idempotentemente pelo script)
→ push/tag do runner → `docker compose up -d teqo-1313` → healthcheck →
smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn
login-options, `api/revalidate` com o secret real).
5. Falha = job vermelho; nada é publicado pela metade (rollback automático
após o swap).
**Primeiro deploy (verificação ao vivo):** depois do cutover OPS71, dispare o
deploy manual e confira no log do job `deploy` o `set-url` do workspace
(origem Forgejo → GitHub), o `deployed_sha` lido do container e o guard
"already deployed" num segundo dispatch do mesmo SHA.
(`docker compose --profile maintenance run --rm teqo-1313-migrate </dev/null`) →
`docker compose up -d teqo-1313` → healthcheck → smoke (`/`,
`/campanha/login`, `/admin`, barreira 307, WebAuthn login-options,
`api/revalidate` com o secret real).
5. Falha = job vermelho no commit; nada é publicado pela metade.
## Onde roda cada coisa
| Máquina | Papel |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público) |
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
| Máquina | Papel |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **workstation** (16c/61GB) | runner Forgejo (`workstation-runner`); job `deploy` roda no host (chave SSH `~/.ssh/id_ed25519` → `ssh homeserver`) |
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313; workspace `~/teqo-deploy` (clone do Forgejo local) |
Segredos **não** ficam no GitHub: o script sourceia `~/stack/teqo-1313.env` e
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
clone do workspace não precisa de credencial. No GitHub ficam apenas os
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
`CURSOR_API_KEY` (archive helper, dormente).
Segredos **não** ficam no Forgejo: o script sourceia `~/stack/teqo-1313.env` e
`~/stack/.env` (chmod 600) no próprio homeserver. Nenhum secret novo em
Settings → Actions.
## Cutover OPS71 (passos manuais, ordem)
## Rollback
1. Remotes locais (config compartilhada pelos worktrees):
`git remote set-url origin git@github.com:fsolla/teqo.git` e
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
main; o tracker (Issues) segue vivo por API.
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
General, defina fork PR workflows como não executar (ou exigir aprovação).
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
sem required check).
5. **Desligar o runner do Forgejo** na workstation (ex.:
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
validado; para o schedule do `ci.yml` antigo.
6. Instalar o runner self-hosted do GitHub no homeserver (labels
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
registrar em infra-solla: `STATE.md`).
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
deploy manual de validação.
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
abrir PRs no Forgejo a partir do main congelado — os workflows de
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
## Rollback (do deploy)
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
A imagem anterior continua local e no registry (`localhost:5000/teqo-1313:<sha>`
— o registry nunca deleta). O compose anterior fica em
`~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`.
```bash
ssh homeserver
@ -103,7 +47,7 @@ cd ~/stack
# o backup do deploy que falhou é o compose com os tags antigos
ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim
cp docker-compose.yml.pre-<sha-anterior> docker-compose.yml
# 2. puxar do registry (a imagem antiga já não fica local após a limpeza):
# 2. a imagem antiga ainda está local; se não, puxe do registry:
docker pull localhost:5000/teqo-1313:<sha-anterior>
docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
# 3. subir:
@ -113,44 +57,32 @@ docker compose up -d teqo-1313
**Caveat:** uma migration de schema aplicada no passo de migrate **não** é
desfeita pelo rollback (Payload é append-only). Código velho sobre schema novo
pode se comportar mal — o caminho primário minimiza essa janela (migrate só
roda após migrator build+push+swap ok; rollout só após runner build ok). Desde
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
novo): um build do runner que falhe depois do migrate deixa o banco à frente
do código — migrations são append-only e revisadas antes do deploy (checklist);
a correção se re-mergeia e o próximo deploy completo publica.
roda após build+push ok; rollout só após migrate ok).
## Falhas conhecidas
| Sintoma | Causa | Tratamento |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Job verde sem deploy ("stale run") | Push mais novo venceu; o dispatch apontava um SHA que não é mais o HEAD do main do GitHub | Nada a fazer — dispatch novo no main atual |
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: `workflow_dispatch` duplicado da mesma HEAD) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
| Job `deploy` nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só o deploy |
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
| Sintoma | Causa | Tratamento |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Job verde sem deploy ("stale run") | Push mais novo venceu; o deploy antigo saiu da fila | Nada a fazer — o run do SHA novo deploya |
| Todo job docker quebra com `exec: "node": executable file not found in $PATH` | `runner.envs.PATH` do `~/.forgejo-runner/config.yaml` (workstation) com paths só de host (nvm/bun) — o container perde o node do toolcache (`/opt/acttoolcache`); edição do config exige o toolcache primeiro (ver comentário no próprio config) | Corrigir o PATH no config (toolcache primeiro — ver comentário no próprio arquivo) + `systemctl --user restart forgejo-runner` (incidente 2026-08-17) |
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script que vai no pipe do `bash -s`; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat `teqo-1313-build-proxy` (na `stack_default`, publicado em 127.0.0.1:5433, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| `checkout@v5` falha no job `host` | Label host do act_runner | Fallback: baixar o script direto do Forgejo (`curl -s https://git.solla.dev/fsolla/teqo/raw/branch/main/scripts/deploy-homeserver.sh`) |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
## Segurança (decisão deliberada)
- O job `deploy` usa `runs-on: [self-hosted, homeserver]` — executa como o
usuário do runner no homeserver. Está **apenas** no `deploy.yml`
(dispatch manual), nunca no `ci-pr.yml`; o hosted (`verify`) não tem acesso
ao homeserver e o self-hosted não recebe secrets de produção (as envs vivem
no próprio homeserver). `main` só anda por PR mergeado com CI verde; o
deploy só roda com `verify` full verde. Fork-PRs não rodam CI (same-repo
gate + setting do repo).
- O job `deploy` usa `runs-on: host` — executa como `fsolla` na workstation.
Está **apenas** no `ci.yml` (push em `main` + dispatch), nunca no `ci-pr.yml`.
`main` só anda por PR mergeado com suite verde. Fork-PRs não disparam
workflows no Forgejo (default off — verificado).
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
- `.github/workflows/deploy.yml` — verify → deploy
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
- `scripts/deploy-homeserver.sh` — o script remoto (fonte da verdade do fluxo)
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"

View file

@ -1,77 +0,0 @@
# Impl: Captura de novidades da campanha na home pública
Status: rascunho
Atualizado em: 2026-08-19
Issue: #93
Intenção: docs/plans/captura-novidades-home-campanha.md
Appetite restante: herdado (~1–1,5 dia eng)
## Leitura da intenção
- **Outcome:** visitante da home deixa nome + WhatsApp (obrigatórios; e-mail, estado, cidade e comentário opcionais) numa seção da própria home, escolhe o nível de engajamento num toggle pré-selecionado ("Quero fazer parte do time"), recebe confirmação in-place; o registro vira Contact + assinatura com consentimento novo fail-closed, e a escolha do toggle fica gravada e visível no admin.
- **O que NÃO negociar:** nada de `supporter` (bloqueio jurídico Onda 0); consentimento NOVO (não `whatsapp-inscricao`), resolvido por chave estável e fail-closed (sem o texto configurado no admin, o form recusa a captura); sem mescla com `/mandato-no-whatsapp`; CTA secundário do hero vira o atalho da captura **sem esconder as bandeiras** (`#bandeiras` continua acessível — link do rodapé já existe); 6 campos do rascunho, nada mais.
- **O que reavaliar:** a hipótese dizia que os campos `NameInput`/`PhoneInput`/`EmailInput`/`StateSelect`/`CitySelect` "já existem e são reutilizáveis" — verdade, mas **`Contact.state` é `NOT NULL` no banco** (migration inicial) e **`StateSelect`/`CitySelect` têm `required` fixo** (usados só pelo `WhatsappForm` hoje). Estado opcional no form exige: `required: false` na collection + migration `DROP NOT NULL`, e um prop `required` nos dois selects (editar o dono, sem gêmeos). A forma do consentimento (um texto vs. dois) fica decidida aqui — ver abordagem.
## Abordagem recomendada
```mermaid
flowchart LR
H[CampaignHero<br/>CTA secundário → #novidades] --> S[CampaignNewsletterSection<br/>id=novidades, após o hero]
S --> F[CampaignNewsletterForm<br/>cliente, react-hook-form+zod]
F -->|submit| A[submitCampaignNewsletter]
A --> C[requireConsentByKey<br/>chave campanha-novidades · fail-closed]
C --> T[Transação: Contact + Subscription]
T -->|ok| X[Sucesso in-place<br/>Ver bandeiras → #bandeiras]
A -->|consent ausente| E[Recusa: nenhum dado gravado]
```
**Opções consideradas:** A (dois consents, escolha = relação de consent) · B (**um consent + campo `campaignLevel` na assinatura**) · C (collection nova de captura)
**Recomendação:** **B** — porque a escolha do toggle sobrevive como **dado explícito** (`subscription.campaignLevel`, coluna própria no admin), o jurídico aprova **um texto** cobrindo os dois níveis (o rabbit-hole "um consentimento só para tudo" da intenção é cortado justamente quando a escolha **sobrevive no dado** — aqui sobrevive), e o padrão repete o precedente `whatsapp-inscricao` (uma chave por fluxo, `requireConsentByKey` fail-closed). O texto em si continua pendente de aprovação jurídica antes da ativação — nenhum dado é capturado sem o(s) documento(s) no admin.
**Rejeitadas:**
- **A (dois consents, `campanha-novidades-time`/`-esporadico`):** juridicamente o dobro de textos a aprovar; a escolha fica implícita numa relação que o admin teria que interpretar pelo label do documento; e se o jurídico aprovar um texto único, a duplicação em dois docs vira truque. Zero schema change é a única vantagem — e B custa só uma coluna nullable.
- **C (collection `campaignNewsletterSignup` nova):** twin do modelo de assinatura; a intenção diz explicitamente "o vínculo reutiliza o modelo de assinatura existente".
### Componentes / mudanças
- **`campaignConsentKeys.ts`** (`src/lib/`): `CAMPAIGN_NEWSLETTER_CONSENT_KEY = 'campanha-novidades'` + `CAMPAIGN_NEWSLETTER_CONSENT_MISSING_MESSAGE` ("Consentimento de novidades da campanha ainda não configurado.").
- **`campaignConsent.ts`** (`src/utilities/`, edit owner): `requireCampaignNewsletterConsent` no padrão das irmãs.
- **`Subscription`** (`src/collections/`): campo novo `campaignLevel` — select `time|esporadico` (labels "Fazer parte do time" / "Comunicações esporádicas", admin label "Nível de engajamento"), **nullable na collection** (linhas do WhatsApp não tocam) e `admin.listColumns` para a coluna aparecer na lista.
- **`Contact`** (`src/collections/`): `state` → `required: false` (a ficha staff via zod `contactCreateSchema` continua exigindo — precedente `phones`: collection permissiva, fluxos com regra própria).
- **Migration** `pnpm migrate:create` (uma: `ALTER TABLE "contact" ALTER COLUMN "state" DROP NOT NULL` + `CREATE TYPE enum_subscription_campaign_level` + `ALTER TABLE "subscription" ADD COLUMN "campaign_level"`); rebase antes de criar; inspecionar o SQL gerado.
- **`schemas/contact.ts`** (edit owner): exportar `contactNameSchema` (hoje const não exportada).
- **`schemas/campaign-newsletter.ts`** (novo, `src/lib/schemas/`): `campaignNewsletterSchema` — `name` (`contactNameSchema`, obrigatório), `phone` (`brazilianMobile`, obrigatório, normaliza p/ 11 dígitos), `email` (`optionalPersistedEmail`), `state` (opcional, `''`→undefined), `city` (opcional, min 3 quando presente), `comment` (`commentSchema.shape.comment`), `campaignLevel` (`z.enum(['time','esporadico']).default('time')` — o pré-selecionado do toggle).
- **`actions/submitCampaignNewsletter.ts`** (novo, `src/app/(frontend)/actions/`): padrão `submitWhatsapp` — parse zod → `requireConsentByKey` fail-closed → transação (`payload.db.beginTransaction`, `req: { transactionID }`): `contact` (name, email?, `phones: [{ value: phone }]`, state?, city?) + `subscription` (`contact`, `consent`, `campaignLevel`, `comment`) → commit; rollback no erro.
- **`StateSelect` / `CitySelect`** (`src/components/`, edit owners): prop `required?: boolean` (default `true`) — o form novo passa `required={false}`; nenhum chamador existente muda.
- **`CampaignHero`** (`src/components/`): CTA secundário `href="#novidades"`, texto **duas linhas** "Receba / novidades" (mesma contagem de caracteres de "Conhecer / bandeiras" — preserva as 6 medições pinadas do teste de primeira dobra, ±2px), `aria-label="Receba novidades da campanha"`. **Scroll animado** (decisão de produto 2026-08-19): a âncora depende do `scroll-behavior: smooth` já existente no container `[data-theme="campaign-site"]` (e2e já pina `smooth`) — verificar se a âncora do CTA do hero participa desse container e manter suave; sem smooth no trecho, adicionar no CSS da home (nunca scroll instantâneo).
- **`CampaignNewsletterSection`** (novo, `src/app/(frontend)/(home)/`): seção server **logo acima do rodapé** (decisão de produto 2026-08-19: depois da seção de bandeiras, antes do `CampaignFooter`), `id="novidades"`, `data-home-section="newsletter"`, banda `--campaign-band` + `border-y` (mesmo padrão da seção contents), eyebrow/título/copy no vocabulário `campaign-section-*`; compõe o form cliente.
- **`CampaignNewsletterForm`** (novo, `src/components/`): cliente, react-hook-form + zodResolver; `NameInput` + `PhoneInput` (obrigatórios), `EmailInput` + `StateSelect`/`CitySelect` (opcionais, placeholders "(opcional)"), `Textarea` comentário, **Checkbox toggle pré-marcado** ("Quero fazer parte do time: receber novidades com frequência, ser adicionado(a) aos grupos de WhatsApp da campanha e participar das ações" + helper "Sem marcar, você recebe apenas comunicações esporádicas…"), botão "QUERO RECEBER NOVIDADES", nota LGPD de rodapé; no sucesso → estado in-place ("Inscrição confirmada" + copy **sem "abaixo"** — as bandeiras ficam acima na home — + botão "Ver bandeiras" → `#bandeiras`). O swap form→sucesso fica no componente cliente — costura pronta para o S10 (pixel) disparar `Lead` aí.
- **UI:** Impeccable B — encaixe fiel ao rascunho aprovado; shape → craft → critique → polish.
### Dados → forma (não se aplica — a captura é o dado; consumo = leitura do admin existente)
## Fases verificáveis
1. **Tracer / schema+server** — migration + collections + keys + schema + action + int tests; `pnpm migrate` local; `pnpm exec tsc --noEmit`.
2. **UI** — selects opcionais, hero CTA, seção + form + sucesso; e2e: atualizar o teste "animates the flags navigation" (CTA secundário → `#novidades`) e medir/re-pinar o teste de primeira dobra se o ±2px falhar; novo e2e do form (cria o Consent via REST admin, submete, assere "Inscrição confirmada", limpa consent+subscription — chave própria, paralelo-safe).
3. **Gates** — `pnpm gate:fast`; `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`; changelog `docs/changelog/2026-08-19-s9.md` + `pnpm changelog:build`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Não mexer em `contactCreateSchema` (ficha staff segue exigindo estado — regra do fluxo, não da collection).
- Não adicionar footer link "Receba novidades" (intenção pede só CTA do hero + seção).
- Não indexar `campaign_level` (sem filtro de admin planejado).
- Não enviar e-mail / double opt-in (gatilho futuro se marketing pedir — fora da intenção).
## Riscos e mitigação
- **Pins de geometria do hero** (`frontend.e2e.spec.ts` — `secondaryAction` x/y/largura ±2px em 6 viewports): texto com mesma contagem de caracteres ("Receba/novidades" ≈ "Conhecer/bandeiras"); se falhar, re-pinar com valores medidos (a mudança é intencional; o teste preserva a detecção de drift).
- **Consent ausente em prod** (bloqueio jurídico pendente): comportamento fail-closed é o aceite — o form recusa a captura com mensagem clara; nenhum dado gravado. Nada no código depende do texto aprovado.
- **Estado opcional vs. admin**: contactos sem `state` agora válidos — a ficha staff continua exigindo (zod), listas do admin existentes não filtram por estado obrigatório.
- **Concorrência de migration**: rebase em `main` antes de `migrate:create`; a cadeia é validada por `pnpm migrate` + `test:int` no PR.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (6 campos, toggle pré-marcado gravado, CTA do hero, consent novo fail-closed, bandeiras acessíveis)
- [x] Invariantes AGENTS/engineering-standards (transação multi-collection com `req.transactionID`; Consent por chave estável; join com `Contact`; copy pt-BR/identifiers EN; edit owner, sem gêmeos)
- [ ] Testes de domínio previstos: int `submitCampaignNewsletter.int.spec.ts` (fail-closed + gravação dos dois níveis + state omitido) e e2e do form → sucesso

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,237 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>UI draft — Captura de novidades na home de campanha</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-5xl p-8">
<p class="text-sm text-zinc-400">
Visitante da home de campanha demonstra apoio e deixa o contato para receber comunicações
(e-mail ou grupos de WhatsApp) — CTA do hero leva à seção de captura na própria página.
</p>
<section data-shot="desktop" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<div class="bg-zinc-900 px-10 pt-12 pb-8">
<div class="flex gap-8">
<div class="flex-1">
<div class="h-3 w-24 rounded bg-zinc-700"></div>
<div class="mt-4 h-12 w-72 rounded bg-zinc-700"></div>
<div class="mt-2 h-12 w-64 rounded bg-zinc-700"></div>
<div class="mt-5 space-y-2">
<div class="h-2 w-96 rounded bg-zinc-700"></div>
<div class="h-2 w-80 rounded bg-zinc-700"></div>
<div class="h-2 w-88 rounded bg-zinc-700"></div>
</div>
<div class="mt-7 flex gap-3">
<div
class="inline-flex h-11 items-center justify-center rounded-[10px] bg-red-600 px-5 text-sm font-extrabold text-white"
>
Quero apoiar
</div>
<div
class="inline-flex h-11 items-center justify-center rounded-[10px] border-2 border-zinc-300 px-5 text-sm font-extrabold text-zinc-100"
>
Receba novidades
</div>
</div>
<div class="mt-8 flex gap-8">
<div class="h-2 w-40 rounded bg-zinc-700"></div>
<div class="h-2 w-40 rounded bg-zinc-700"></div>
</div>
</div>
<div class="hidden w-64 shrink-0 lg:block">
<div class="h-72 w-48 rounded-lg bg-zinc-700/60"></div>
</div>
</div>
</div>
<div class="bg-zinc-100 px-10 py-12 text-zinc-900">
<div class="mx-auto max-w-2xl text-center">
<div class="mx-auto h-2 w-28 rounded bg-zinc-400"></div>
<div class="mx-auto mt-4 h-8 w-96 rounded bg-zinc-400"></div>
<div class="mx-auto mt-3 h-2 w-80 rounded bg-zinc-400"></div>
<div class="mx-auto mt-3 h-2 w-72 rounded bg-zinc-400"></div>
</div>
<div class="mx-auto mt-8 max-w-3xl space-y-4">
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Nome completo*
</div>
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
WhatsApp (com DDD)*
</div>
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
E-mail (opcional)
</div>
<div class="grid grid-cols-2 gap-4">
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Estado (opcional)
</div>
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Cidade (opcional)
</div>
</div>
<div
class="h-20 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Comentário (opcional)
</div>
<div class="rounded-lg border border-zinc-300 bg-white p-4">
<div class="flex items-start gap-3">
<div
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-red-600 bg-red-600 text-xs font-bold text-white"
>
✓
</div>
<div class="text-sm text-zinc-800">
<strong>Quero fazer parte do time:</strong> receber novidades com frequência, ser
adicionado(a) aos grupos de WhatsApp da campanha e participar das ações.
</div>
</div>
<p class="mt-2 text-xs text-zinc-500">
Sem marcar, você recebe apenas comunicações esporádicas da campanha — sem grupos e
sem envios frequentes.
</p>
</div>
</div>
<div class="mx-auto mt-4 max-w-3xl">
<div
class="inline-flex h-12 w-full items-center justify-center rounded-lg bg-red-600 text-sm font-extrabold text-white"
>
QUERO RECEBER NOVIDADES
</div>
</div>
<p class="mx-auto mt-4 max-w-xl text-center text-xs text-zinc-500">
Ao enviar, você demonstra apoio à campanha e escolhe como quer acompanhar: comunicações
frequentes e grupos de WhatsApp (marcando a opção acima) ou comunicações esporádicas —
conforme a Política de Privacidade (LGPD).
</p>
</div>
</section>
<section
data-shot="mobile"
class="mt-6 w-[390px] overflow-hidden rounded-xl border border-zinc-800"
>
<div class="bg-zinc-900 px-5 pt-8 pb-6">
<div class="h-3 w-20 rounded bg-zinc-700"></div>
<div class="mt-4 h-9 w-56 rounded bg-zinc-700"></div>
<div class="mt-2 h-9 w-48 rounded bg-zinc-700"></div>
<div class="mt-4 space-y-2">
<div class="h-2 w-72 rounded bg-zinc-700"></div>
<div class="h-2 w-60 rounded bg-zinc-700"></div>
</div>
<div class="mt-6 flex flex-col gap-2">
<div
class="inline-flex h-11 items-center justify-center rounded-[10px] bg-red-600 px-5 text-sm font-extrabold text-white"
>
Quero apoiar
</div>
<div
class="inline-flex h-11 items-center justify-center rounded-[10px] border-2 border-zinc-300 px-5 text-sm font-extrabold text-zinc-100"
>
Receba novidades
</div>
</div>
</div>
<div class="bg-zinc-100 px-5 py-8 text-zinc-900">
<div class="mx-auto h-2 w-24 rounded bg-zinc-400"></div>
<div class="mx-auto mt-4 h-7 w-72 rounded bg-zinc-400"></div>
<div class="mx-auto mt-3 h-2 w-64 rounded bg-zinc-400"></div>
<div class="mt-6 space-y-3">
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Nome completo*
</div>
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
WhatsApp (com DDD)*
</div>
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
E-mail (opcional)
</div>
<div class="grid grid-cols-2 gap-3">
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Estado (opcional)
</div>
<div
class="h-12 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Cidade (opcional)
</div>
</div>
</div>
<div class="mt-3">
<div
class="h-20 rounded-lg border border-zinc-300 bg-white px-4 py-3 text-sm text-zinc-500"
>
Comentário (opcional)
</div>
</div>
<div class="mt-3 rounded-lg border border-zinc-300 bg-white p-4">
<div class="flex items-start gap-3">
<div
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-red-600 bg-red-600 text-xs font-bold text-white"
>
✓
</div>
<div class="text-sm text-zinc-800">
<strong>Quero fazer parte do time:</strong> receber novidades com frequência, ser
adicionado(a) aos grupos de WhatsApp da campanha e participar das ações.
</div>
</div>
<p class="mt-2 text-xs text-zinc-500">
Sem marcar, você recebe apenas comunicações esporádicas da campanha — sem grupos e sem
envios frequentes.
</p>
</div>
<div class="mt-4">
<div
class="inline-flex h-12 w-full items-center justify-center rounded-lg bg-red-600 text-sm font-extrabold text-white"
>
QUERO RECEBER NOVIDADES
</div>
</div>
<p class="mx-auto mt-4 max-w-xs text-center text-xs text-zinc-500">
Ao enviar, você demonstra apoio à campanha e escolhe como quer acompanhar: comunicações
frequentes e grupos de WhatsApp (marcando a opção acima) ou comunicações esporádicas —
conforme a Política de Privacidade (LGPD).
</p>
</div>
</section>
<section data-shot="sucesso" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<div class="bg-zinc-100 px-10 py-16 text-center text-zinc-900">
<div class="mx-auto h-10 w-10 rounded-full border-2 border-zinc-400"></div>
<div class="mx-auto mt-5 h-7 w-80 rounded bg-zinc-400"></div>
<p class="mx-auto mt-4 max-w-md text-sm text-zinc-600">
Você vai receber as novidades da campanha. Enquanto isso, acompanhe as bandeiras da
campanha abaixo.
</p>
<div
class="mx-auto mt-6 h-11 w-48 rounded-lg border-2 border-zinc-300 bg-white text-sm font-extrabold text-zinc-700"
></div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,90 +0,0 @@
# Captura de novidades da campanha na home pública
Status: registrado
Atualizado em: 2026-08-19
Issue: #93
Priority: P1
Model: composer-2.5
Impeccable: B — encaixe na home de campanha (CTA do hero + nova seção de captura)
Rascunho UI: docs/plans/captura-novidades-home-campanha-ui-draft.html + PNGs embutidos abaixo
Appetite: ~1–1,5 dia eng; seção de captura na home + CTA no hero + ação transacional com consentimento novo fail-closed
Responsável: —
## Intenção
O site público de campanha (home `jorgesolla1313.com.br`) hoje converte visitante em **doação** (CTA "Quero apoiar") e em **comunidade WhatsApp** (`/mandato-no-whatsapp`), mas não captura o visitante que demonstra apoio e quer acompanhar a campanha sem necessariamente doar. Sem esse degrau, o tráfego orgânico e pago da home sai sem deixar contato, e o time não tem lista própria de interesse para comunicações (novidades, agenda, ações) e para grupos de WhatsApp da campanha. A campanha está em janela eleitoral (propaganda iniciada 16/08, 1º turno 04/10) — cada visitante não capturado é audiência perdida para as semanas decisivas.
A captura tem dupla função, confirmada com produto: o contato é **um sinal de apoio** a Solla (a pessoa se dispõe a receber comunicações da campanha) **e** a porta de entrada para comunicações por e-mail **ou inclusão em grupos de WhatsApp da campanha** — por isso o telefone é campo obrigatório.
## Persona e fluxo
- **Persona / contexto:** visitante da home de campanha (eleitor simpatizante ou indeciso que quer acompanhar), navegando no celular ou no desktop; demonstra apoio deixando o contato para receber comunicações.
- **Job principal:** deixar o contato em segundos (nome + WhatsApp) para receber novidades da campanha, sem sair da página, escolhendo o nível de engajamento.
- **Fluxo desejado:**
1. Chega na home e vê o CTA secundário do hero apontando para a captura (substitui "Conhecer bandeiras").
2. Preenche nome + WhatsApp (obrigatórios) e, opcionalmente, e-mail, estado, cidade e comentário, numa seção da própria home.
3. Decide o nível de engajamento num toggle **pré-selecionado** "Quero fazer parte do time": marcado = comunicações frequentes + inclusão em grupos de WhatsApp da campanha + participação nas ações; desmarcado = apenas comunicações esporádicas, sem grupos e sem envios frequentes.
4. Envia; a seção vira confirmação in-place ("Inscrição confirmada").
5. O registro vira um contato com consentimento próprio **e a escolha do toggle gravada** (nível "time" vs "esporádico"), visível no admin junto das demais listas públicas.
- **Anti-goals de produto:** segundo cadastro de pessoa paralelo a `Contact`; gravar `supporter` (registro nominal interno da campanha — bloqueado pelo lote jurídico da Onda 0; este fluxo é captura pública com consentimento próprio, não o registro de apoiador interno); virar mini-CRM com segmentação/fase do funil; mesclar com o fluxo `/mandato-no-whatsapp` (comunidade do mandato, consentimento `whatsapp-inscricao` — canal e semântica diferentes).
### Rascunho UI (B)
![Rascunho UI — desktop](captura-novidades-home-campanha-ui-draft-desktop.png)
![Rascunho UI — mobile](captura-novidades-home-campanha-ui-draft-mobile.png)
![Rascunho UI — sucesso](captura-novidades-home-campanha-ui-draft-sucesso.png)
Fonte iterável: `docs/plans/captura-novidades-home-campanha-ui-draft.html`.
## Objetivo e aceite
- Visitante da home consegue demonstrar apoio e deixar **nome + WhatsApp** (obrigatórios) para receber comunicações da campanha, com e-mail, estado, cidade e comentário opcionais, e confirmação imediata na própria página.
- **Toggle "Quero fazer parte do time" pré-selecionado**: marcado = comunicações frequentes + inclusão em grupos de WhatsApp da campanha; desmarcado = apenas comunicações esporádicas (sem grupos, sem envios frequentes). A **escolha fica gravada no registro** — o admin distingue quem é "time" de quem é "esporádico".
- O CTA secundário do hero ("Conhecer bandeiras") vira o atalho para a seção de captura — as bandeiras continuam acessíveis na navegação da própria home (âncora `#bandeiras` já existe; o fluxo de captura não pode escondê-las).
- O registro é gravado como contato + vínculo de assinatura com **consentimento novo** (não reutiliza `whatsapp-inscricao`), no padrão fail-closed dos fluxos públicos existentes: sem o(s) texto(s) de consentimento aprovado(s) configurado(s) no admin, o form recusa a captura (nenhum dado é gravado).
- O(s) texto(s) do consentimento cobre(m) os dois níveis — "fazer parte do time" (comunicações frequentes + grupos de WhatsApp) e "esporádico" — aprovado(s) pelo jurídico antes da ativação.
- Guardrail LGPD: nenhuma captura antes do(s) documento(s) de consentimento (chave estável nova) existir(em) no admin — o executor resolve por chave, não por ID; a estrutura exata do consentimento (um texto com dois níveis vs. dois textos) é decisão de implementação junto do jurídico.
## Dados (intenção)
- **Vou apresentar dados?** Não — a captura é o dado; o consumo (quantidade de inscritos, crescimento) é leitura do admin existente, sem superfície nova.
## Direção no codebase (hipótese)
- **Áreas prováveis:** home `src/app/(frontend)/(home)/page.tsx` (nova seção + composição), hero `src/components/CampaignHero.tsx` (troca do CTA secundário), componente de formulário cliente em `src/components/` no padrão `WhatsappForm` (campos `NameInput`/`PhoneInput`/`EmailInput`/`StateSelect`/`CitySelect` já existem e são reutilizáveis), action em `src/app/(frontend)/actions/` no padrão `submitWhatsapp` (transação `Contact` + `Subscription` com consent por chave estável, `requireConsentByKey` fail-closed), chave nova em `src/lib/campaignConsentKeys.ts`.
- **Precedente a olhar:** `src/app/(frontend)/actions/submitWhatsapp.ts` (transação + consent), `src/components/WhatsappForm.tsx` (form e inputs), `src/lib/schemas/whatsapp-form.ts` (schema base `contactSchema` — obrigatoriedade difere: aqui nome + telefone), `/mandato-no-whatsapp` (página irmã — **não** mesclada nesta fatia).
- **Risco de acoplamento:** a home é superfície compartilhada do trilho S (últimos itens S1–S6) — toca o mesmo arquivo que o item S8 (pixel) tocará; serializado por dependência.
## Dependências
- Nenhuma dura. **S10** (pixel da home) depende deste item para disparar `Lead` no sucesso do form.
## Fora de escopo
- Mescla com `/mandato-no-whatsapp` ou reuso do consentimento `whatsapp-inscricao`.
- Gravação em `supporter` (registro nominal interno da campanha) — permanece bloqueado pelo lote jurídico da Onda 0.
- Segmentação, tags de interesse, double opt-in por e-mail, integração com ferramenta de e-mail marketing — anotar como gatilho futuro se marketing pedir.
- Redesign do hero ou da home (mudança de um CTA + uma seção).
## Rabbit holes de produto
- **Mini-CRM.** Se alguém "só completar": campos extras, segmentação, fases de funil. **Corte neste item:** os 6 campos do rascunho (nome + WhatsApp obrigatórios; e-mail, estado, cidade, comentário opcionais) e nada mais; o vínculo reutiliza o modelo de assinatura existente.
- **"Já que o telefone é obrigatório, vira apoiador do sistema interno".** Puxa o registro nominal bloqueado juridicamente. **Corte:** o registro vive nas listas públicas de contato/assinatura com consentimento próprio; `supporter` só quando o lote jurídico liberar.
- **"Um consentimento só para tudo" ou "dois consents mas um só registro".** A escolha do toggle tem que sobreviver no dado, senão o time não sabe quem pode entrar em grupo. **Corte:** a escolha fica gravada e visível no admin; a forma (um texto com os dois níveis vs. dois textos) é decisão de implementação com o jurídico.
- **Form igual ao do WhatsApp.** Copiar a semântica do "comunidade do mandato" arrasta canal e consentimento errados. **Corte:** consentimento novo; fluxos permanecem irmãos, não gêmeos.
- **Double opt-in / verificação de e-mail.** Vira infraestrutura de e-mail marketing. **Corte:** confirmação in-place sem envio de e-mail; gatilho futuro se marketing pedir.
## Questões em aberto (produto)
- **Texto(s) do consentimento?** Novo texto cobrindo os dois níveis — "fazer parte do time" (comunicações frequentes + grupos de WhatsApp + participação nas ações) e "esporádico" (comunicação direta, sem grupos/sem frequência) — com chave estável nova, resolvida fail-closed. **Recomendação:** o jurídico aprova o(s) texto(s) e o admin cria o(s) documento(s) antes da ativação — o app não captura sem ele(s); a chave exata e a estrutura (um vs. dois textos) ficam para a implementação junto do jurídico. _(decisões de campos/local/toggle/Lead já confirmadas com produto em 2026-08-19; só o texto jurídico fica pendente de aprovação)_
## Referências
- Rascunho UI (gate): `docs/plans/captura-novidades-home-campanha-ui-draft.html` + PNGs embutidos acima
- `src/app/(frontend)/actions/submitWhatsapp.ts` — padrão de escrita transacional com consent fail-closed
- `src/components/WhatsappForm.tsx`, `src/lib/schemas/whatsapp-form.ts` — padrão de form cliente e schema
- `src/lib/campaignConsentKeys.ts` — keys estáveis existentes
- `src/components/CampaignHero.tsx` — CTA "Conhecer bandeiras" (linha ~104–114) a substituir
- `AGENTS.md` — `Contact` como pessoa normalizada + joins, consentimento por chave estável, transações multi-collection, bloqueio jurídico de `supporter` (Onda 0)

View file

@ -1,68 +0,0 @@
# Impl: Cards da seção "Acompanhe de perto": sem cantos arredondados, imagem de borda a borda, chip removido
Status: aprovado
Atualizado em: 2026-08-19
Issue: #90
Intenção: docs/plans/cards-acompanhe-de-perto-polimento.md
Appetite restante: herdado (~0,5–1 dia; na prática, horas)
## Leitura da intenção
- **Outcome:** os cards da seção "Acompanhe de perto" (bento 1+4 no desktop, carrossel no mobile) passam a ter cantos retos (card e capa), capa de borda a borda, **sem borda e sem sombra no card** (decisão do humano em 2026-08-19, após o gate — estende o rascunho, que mantinha `border`/`shadow`) e nenhum chip de badge — nem tipos de post do site, nem origens externas (decisão B do gate). O texto interno (título, subtítulo, meta) continua com o respiro atual (`p-3`) em relação às bordas.
- **O que NÃO negociar:** nada além da superfície muda — compartilhamento (S4) usável no canto, grade do bento, carrossel, links, bandas do S5 (borda/background da seção) e o fail-closed da seção (some inteira quando nada é visível). Nada de tokens de cor, capas reais, proporções ou outras seções.
- **O que reavaliar:** a hipótese da intenção ("mover o padding para o bloco de texto e remover o badge; dados de badge tendem a ficar órfãos — o executor decide o que descartar"). Confirmado no código: `badgeLabel` vive só no card + nas três funções de mapeamento da seção + `POST_TYPE_BADGE_LABELS` (com um unit test dedicado). Decisão de engenharia: remover os órfãos por completo (tipo do card, constante e teste unitário) — manter morto seria dead code flagrável pelo knip (CI-blocking).
## Abordagem recomendada
```mermaid
flowchart LR
A[CampaignContentCard.tsx<br/>superfície do card] --> B["card: -rounded-xl -p-3 -gap<br/>capa: -rounded-lg<br/>texto: +p-3<br/>badge span: removido"]
A --> C["CampaignContentCardData<br/>-badgeLabel"]
D[CampaignContentSection.tsx] --> E["-badgeLabel nas 3 funções<br/>-import POST_TYPE_BADGE_LABELS"]
F[posts.ts] --> G["-POST_TYPE_BADGE_LABELS"]
H[postsDateFormats.unit.spec.ts] --> I["-describe POST_TYPE_BADGE_LABELS"]
J[frontend.e2e.spec.ts] --> K["badge loop: 812-814,<br/>YouTube: 952,<br/>Instagram: 1153<br/>→ ausência + raio 0"]
```
**Opções consideradas:** A (superfície no card único + remoção dos órfãos) | B (variante/flag de card, ex. `variant="flat"`) | C (manter `POST_TYPE_BADGE_LABELS` e o badge no DOM com CSS oculto)
**Recomendação:** A — o card é o único componente da seção (bento e carrossel reusam `CampaignContentCard`); não há outro consumidor com cantos arredondados para preservar, então uma variante seria abstração sem dono. A remoção dos dados de badge é limpa: `badgeLabel` não é usado em mais nenhum lugar do repo.
**Rejeitadas:** B (over-engineering: nenhum outro card usa o componente; "edit the owner, don't twin"); C (dead code + dead data path; knip derruba o CI e o teste unitário continuaria pinando um contrato morto).
### Componentes / mudanças
- **`CampaignContentCard.tsx`** (`src/components/`): `cardClassName` vira constante sem `featured` (o único uso do param era o `gap-3`/`gap-2`; `featured` continua dirigindo `sizes` da imagem e o `h3`): `flex h-full flex-col bg-white transition-shadow ...` — sem `rounded-xl`, sem `p-3`, sem `gap`, **sem `border border-(--campaign-line)` e sem `shadow-sm`/`hover:shadow-md`/`transition-shadow`**. Capa: remove `rounded-lg` (mantém `overflow-hidden` — o zoom hover continua contido na própria capa). Bloco de texto: ganha `p-3` (`flex flex-1 flex-col gap-1 p-3`), igual ao draft. Badge `<span>` removido; `badgeLabel` sai de `CampaignContentCardData`. O anel de foco (`focus-visible:ring-2 ring-(--pt-red)`) do link permanece — a11y.
- **`CampaignContentSection.tsx`** (`src/components/`): `badgeLabel` removido de `toArticleCardData`/`toVideoCardData`/`toInstagramCardData`; import `POST_TYPE_BADGE_LABELS` removido. Nada mais muda (limites, mesclagem, capas, metas).
- **`src/utilities/posts.ts`**: deleta `POST_TYPE_BADGE_LABELS` (único consumidor era a seção; `POST_TYPE_LABELS` plural fica — usado nas listagens).
- **`tests/unit/postsDateFormats.unit.spec.ts`**: import e describe de `POST_TYPE_BADGE_LABELS` removidos.
- **Migration:** sem migration (nenhuma mudança de schema/coleção).
- **Access / Consent:** nenhum.
- **UI:** Impeccable B — encaixe em um componente existente (shape→craft→critique→polish). O botão `ContentShareButton` (S4) segue `absolute top-2 right-2` sobre a capa, agora sem moldura; o draft mantém o botão no canto — sem mudança nele.
### Dados → forma (se aplicável)
- N/A — nenhum dado novo; apenas remoção de um campo de superfície (`badgeLabel`) do tipo de dados do card.
## Fases verificáveis
1. **Superfície + órfãos** — `CampaignContentCard.tsx`, `CampaignContentSection.tsx`, `posts.ts`, unit test do badge; verificação: `pnpm typecheck` + `pnpm test:unit` (o describe removido some, o resto passa).
2. **e2e** — `tests/e2e/frontend.e2e.spec.ts`: loop de badges (812–814) e as asserções `YouTube`/`Instagram` (952, 1153) → asserções de ausência (`toHaveCount(0)` para os quatro tipos + YouTube/Instagram nos testes de feed) e pin do novo visual no teste do bento (`border-radius: 0px` no card e na capa, `border-top-width: 0px` e `box-shadow: none` no card, no mesmo espírito dos pins CSS do S5). `toHaveCSS` com `first()` para não quebrar com o carrossel escondido no mobile.
3. **Gates** — `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test` (unit+int). E2e roda no CI (OPS59) — local opcional com `pnpm test:e2e:affected`.
## Rabbit holes / Não escopo (engenharia)
- Não tocar `ContentShareButton`, `CampaignContentCarousel`, banda/bordas da seção (S5), `POST_TYPE_LABELS` (plural), `getVisiblePosts`/fail-closed ou capas. O rascunho HTML do gate fica intocado como artefato — a remoção de borda/sombra é decisão do humano posterior ao gate e não altera o arquivo do rascunho.
- Não criar variante de card, não esconder badge via CSS, não renomear `h3`/metas.
- Cuidado com o `gap` removido: o `mt-auto` da meta continua pinando a data ao rodapé do bloco; o `p-3` do bloco de texto é quem dá o respiro (draft).
## Riscos e mitigação
- **e2e que assere badge que não mapeei** — verificado por grep: só 812–814 (loop), 952 (YouTube) e 1153 (Instagram) no `frontend.e2e.spec.ts`; `waitForHomeHTML` só usa títulos/links de cabeçalho. Grep final antes do push.
- **Raio 0 pinado pelo e2e pode flake por arredondamento** — `toHaveCSS('border-radius', '0px')` é determinístico (nenhum token de raio no card/capa).
- **Dead code residual** — `POST_TYPE_BADGE_LABELS` e `badgeLabel` removidos do mesmo PR; knip no gate confirma.
- **Capa sem cover (placeholder `--campaign-band`)** — continua com borda a borda e cantos retos, igual ao draft; nada quebra.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (raio 0 card+capa, capa borda a borda, texto `p-3`, nenhum chip em nenhum card, **sem borda e sem sombra no card**)
- [ ] Invariantes AGENTS/engineering-standards (card único como dono da superfície; S5/bandas intocadas; fail-closed da seção intacto)
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum write path muda); e2e atualizados para o novo estado visual

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View file

@ -1,143 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>
UI draft — Cards da seção "Acompanhe de perto": sem raio, imagem de borda a borda, chip
removido
</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-6xl p-8">
<p class="text-sm text-zinc-400">
Visitante da home pública de campanha (jorgesolla1313) — cards da seção "Acompanhe de perto"
propostos: sem cantos arredondados, imagem de borda a borda no card, texto com respiro
interno, chip removido de todos os cards (posts do site e origens externas). Cenário 1:
desktop. Cenário 2: mobile ~390px.
</p>
<section data-shot="desktop" class="mt-8 border-y border-zinc-800 bg-zinc-200">
<div class="mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="text-xs font-black uppercase tracking-[0.1em] text-red-700">
Acompanhe de perto
</p>
<h2 class="mt-1 text-2xl font-black tracking-tight text-zinc-900">
A caminhada, em tempo real
</h2>
<p class="mt-1 text-sm text-zinc-600">
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
</p>
</div>
<div class="flex gap-4 text-sm font-bold text-red-700 underline-offset-4">
<span>Ver artigos →</span>
<span>Seguir no Instagram →</span>
<span>YouTube →</span>
</div>
</div>
<div class="mt-6 grid grid-cols-4 gap-4">
<div class="col-span-2 row-span-2">
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="text-base font-bold leading-snug text-zinc-900">
Caravana do Jorge chega ao interior com mutirão de saúde
</h3>
<p class="text-xs leading-relaxed text-zinc-600">
Equipe percorreu quatro municípios e levou atendimento, roda de conversa e a
defesa da retomada do programa.
</p>
<span class="mt-auto text-xs text-zinc-600">há 2 horas · Saúde</span>
</div>
</article>
</div>
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="text-sm font-bold leading-snug text-zinc-900">
Plenária em Feira de Santana debate a reforma tributária
</h3>
<span class="mt-auto text-xs text-zinc-600">há 5 horas · Eleitoral</span>
</div>
</article>
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="text-sm font-bold leading-snug text-zinc-900">
Jorge em visita à fábrica de remédios no Polo Petroquímico
</h3>
<span class="mt-auto text-xs text-zinc-600">há 1 dia · Mandato</span>
</div>
</article>
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="text-sm font-bold leading-snug text-zinc-900">
Direto da caminhada: o povo no centro do projeto
</h3>
<span class="mt-auto text-xs text-zinc-600">há 1 dia · 12 mil visualizações</span>
</div>
</article>
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
<div class="relative aspect-square w-full shrink-0 bg-zinc-300"></div>
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="text-sm font-bold leading-snug text-zinc-900">
Mutirão de castração gratuita reúne famílias no bairro
</h3>
<span class="mt-auto text-xs text-zinc-600">há 2 dias</span>
</div>
</article>
</div>
</div>
</section>
<section data-shot="mobile" class="mt-8 w-[390px] border-y border-zinc-800 bg-zinc-200">
<div class="px-5 py-8">
<p class="text-xs font-black uppercase tracking-[0.1em] text-red-700">
Acompanhe de perto
</p>
<h2 class="mt-1 text-xl font-black tracking-tight text-zinc-900">
A caminhada, em tempo real
</h2>
<p class="mt-1 text-sm text-zinc-600">
Bastidores, caravanas e as lutas do mandato: conteúdo atualizado, direto das redes.
</p>
<div class="mt-5">
<article class="flex h-full flex-col border border-zinc-400 bg-white shadow-sm">
<div class="relative aspect-video w-full shrink-0 bg-zinc-300"></div>
<div class="flex flex-1 flex-col gap-1 p-3">
<h3 class="text-sm font-bold leading-snug text-zinc-900">
Direto da caminhada: o povo no centro do projeto
</h3>
<p class="text-xs leading-relaxed text-zinc-600">
Do comício em Salvador aos mutirões no interior, o dia a dia da campanha em vídeo,
direto do canal.
</p>
<span class="mt-auto text-xs text-zinc-600">há 1 dia · 12 mil visualizações</span>
</div>
</article>
<div class="mt-3 flex items-center justify-center gap-2">
<span class="h-2 w-2 rounded-full bg-red-700"></span>
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
<span class="h-2 w-2 rounded-full bg-zinc-500"></span>
</div>
<p class="mt-2 text-center text-xs font-semibold text-zinc-600">
1 de 5 · deslize para ver os próximos
</p>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,107 +0,0 @@
# Cards da seção "Acompanhe de perto": sem cantos arredondados, imagem de borda a borda, chip removido
Status: rascunho
Atualizado em: 2026-08-19
Issue: #90
Priority: P2
Model: composer-2.5
Impeccable: B — encaixe nos cards da seção "Acompanhe de perto" da home pública (jorgesolla1313)
Rascunho UI: docs/plans/cards-acompanhe-de-perto-polimento-ui-draft.html + PNGs embutidos abaixo
Appetite: ~0,5–1 dia de eng — CSS em um componente + ajuste de teste
Responsável: —
## Intenção
Os cards da seção "Acompanhe de perto" (a home de campanha, bento 1+4 no desktop e carrossel no
mobile) ficaram com cara de "card de app": cantos arredondados, imagem de capa emoldurada por um
respiro de padding e um chip "Notícia" repetido em todo post do site. O desejo é um look mais
editorial e direto: **cantos retos**, **imagem de capa de borda a borda no card** e **chips de
badge removidos de todos os cards** (posts do site e origens externas — decisão do gate) — o
texto interno continua com respiro em relação às bordas do card. Nada além disso muda: o mesmo
componente serve o bento e o carrossel, e a mudança vale para os dois.
## Persona e fluxo
- **Persona / contexto:** visitante da home pública de campanha, navegando no celular ou desktop;
rola até a seção "Acompanhe de perto" e escaneia os cards.
- **Job principal:** ver o conteúdo de relance — a imagem é o chamariz, o título e a data seguem
abaixo, sem ruído de etiqueta repetida.
- **Fluxo desejado:** a seção abre como hoje; cada card mostra a capa em tela cheia, cantos retos,
e o texto respira da borda; nenhum chip de badge polui a leitura; o compartilhamento (S4) segue
disponível no canto.
- **Anti-goals de produto:** não redesenhar a seção (banda de fundo do S5, textos, links e
carrossel seguem iguais); não mexer em outras seções da home; não mudar tokens de cor ou o
comportamento fail-closed da seção.
### Rascunho UI (B/C/D)
Cena 1 (desktop — bento 1+4): cards sem raio, capa de borda a borda, texto com `p-3`, nenhum
chip de badge em nenhum card:
![Rascunho UI — desktop](cards-acompanhe-de-perto-polimento-ui-draft-desktop.png)
Cena 2 (mobile ~390px — carrossel): mesmo card do carrossel, capa de borda a borda, sem chip:
![Rascunho UI — mobile](cards-acompanhe-de-perto-polimento-ui-draft-mobile.png)
## Objetivo e aceite
- Nenhum card da seção tem cantos arredondados — nem o card, nem a capa (desktop e mobile).
- A capa encosta nas bordas superior/laterais do card (sem padding no contêiner do card); o texto
(título, subtítulo, meta) tem padding interno consistente com o respiro atual (`p-3`) em relação
às bordas do card.
- O chip de tipo do post do site ("Notícia" e os demais tipos de post) some dos cards de posts —
e, por decisão do gate, os chips de origem externa (YouTube/Instagram) também são removidos:
nenhum chip de badge aparece em nenhum card.
- O compartilhamento (botão de canto, S4), a grade do bento, o carrossel, os links e o
comportamento fail-closed da seção continuam funcionando; o e2e que assere os textos de badge
(`tests/e2e/frontend.e2e.spec.ts`, loop de `Artigo|Notícia|Campanha|Evento`) é atualizado para o
novo estado visual (e o e2e dos feeds externos, se asserir "YouTube"/"Instagram" na seção).
## Dados (intenção)
- **Vou apresentar dados?** Não — nenhum número novo; só a superfície dos cards muda.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/components/CampaignContentCard.tsx` (único componente de card —
classes `rounded-xl`/`rounded-lg` do card e da capa, `p-3` do contêiner, `span` do badge no
canto superior esquerdo da capa; mover o padding para o bloco de texto e remover o badge); o
carrossel (`CampaignContentCarousel.tsx`) herda a mudança por reuso do mesmo card; os dados de
badge (`badgeLabel` em `CampaignContentSection.tsx` / `POST_TYPE_BADGE_LABELS` em
`src/utilities/posts.ts`) tendem a ficar órfãos com a remoção de todos os chips — o executor
decide o que descartar sem quebrar os e2e.
- **Precedente a olhar:** `docs/plans/separacao-visual-secao-conteudos-home.md` (S5 — banda da
seção, que não deve ser tocada) e `docs/plans/secao-conteudos-home-artigos.md` (S1).
- **Risco de acoplamento:** o e2e da home (`tests/e2e/frontend.e2e.spec.ts`) assere os textos de
badge na seção; o `ContentShareButton` vive no canto do card e deve continuar usável sobre a
capa sem padding.
## Dependências
- Nenhuma (S1–S6 entregues e em produção).
## Fora de escopo
- Remover borda ou sombra dos cards (não pedido — a definição contra a banda do S5 continua).
- Mudar textos, links, ordem, capas reais, proporção das capas ou o carrossel em si.
- Polimento de outras seções da home ou tokens de cor.
## Rabbit holes de produto
- **"Já que os cards ficaram retos, aproveita e redesenha a seção".** Explosão: nova paleta,
microinterações, outras seções. **Corte neste item:** só raio, padding do card e chip.
## Questões em aberto (produto)
- **Qual o alcance da remoção do chip?** **Decidido no gate (2026-08-19): B — remover todos os
chips, incluindo YouTube/Instagram** (a identificação da plataforma de origem deixa de aparecer
no card; o link externo segue sinalizado por abrir em nova aba).
## Referências
- Rascunho UI (gate): `docs/plans/cards-acompanhe-de-perto-polimento-ui-draft.html` + PNGs acima
- `src/components/CampaignContentCard.tsx` — card único (bento + carrossel)
- `src/components/CampaignContentSection.tsx` — montagem da seção (badges por fonte)
- `src/components/ContentShareButton.tsx` — controle de compartilhar do canto (S4, intocado)
- `tests/e2e/frontend.e2e.spec.ts` — asserções de badge da seção a atualizar

View file

@ -1,107 +0,0 @@
# Impl: Compartilhar conteúdos da home no WhatsApp (S4)
Status: aprovado
Atualizado em: 2026-08-19
Issue: #19
Intenção: docs/plans/compartilhar-conteudos-home-whatsapp.md
Appetite restante: herdado (~0,5–1 dia eng)
## Pós-entrega (2026-08-19)
Entregue e mergeado em `main`. Desvios de execução em relação a este plano:
- **`buildWhatsAppTextShareUrl` já existia** em `src/lib/homeSearchShare.ts` (S6) — em vez do builder próprio do rascunho, o `contentShare.ts` reusa o canônico, e o dono do builder foi movido para `src/lib/phone.ts` (o módulo wa.me) com re-export no `homeSearchShare.ts` (sem twin).
- **Roles `menu`/`menuitem` removidos do popover** (achado P1 do critique): Radix Popover não implementa o contrato de widget de menu (setas/roving focus); semântica honesta é popover de 2 ações com Tab — Radix emite `aria-expanded` no trigger.
- **Encoding do `text` é `+` para espaço** (URLSearchParams do builder canônico) — válido para o WhatsApp; os decodes de teste fazem `replace(/\+/g, ' ')`.
- **Débito deferido:** dedupe do blob de 15 campos do `updateSocialFeedSettings` (5 cópias no describe do e2e) — quando os testes S2/S3 forem tocados de novo.
## Leitura da intenção
- **Outcome:** todo card de conteúdo da home (Artigo/YouTube/Instagram — seção S1+S2+S3 entregue) ganha um botão discreto de compartilhar que abre `wa.me` com mensagem pronta por fonte + "Copiar link", sem competir com o CTA do card, sem coletar nada.
- **O que NÃO negociar:** mensagem por fonte (artigo/vídeo/post, decisão B travada); link = artigo → página canônica, YT/IG → URL da plataforma; nova aba com `noopener`; botão acessível (aria-label, alvo ≥44px no mobile) e sem mudar o layout do card; sem migration/schema/Consent — montagem de URL no cliente, sem escrita no servidor; mensagem honesta.
- **O que reavaliar:** a hipótese "reusar `buildWhatsAppUrl` (`src/utilities/phone.ts`)" está **errada** — (a) o módulo é `src/lib/phone.ts`, e (b) `buildWhatsAppUrl` **exige telefone brasileiro válido e lança erro sem ele**; S4 não tem destinatário — é o `wa.me/?text=` do próprio remetente (precedente exato: `PetitionSuccessDialog.tsx:39`). Também reavaliar: o card inteiro é um `<a>`/`<Link>` — um `<button>` aninhado seria HTML inválido e o clique navegaria; o botão precisa ser **irmão** do anchor.
## Abordagem recomendada
```mermaid
flowchart LR
Section["CampaignContentSection (server)<br/>mappers setam shareKind<br/>article|video|instagram"]
Card["CampaignContentCard (server)<br/>wrapper relative + anchor irmão"]
Button["ContentShareButton (client)<br/>Popover (Radix, portal)"]
Menu["menu role=menu:<br/>Compartilhar no WhatsApp / Copiar link"]
Wa["wa.me/?text=msg encodada<br/>target _blank noopener noreferrer"]
Copy["navigator.clipboard<br/>label 'Link copiado' 2s"]
Lib["contentShare.ts (puro)<br/>mensagem por fonte + URL absoluta + wa.me"]
Section --> Card
Card --> Button
Button --> Menu
Menu --> Wa
Menu --> Copy
Button -. monta .-> Lib
```
**Opções consideradas:** A | B | C (por decisão abaixo)
**Recomendação:** descrita nas decisões — irmão do anchor + `wa.me/?text=` + Popover Radix.
**Rejeitadas:** `buildWhatsAppUrl` (exige telefone; throw); botão aninhado no anchor (HTML inválido, clique navega); `navigator.share` nativo (não é o aceite — WhatsApp + copiar link); menu absoluto caseiro (reimplementa portal/foco/fora/escape que o `ui/Popover` já tem).
### Decisões de engenharia
**D1 — Estrutura do card (o botão não cabe dentro do anchor).**
Opções: A) wrapper `relative` no card + anchor irmão + botão absoluto (`z-10`, visual 28px em área de toque 44px, canto superior direito — `group` migra para o wrapper, preservando o zoom do cover e o `h-full` da cadeia bento/li) | B) botão aninhado com `preventDefault`/stopPropagation | C) âncora só no conteúdo e botão em fluxo normal.
Recomendação: **A** — siblings resolvem propagação e a11y por construção, sem `preventDefault` mágico; zero mudança de layout (absoluto). Rejeitadas: B (interactive dentro de interactive — invalid HTML, `role` confuso para leitor de tela); C (muda a hit-area/estética do card — viola "não muda o layout").
**D2 — Link absoluto (artigo é caminho relativo).**
Opções: A) montar no cliente no clique: `new URL(href, window.location.origin)` — hrefs YT/IG já absolutos passam inalterados; origem é a do visitante real (preview correto) | B) resolver `siteUrl` no server (metadata global) e passar link absoluto no card data.
Recomendação: **A** — casa com o aceite "montagem de URL no cliente"; zero dado novo no contrato do card; precedente `PetitionSuccessDialog` (`window.location.href`). Rejeitada: B (acopla a seção à leitura da global + prop extra sem necessidade).
**D3 — Mensagem por fonte (aceite travado).**
Opções: A) campo explícito `shareKind: 'article' | 'video' | 'instagram'` no `CampaignContentCardData`, setado pelos 3 mappers da seção (ponto único de renderização) | B) derivar da fonte por heurística (`badgeLabel`, forma do href).
Recomendação: **A** — `badgeLabel` é display (mudaria a msg se a label mudar); href é heurística frágil. A lógica pura fica em `src/lib/contentShare.ts` (unit-testável): prefixo por kind (`Olha isso do Solla: ` / `Olha esse vídeo do Solla: ` / `Olha esse post do Solla: `) + `{título} — {link}`.
**D4 — Menu (clipping do carrossel mobile).**
Opções: A) Popover Radix (`src/components/ui/Popover.tsx` — portal, escape, clique-fora, foco de graça) | B) menu absoluto caseiro dentro do card | C) Sheet/Drawer no mobile.
Recomendação: **A** — o track do `CampaignContentCarousel` é `overflow-x-auto`; um absoluto caseiro estouraria o box do card e criaria scrollbar vertical/clipping; o portal do Radix escapa qualquer container. Rejeitadas: B (reimplementa foco/fora/escape e arrisca clipping); C (overkill para 2 ações). Conferir no craft o token de superfície do conteúdo (`bg-popover` existe no tema público? senão `bg-white` + `border-(--campaign-line)` igual ao card).
**D5 — Feedback de copiar.**
Opções: A) swap inline do item para "Link copiado" + `CheckIcon` por 2s (padrão `LeadershipInviteButtons`/`CalendarFeedDialog`) | B) `toast` (sonner).
Recomendação: **A** — o layout público não monta `<Toaster>`; swap inline é o padrão do repo. `navigator.clipboard` com try/catch (fallback de label em falha).
### Componentes / mudanças
- **`ContentShareKind` + `buildContentShareMessage` + `buildContentShareLink` + `buildContentShareWhatsAppUrl`** (`src/lib/contentShare.ts`, novo, puro): frase por fonte, resolução `new URL(href, origin)` e `https://wa.me/?text=${encodeURIComponent(message)}` — a forma do `PetitionSuccessDialog` com `noopener noreferrer` no call site.
- **`CampaignContentCardData.shareKind`** (novo campo, obrigatório) + **`CampaignContentCard`** (server): wrapper `relative` (absoluto do botão), anchor irmão; renderiza `<ContentShareButton kind title href />`. Sem migration.
- **`CampaignContentSection`**: os 3 mappers (`toArticleCardData`/`toVideoCardData`/`toInstagramCardData`) setam `shareKind`. Único ponto — nada duplicado por plataforma.
- **`ContentShareButton`** (`src/components/ContentShareButton.tsx`, novo, client): trigger `aria-label="Compartilhar"` (`Share2Icon` — já usado no repo), visual 28px `rounded-full bg-white shadow` sobre área de toque ≥44px (`h-11 w-11`), `PopoverTrigger` + `PopoverContent` (portal) com `role="menu"`; itens: `<a href={waUrl} target="_blank" rel="noopener noreferrer">` com `WhatsAppIcon` (de `socialIcons`, usado no público) e botão "Copiar link" (`CopyIcon` → "Link copiado" + `CheckIcon`).
- **Migration:** nenhuma. **Access/Consent:** nenhum — zero escrita, zero leitura nova.
- **UI:** Impeccable B (discreto, não compete com o CTA) — shape → craft → critique → polish no padrão da seção (tokens `--campaign-*`, `--pt-red` no foco, `motion-reduce`).
## Fases verificáveis
1. **Lib pura + unit** — `src/lib/contentShare.ts` + `tests/unit/contentShare.unit.spec.ts` (frases por fonte, resolução de link relativo/absoluto, encoding do `wa.me`, round-trip `decodeURIComponent`).
2. **Card + seção** — wrapper relativo + `shareKind` nos mappers + `ContentShareButton` com Popover; verificar visual no dev (bento desktop + carrossel mobile, incl. card inerte do carrossel).
3. **E2E** — dentro do describe `Campaign home content section` (`tests/e2e/frontend.e2e.spec.ts`, serial, fixtures/stubs existentes): botão por card; menu abre; href `wa.me` correto por fonte (título + link absoluto, `target=_blank` `rel=noopener noreferrer`); copiar link grava a URL absoluta no clipboard (grant `clipboard-read/write`) e troca label; Escape/clique-fora fecha; mobile: área de toque ≥44px no card do carrossel e menu visível (portal não é clippado).
4. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm build`; e2e verde no CI.
## Rabbit holes / Não escopo (engenharia)
- **UTM/medição de clique** — fora (intenção: item futuro explícito); nada é coletado.
- **`navigator.share` nativo / outros canais** — fora; aceite é WhatsApp + copiar link.
- **Terceira ação no menu, ou menu por plataforma** — não; 2 itens fixos, um componente.
- **Generalizar o popover de compartilhar para fora da home** — não; 1 call site.
- **Tocar `CampaignContentCarousel`** — não; o botão vem do card, e o `inert` do carrossel já cobre os cards não ativos.
## Riscos e mitigação
- **HTML inválido / navegação ao compartilhar** → botão irmão do anchor (D1); zero propagação.
- **Clipping do menu no carrossel `overflow-x-auto`** → portal do Radix (D4).
- **Token `bg-popover` ausente no tema público** → conferir em `styles.css`; fallback `bg-white` + borda do card.
- **Quebra da cadeia `h-full` do bento/`li`** → wrapper `h-full` + anchor `h-full` (verificar no craft com o card featured).
- **Clipboard indisponível (http/privado)** → try/catch com label de erro honesto, sem crash.
- **Hidratação** → props de server→client são só `kind`/`title`/`href` (strings) — sem serialização nova.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (mensagens por fonte, links, `noopener`, ≥44px mobile, sem mudança de layout, sem escrita)
- [ ] Invariantes AGENTS: identificadores em inglês / copy pt-BR; sem migration; sem Consent; reuso de `ui/Popover`, `WhatsAppIcon`, padrão `copied` do repo
- [ ] Unit da lib pura + E2E no describe existente da seção (cobertura de domínio: mensagem por fonte e URL absoluta)

View file

@ -1,89 +0,0 @@
# Impl: Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
Status: aprovado
Atualizado em: 2026-08-19
Issue: #106
Intenção: docs/plans/demandas-responsaveis.md
Appetite restante: herdado (~1–1,5 dias eng; tracer primeiro, polish depois)
## Leitura da intenção
- **Outcome:** a demanda tem um conjunto de **responsáveis** (staff da campanha); candidato e coordenador sempre a veem; assessor do município relacionado que não é responsável **não vê** — nem lista, nem URL, nem busca. Criador entra automaticamente como responsável. Quem atualiza a demanda gerencia os responsáveis. Leader lockdown intacto; custo/comprovantes seguem a visibilidade da demanda.
- **O que NÃO negociar:** visibilidade **fail-closed** por vínculo explícito (nada de herança do município); regra nos dados (access), não escondida no cliente; leader nunca vê demandas; escalada continua candidato/coordenador.
- **O que reavaliar:** a hipótese da intenção era "`canReadCampaignDemand` passa a ser unrestricted ou responsável". Confirma-se no código: hoje o escopo vive **só** em `src/utilities/access/demands.ts:33` (`resolveActorScopedRead` com `municipality`), e **todos** os consumidores (lista, detalhe, busca global do dashboard, demandas vinculadas da atividade, C90, omnibox) herdam via Local API `overrideAccess: false` — não há REST nem client query. O corte é cirúrgico: regra nova + campo + migration + UI de gestão.
## Abordagem recomendada
```mermaid
flowchart LR
F[CampaignDemand.responsibles<br/>hasMany → campaignUser] --> A[canRead/UpdateCampaignDemand<br/>advisor → responsibles contains id]
H[enforceDemandWorkflow<br/>criador vira responsável na criação] --> F
M[Migration: campaign_demand_rels<br/>+campaign_user_id + backfill createdBy] --> F
S[updateCampaignDemandResponsibles<br/>tx + advisory lock] --> A
U[DemandResponsibleMultiSelect<br/>nova + wizard + detalhe] --> S
```
**Opções consideradas:**
- **A — Campo `responsibles` hasMany → `campaignUser` + regra `where` de membership** (espelho de `municipality.advisors` / `{ advisors: { contains } }`).
- **B — Relação polimórfica `responsible` (union campaignUser/leadership/stateDeputy) como a `activity`.**
- **C — `or: [responsibles contains, createdBy equals]`** (criador visível mesmo se removido dos responsáveis).
**Recomendação:** **A** — single collection, sem union desnecessária (responsável é sempre staff da campanha — `eligibleCampaignStaffWhere`), query escalar `{ responsibles: { contains: id } }` (mesmo mecanismo do `advisors`), e a join table `campaign_demand_rels` **já existe** (hoje usada por `receipts`/media) — a migration só adiciona a coluna `campaign_user_id` + FK + índice.
**Rejeitadas:** **B** porque responsável é sempre `campaignUser` (não há liderança/dobradinha como responsável de demanda — a intenção fala de "pessoas da campanha"/staff; union polimórfica traria o custo do `equals { relationTo, value }` e catalog triplo sem ganho). **C** porque fura o modelo de responsável explícito: remover o criador da lista não o tiraria da visibilidade, e a intenção fixou "criador entra **como responsável**" (vínculo explícito, único source of truth). O criador entra na lista por hook **na criação** + **backfill** dos existentes.
### Decisões de engenharia
1. **Regra de leitura/atualização** — `canReadCampaignDemand` deixa de usar `resolveActorScopedRead` (que é o prólogo municipal) e vira função própria:
`admin → true; leader → false; unrestricted → true; advisor → { responsibles: { contains: id } }; resto → false`. `canUpdateCampaignDemand = canReadCampaignDemand` (regra única "quem vê gerencia" — aceite: "quem pode atualizar pode gerenciar os responsáveis"). `canCreateCampaignDemand` **inalterado** (staff no município da carteira). `canDeleteCampaignDemand` admin-only inalterado. `canReadCampaignStaffField`/`canManageCampaignStaffField` inalterados.
2. **Criador auto-responsável** — no branch `create` do hook `enforceDemandWorkflow` (`CampaignDemand.ts:129-143`), quando `actor` é campaignUser: `data.responsibles = union(data.responsibles, [actor.id])`. Cobre **todos** os caminhos de criação: form `/demandas/nova`, wizard (A5), rascunhos C90 (activity.ts passa `user: currentActor`), admin UI (payload admin → actor null → sem responsáveis → fail-closed: invisível a assessores, o que é aceitável — quem criou via admin vê tudo). Sem auto-add no update.
3. **Backfill dos existentes** — na própria migration (padrão C90 `20260808_184113_remodel_activity_responsible.ts`, `RAISE NOTICE` + SQL no up): `INSERT INTO campaign_demand_rels (order, parent_id, path, campaign_user_id) SELECT 0, id, 'responsibles', created_by_id FROM campaign_demand WHERE created_by_id IS NOT NULL`. Preserva "a criação não some do criador" para as demandas pré-c143. Campo novo → nenhuma linha tem responsável → sem dedup.
4. **Gestão (server)** — `updateCampaignDemandResponsiblesRecord` em `actions/demand.ts`, mesmo shape dos irmãos: schema zod novo (`{ id, responsibles: array de positiveRelationshipId }`), `withPayloadTransaction`, `reloadCampaignActor`, staff check, advisory lock `campaign-demand:{id}`, `findByID` scoped (`overrideAccess: false` — a row access autoriza antes do write), `payload.update` com a lista **completa** (replace). Replace é a forma mais simples e segura: o estado do client já é a lista inteira e o advisory lock serializa editores concorrentes (add/remove atômico seria 2 formas de escrita para o mesmo dado).
5. **Busca de candidatos (UI)** — server action `searchDemandResponsibleOptions(query, municipalityId)` (padrão `searchDemandActivityOptions`): query < 2 chars → sugestões = assessores do município da demanda (read scoped de `municipality.advisors`); senão → busca por nome em `campaignUser` com `eligibleCampaignStaffWhere` (reusa o padrão de `loadEligibleAdvisorOptions`), `overrideAccess: false` (campaign users leem `campaignUser` — `canReadCampaignUsers`). Sugestão NÃO é herança: é atalho de preenchimento, a visibilidade vem só do campo salvo.
6. **Componente de multi-select** — `DemandResponsibleMultiSelect` novo em `src/components/campaign/demand/` (domain, não shared): single collection, chips removíveis, chip do criador rotulado "Você (criador)", sugestões no diálogo quando query curta, hidden inputs repetidos `responsibles` (contrato de `RelationMultiSelect`, não o JSON de `ResponsibleMultiSelect`). **Não reusar `ResponsibleMultiSelect` (C90)**: é polimórfico (typeLabel/relationTo), tem cap hardcoded `MAX_ACTIVITY_RESPONSIBLES=20`, e o contrato de FormData é JSON tipado — forçar modo single-collection seria 4 props novas + branch, um módulo raso; dois componentes com contratos distintos é honesto (mesmo critério que separou `RelationMultiSelect` de `ResponsibleMultiSelect`).
7. **Detalhe** — card `DemandResponsiblesCard` (client) com a lista (nome + avatar via `loadCampaignUserDisplayByIds`, helper existente), add por combobox, remove por chip, e o rótulo estático "Visível para: Responsáveis · Candidato · Coordenador". Layout do rascunho: grade `lg:grid-cols-3` — coluna 1–2 com os blocos atuais (descrição, decisão, workflow, controle interno), coluna 3 com o card. Todos os readers são updaters (update access = read access), então o card de gestão aparece para qualquer staff que abra a demanda — sem flag.
8. **Criação** — `DemandFields` ganha o campo responsáveis (opcional via prop `responsiblesMunicipalityId`/`currentUser`): `/demandas/nova` e wizard compartilham o mesmo parse (`parseCampaignDemandCreateFormData` ganha `responsibles: formData.getAll('responsibles')`), schema de create ganha `responsibles: z.array(positiveRelationshipId)` (sem cap — como `municipality.advisors`; o hook faz o union, dedup implícito). Draft: `/demandas/nova` em grade 2 colunas (form | responsáveis); wizard empilhado (o wizard herda via `DemandFields`).
### Componentes / mudanças
- **`CampaignDemand.responsibles`** (`src/collections/CampaignDemand.ts`): `relationship hasMany → campaignUser`, label "Responsáveis", index; sem field access (row access já gateia; staff nativo não abre `/admin`).
- **`enforceDemandWorkflow`** (`CampaignDemand.ts`): union do criador no create (campaignUser only).
- **`canReadCampaignDemand`/`canUpdateCampaignDemand`** (`src/utilities/access/demands.ts`): regra de membership; remove `resolveActorScopedRead`/`getAccessibleMunicipalityIds` deste módulo.
- **Migration:** `add_campaign_demand_responsibles` (drizzle): `ALTER TABLE campaign_demand_rels ADD COLUMN campaign_user_id` + FK (`ON DELETE cascade`, padrão `state_deputy_rels`) + índice; + backfill no up. Gerar com `pnpm migrate:create`.
- **Access / Consent:** sem Consent (dado interno staff). Fail-closed no where da access.
- **Server actions:** `updateCampaignDemandResponsiblesRecord` + `setDemandResponsiblesFormAction` (`[slug]/formActions.ts`, `revalidatePath`); `searchDemandResponsibleOptions` (`demandas/responsibleSearchActions.ts`).
- **UI:** `DemandResponsibleMultiSelect` + `DemandResponsiblesCard` em `src/components/campaign/demand/`; `DemandFields`/`DemandForm`/`WizardRegisterDemandStep` ganham o campo; `[slug]/page.tsx` ganha o card + grade lg. Impeccable B — seguir o rascunho (`demandas-responsaveis-ui-draft-*.png`): chips, placeholder "Buscar assessor…"/"Adicionar responsável…", textos informativos do rascunho.
- **View-model:** `DemandDetailViewModel.responsibles: Array<{ id, name, avatarUrl }>` via `loadCampaignUserDisplayByIds`; `nova/page.tsx` carrega o `currentUser` (id/nome) para seed do picker.
### Dados → forma
- Sem dados apresentados ao usuário além de nomes/avatares de responsáveis — forma já existente (chips com nome + avatar, padrão do código). Lista da demanda **não** ganha coluna de responsáveis (fora do aceite — apenas visibilidade muda).
## Fases verificáveis
1. **Schema + server (tracer)** — migration + campo + hook do criador + access rule; `pnpm generate:types`; int tests de access reescritos/adicionados (`campaignDemandWorkflow.int.spec.ts`, `homeSearchDemands.int.spec.ts`); backfill verificado no local.
2. **Ações + busca** — `updateCampaignDemandResponsiblesRecord` + zod + `searchDemandResponsibleOptions`; int de gestão (add/remove/self-removal, advisor não-responsável não lê).
3. **UI** — multi-select na criação (nova + wizard) e card no detalhe; shape → critique → polish contra o rascunho; e2e: assessor não-responsável não vê (lista vazia + URL → 404); ajustar e2e existentes se o aceite mudou o comportamento (criador auto-responsável preserva os fluxos atuais).
4. **Gates** — `tsc`, `lint`, `format:check`, `knip`, `check:cycles`, unit+int, `build` local; e2e afetado no CI. Aikido nos arquivos tocados.
## Rabbit holes / Não escopo (engenharia)
- **Notificação ao ser marcado responsável** (fora de escopo da intenção).
- **Coluna de responsáveis na lista / omnibox** — visibilidade herda a access, sem mudança de UI de lista.
- **C141 "Visão Tudo"** — não existe ainda; a regra nova já falha fechado para assessor em qualquer perfil futuro.
- **Generalizar `ResponsibleMultiSelect`/`RelationMultiSelect`** — rejeitado (contratos distintos, módulo raso).
- **Overflow "+N" dos chips no detalhe** — o padrão do código é wrap; o "+2" do rascunho é affordance de wireframe, não spec.
## Riscos e mitigação
- **Regra nova muda o que assessores veem em todos os pontos de demanda** (lista, detalhe, busca home, activity detail, C90). Mitigação: tudo herda por `overrideAccess: false` — um único ponto de corte; int tests cobrem cada superfície; C90 cria com `user: currentActor` → criador auto-responsável → a atividade continua listando as próprias demandas.
- **Self-removal deixa a demanda sem responsável** → visível só a candidato/coordenador (fail-closed, aceitável e coerente com o modelo explícito — gestão é dos responsáveis).
- **Join table já existente** (`campaign_demand_rels` com `path='media'`) — a migration adiciona coluna/FK; conferir o SQL gerado (não regenerar a tabela).
- **`codebaseConventions.unit.spec.ts`** — o guard anti-fragmento municipal não é violado (a regra nova não re-escreve `{ municipality: { in } }`); rodar o guard no gate.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (visibilidade fail-closed por responsáveis explícitos; criador automático; gestão por quem atualiza; leader lockdown; escalada inalterada)
- [x] Invariantes AGENTS/engineering-standards (Local API com `user`/`overrideAccess: false`; transação + advisory lock nas escritas multi-step; pt-BR em labels, identificadores em inglês)
- [x] Testes de domínio previstos (int: access rule, gestão de responsáveis, backfill; e2e: invisibilidade para não-responsável; adjust: workflow/homeSearch/listData)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View file

@ -1,167 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>UI draft — Demandas: responsáveis explícitos</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-6xl p-8">
<p class="text-sm text-zinc-400">
Demanda só aparece para candidato/coordenador e para quem está marcado como responsável —
nem assessor do município vê fora disso. Cenas: detalhe com seção Responsáveis + editor,
formulário de criação com sugestão.
</p>
<!-- Cena 1: detalhe da demanda — visão de coordenador, com responsáveis -->
<section data-shot="detalhe" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<div class="bg-zinc-100 px-8 py-6">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
>Material de campanha</span
>
<span class="rounded bg-zinc-300 px-2 py-0.5 text-xs font-bold text-zinc-800"
>Em aberto</span
>
</div>
<div class="mt-2 text-lg font-bold text-zinc-900">
Cartazes para a caminhada do fim de semana
</div>
<div class="mt-1 text-sm text-zinc-500">Ilhéus · liderança: Nome da liderança</div>
</div>
</div>
<div class="mt-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="rounded-lg border border-zinc-200 bg-white p-5">
<div class="text-sm font-bold text-zinc-900">Descrição</div>
<div class="mt-2 text-sm leading-relaxed text-zinc-600">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
</div>
<div class="mt-4 flex items-center justify-between border-t border-zinc-100 pt-3">
<div class="text-xs text-zinc-500">Custo estimado</div>
<div class="text-sm font-bold text-zinc-900">R$ 1.200,00</div>
</div>
<div class="mt-2 flex items-center justify-between">
<div class="text-xs text-zinc-500">Comprovantes</div>
<div class="text-sm text-zinc-600">2 anexos</div>
</div>
<div class="mt-4 flex items-center justify-between border-t border-zinc-100 pt-3">
<div class="text-xs text-zinc-500">Visível para</div>
<div class="text-sm text-zinc-600">Responsáveis · Candidato · Coordenador</div>
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-5">
<div class="flex items-center justify-between">
<div class="text-sm font-bold text-zinc-900">Responsáveis pela demanda</div>
<div class="text-xs text-zinc-400">Só quem é responsável vê esta demanda</div>
</div>
<div class="mt-3 flex flex-wrap gap-1.5">
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
>Assessora de campo</span
>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
>Outro assessor</span
>
<span
class="inline-flex h-6 items-center rounded bg-zinc-100 px-2 text-xs text-zinc-500"
>+2</span
>
</div>
<div
class="mt-3 h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-500"
>
Adicionar responsável…
</div>
<div class="mt-2 text-[11px] text-zinc-500">
Criador já entra como responsável automaticamente. Assessores do município aparecem
como sugestão, mas só quem for marcado enxerga.
</div>
</div>
</div>
<div class="mt-4 rounded-md bg-zinc-100 px-4 py-2 text-xs leading-relaxed text-zinc-600">
Assessor do município que não é responsável: a demanda não aparece na lista dele nem
abre por URL — mesmo com permissão "Visão Tudo".
</div>
</div>
</section>
<!-- Cena 2: criação de demanda — campo responsáveis -->
<section data-shot="criacao" class="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<div class="bg-zinc-100 px-8 py-6">
<div class="text-base font-bold text-zinc-900">Nova demanda</div>
<div class="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="space-y-4">
<div>
<div class="text-xs font-bold text-zinc-700">Título</div>
<div
class="mt-1 h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-500"
>
Cartazes para a caminhada do fim de semana
</div>
</div>
<div>
<div class="text-xs font-bold text-zinc-700">Município</div>
<div
class="mt-1 flex h-9 w-full items-center justify-between rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-800"
>
Ilhéus
<span class="text-xs text-zinc-400">▾</span>
</div>
</div>
<div>
<div class="text-xs font-bold text-zinc-700">Tipo</div>
<div
class="mt-1 flex h-9 w-full items-center justify-between rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-800"
>
Material de campanha
<span class="text-xs text-zinc-400">▾</span>
</div>
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4">
<div class="text-xs font-bold text-zinc-700">Responsáveis pela demanda</div>
<div class="mt-2 flex flex-wrap gap-1.5">
<span class="rounded bg-zinc-300 px-2 py-1 text-xs font-bold text-zinc-800"
>Você (criador)</span
>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700"
>Assessora de campo</span
>
</div>
<div
class="mt-3 h-9 w-full rounded-md border border-zinc-300 bg-zinc-50 px-3 text-sm text-zinc-500"
>
Buscar assessor…
</div>
<div class="mt-2 text-[11px] text-zinc-500">
Sugestões: assessores do município. Se ninguém for marcado além de você, só você,
candidato e coordenador verão a demanda.
</div>
</div>
</div>
<div class="mt-5 flex justify-end gap-2">
<div
class="inline-flex h-9 items-center rounded-lg border border-zinc-300 px-4 text-sm font-bold text-zinc-700"
>
Cancelar
</div>
<div
class="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-sm font-extrabold text-white"
>
Criar demanda
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,88 +0,0 @@
# Demandas visíveis apenas para responsáveis explícitos + candidato/coordenador
Status: aguardando execução
Atualizado em: 2026-08-19
Issue: #106
Priority: P1
Model: cursor-grok-4.5-high
Impeccable: B — encaixe no detalhe de demanda (`/campanha/demandas/[slug]`) e na criação
Rascunho UI: docs/plans/demandas-responsaveis-ui-draft.html
Appetite: ~1–1,5 dias eng; campo de responsáveis + regra de visibilidade fail-closed
Responsável: —
## Intenção
Demandas carregam custo estimado e comprovantes (controle interno de gastos). Hoje **qualquer assessor que administra o município da demanda a vê** — e a mesa descobriu que isso é amplo demais: a demanda deve ser vista **só por quem é responsável por ela**, pelo candidato e pelo coordenador geral. Mesmo um assessor do município relacionado, se não estiver marcado como responsável, não vê. Decidido no gate (2026-08-19, opção B): responsável é **vínculo explícito por demanda**.
## Persona e fluxo
- **Persona / contexto:** Alex (Coordenador Geral) e o Candidato acompanhando custos e decisões; assessores responsáveis acompanhando "suas" demandas; assessores do município que NÃO devem esbarrar em demandas que não são deles.
- **Job principal:** quem cria a demanda (ou a coordenação) define os responsáveis; só eles — além de candidato/coordenador — enxergam e tocam a demanda.
- **Fluxo desejado:**
1. Um assessor cria uma demanda no município da carteira: o formulário pede "Responsáveis pela demanda" (criador já vem marcado; sugestões = assessores do município).
2. No detalhe da demanda, quem pode atualizá-la adiciona/remove responsáveis.
3. Um assessor do município que não é responsável: a demanda não aparece na lista dele, não abre por URL e não aparece em buscas — sem erro "sem permissão", simplesmente não existe para ele.
- **Anti-goals de produto:** responsável herdado automaticamente do município (a decisão do gate foi explícita); visibilidade por município como default; auditoria de quem viu (fora de escopo).
### Esboço de fluxo (B/C/D)
```text
[criar demanda] → [campo "Responsáveis" — criador pré-marcado, sugestão do município]
→ [criar] → [criador vira responsável; lista só para responsáveis + candidato/coordenador]
[detalhe] → [responsáveis gerenciam responsáveis]
[assessor não-responsável] → [demanda invisível: lista, URL e busca]
```
### Rascunho UI (B/C/D)
![Rascunho UI — detalhe da demanda com seção Responsáveis](demandas-responsaveis-ui-draft-detalhe.png)
![Rascunho UI — criação com campo Responsáveis](demandas-responsaveis-ui-draft-criacao.png)
## Objetivo e aceite
- A demanda tem um conjunto de **responsáveis** (pessoas da campanha); candidato e coordenador sempre a veem, responsável ou não.
- **Visibilidade fail-closed:** sem responsável marcado além do criador, a demanda é visível só ao criador + candidato/coordenador. Assessor do município relacionado, se não for responsável, **não vê** — nem na lista, nem por URL, nem na busca.
- O **criador** entra automaticamente como responsável (garante pelo menos um dono e que a criação não suma do criador).
- Quem pode **atualizar** a demanda pode gerenciar os responsáveis (mesma regra de visibilidade: responsáveis + candidato/coordenador).
- A criação pede os responsáveis (sugestão = assessores do município da demanda), mas não obriga além do criador.
- Guardrails: leader lockdown intacto (liderança nunca vê demandas); decisão de escalada continua candidato/coordenador; custo/comprovantes seguem a visibilidade da demanda.
## Dados (intenção)
- **Vou apresentar dados?** Não — regra de visibilidade e controle de acesso.
- **Decisões desbloqueadas:** coordenação decide quem acompanha cada demanda.
## Direção no codebase (hipótese)
- **Áreas prováveis:** campo de responsáveis na demanda em `src/collections/CampaignDemand.ts`; regra de leitura/atualização em `src/utilities/access/demands.ts` (hoje `canReadCampaignDemand` = escopo do município — passa a ser "unrestricted ou responsável"); server actions `src/app/(campaign)/campanha/actions/demand.ts`; UI do detalhe `src/components/campaign/demand/` e do fluxo de criação (registrar pedido).
- **Precedente a olhar:** o fragmento de escopo P3-D (não re-escrever o `{ municipality: { in: ids } }` por aí — a regra nova substitui o escopo municipal de demandas); `getFreshCampaignUser`/`memoizePerRequest` para a leitura por request; o padrão de "membership" de `municipality.advisors` para o controle de responsáveis.
- **Risco de acoplamento:** atividades geram demandas (C90) e a lista/omnibox de demandas filtra por escopo — a regra nova muda o que assessores veem em todos os pontos; o Sollinha usa as mesmas queries de demanda (escopo por user).
## Dependências
- Nenhuma dura. **Guardrail:** C141 (perfis por assessor) nunca contorna esta regra — Visão "Tudo" não abre demandas fora da responsabilidade.
## Fora de escopo
- Perfil de permissão por assessor (C141) e apresentação por perfil (C142).
- Notificação/feed ao ser marcado responsável.
- Auditoria de leituras da demanda.
## Rabbit holes de produto
- **"Responsável = assessor do município, simples."** Isso é exatamente o modelo de hoje, que o gate rejeitou. **Corte:** vínculo explícito; sugestões sim, herança automática não.
- **"Vou usar o histórico de quem criou."** `createdBy` existe, mas só o criador não cobre quem vai executar a demanda. **Corte:** campo próprio de responsáveis; criador vira um responsável, não o único.
- **"Vou esconder no cliente."** Esconder na UI não segura URL direta nem a API. **Corte:** regra no acesso de dados (fail-closed) — lista, URL e busca respeitam o mesmo where.
## Questões em aberto (produto)
Fechadas no gate. Assunções operacionais (sem pergunta nova): criador entra automaticamente como responsável; quem atualiza a demanda gerencia responsáveis; sugestões do campo = assessores do município da demanda; visível para = "Responsáveis · Candidato · Coordenador" (rótulo exibido no detalhe). _Assumidas — validar se contrariarem a mesa._
## Referências
- Decisão do gate 2026-08-19 (resposta 1): responsáveis explícitos; assessor do município não-responsável não vê.
- Item irmão: `docs/plans/permissao-granular-assessores.md` (C141).
- Rascunho UI (gate): `docs/plans/demandas-responsaveis-ui-draft.html` + PNGs acima.
- Para abrir primeiro: `src/utilities/access/demands.ts`, `src/collections/CampaignDemand.ts`, `docs/plans/demandas-campanha.md`.
- `AGENTS.md` — convenção P3-D do escopo de assessor.

View file

@ -1,60 +0,0 @@
# Escala/DRY pós-S9 — integridade e alinhamento das ações públicas
Status: registrado
Atualizado em: 2026-08-19
Issue: a registrar (depends: #93)
Priority: P2
Model: composer-2.5
Appetite: ~0,5–1 dia eng (3 fases pequenas, lock primeiro)
## Fase 1 — Deletar `Consent` em uso falha com erro cru (expensive_lock)
**Sintoma:** deletar um consentimento referenciado por `subscription` (ou
`signature`/`petition`) estoura `23502` no Postgres: a FK é `ON DELETE SET
NULL` mas a coluna `subscription.consent_id` é `NOT NULL` — o gatilho tenta
setar NULL e viola a constraint; no admin o usuário vê um 500 sem explicação.
**Por que existe:** `required: true` no campo `consent` (migration inicial).
O comportamento "não deletar consentimento em uso" é **protetivo** (texto
legal versionado) — o problema é só a superfície: erro cru, sem orientação.
**Decisão de implementação (com produto/jurídico):** mensagem clara no admin
("Consentimento em uso por N assinaturas/inscrições — não é possível
excluir") vs. semântica de arquivamento. Onde: `beforeDelete` hook do
`Consent` (contar referências; lançar `APIError` amigável) ou validação
explícita no delete operation. Descoberto na sessão S9 (também travou os
lifecycles de teste — as purges de `campanha-novidades` precisam deletar
subscriptions antes).
## Fase 2 — `submitWhatsapp`/`submitPetitionSignature` → `withPayloadTransaction`
As duas actions públicas irmãs usam o hand-roll (begin/try/commit/rollback)
que o `submitCampaignNewsletter` (S9) já substituiu pelo dono do concern
(`src/utilities/payloadTransaction.ts`). O helper entrega rollback também
quando o **commit** falha, `AggregateError` quando rollback+erro falham juntos
e o registro after-commit (`onPayloadTransactionCommit`) — a costura pronta
para S10 disparar `Lead` e para notificações pós-write. Sem mudança de
contrato: mesmos creates, mesmo retorno `{ ok: true }`.
## Fase 3 — Consolidar os schemas de estado/cidade em `contact.ts`
O S9 exportou `optionalContactStateSchema` e `contactCityFieldSchema`, mas as
4 fichas do próprio `contact.ts` (`contactSchema`, `contactCreateSchema`,
`contactFieldUpdateSchema`, `contactFullUpdateSchema`) mantêm cópias inline do
predicate `value in CitiesByState` e da chain de cidade (mesma mensagem,
mesmo comportamento). Trocar as cópias pelos exports únicos — DRY de
conhecimento no próprio dono.
## Já resolvido no simplify/critique (não reabrir)
- e2e do path de recusa (consent ausente) — cortado de propósito: a action
fail-closed devolve 500 e o `e2eFailureGuard` trata 5xx como falha; o int
spec cobre com a mensagem exata + nada gravado.
- `contactStateSchema` privado (knip) — o export só teria 1 consumidor.
## Explicitamente fora (defers com gatilho)
- Refatorar o hand-roll das actions irmãs ANTES de S9 existir — já feito na
action nova; o débito é só das irmãs (Fase 2).
- Gatilho: quando S10 precisar do `onPayloadTransactionCommit` para `Lead`, a
Fase 2 vira requisito (não só limpeza).

View file

@ -1,62 +0,0 @@
# Escala pós-C141: options write-scoped para formulários de escrita
Status: rascunho
Atualizado em: 2026-08-19
Issue: C144 (a criar)
Priority: P2
Model: deepseek/deepseek-v4-flash
Impeccable: A — backend de dados de formulário (o C142 cuida da apresentação)
Appetite: ~0,5–1 dia eng
## Contexto (achado do /simplify da entrega C141)
`loadMunicipalityOptions` (`src/utilities/campaignRelationOptions.ts`) resolve os
municípios oferecidos nos formulários de escrita com o escopo de LEITURA
(`overrideAccess: false` + access de `canReadMunicipality`). Desde o C141, um
assessor com **Visão "Tudo" + Edição "Carteira"** vê os 435 municípios nos
pickers de atividade/demanda/apoiador/liderança, mas o servidor rejeita o
submit fora da carteira (fail-closed, mensagem genérica) — e em **atividade**
o servidor hoje **aceita** (o create individual foi corrigido no C141, mas o
formulário continua oferecendo o estado inteiro). A apresentação por perfil é
o item irmão C142; este item entrega a base de dados correta para os
formulários de escrita.
## Objetivo
- Os pickers de município dos **formulários de escrita** oferecem apenas
municípios no escopo de **escrita** do ator (`getWritableMunicipalityIds`):
unrestricted/tudo → todos; carteira → carteira; somente leitura → nenhum
(formulários de escrita nem abrem para esse assessor).
- Superfícies alvo: atividade (create/update + giro), demanda (criar),
apoiador (novo), liderança (criar/editar), organização (vínculos).
- Leitura (listas/detalhes/mapa) continua read-scoped — não tocar.
- Sem mudança de enforcement (C141 já fail-closed no servidor).
## Direção no codebase
- `src/utilities/campaignRelationOptions.ts` (`loadMunicipalityOptions`) —
variante write-scoped (parametro `scope: 'read' | 'write'` ou helper irmão)
consumida pelos form actions/pages de escrita; manter o fragmento P3-D
(`advisorMunicipalityScopeWhere`) e o `getWritableMunicipalityIds` do C141.
- Mapear os call sites dos pickers (atividades/agenda, demandas, apoiadores,
lideranças, organizações) e trocar só os de escrita.
## Fases
1. Variante write-scoped + call sites de atividade (onde o servidor aceita) — tester int: picker de atividade reflete o eixo.
2. Demanda/apoiador/liderança/organização (UX — servidor já rejeita).
3. Gates (`pnpm push`); e2e no CI.
## Já resolvido no simplify/critique (não reabrir)
- Criação de atividade avulsa/giro fora do escopo: server-side no C141
(`getWritableMunicipalityIds` no create individual e no batch do giro).
- Ficha de contatos write-scoped (`assertPersonContactWritable`).
- Ações de liderança write-scoped (`assertMunicipalitiesWithinScope`).
- `allocationDecision` create capado na carteira.
## Explicitamente fora (deste lote)
- Apresentação por perfil (botões/controles/atalhos) — C142.
- Enforcement server-side — C141 (já entregue).
- Options de leitura (listas/detalhes) — read-scoped permanece.

View file

@ -1,123 +0,0 @@
# Impl: Feed do Instagram configurado não aparece na home pública
Status: aprovado
Atualizado em: 2026-08-19
Issue: #115
Intenção: docs/plans/feed-instagram-nao-aparece-home.md
Appetite restante: ~1 dia eng
## Leitura da intenção
- **Outcome:** a assessoria vê, na própria global "Feed de redes sociais", o estado da sincronização do Instagram — "Sincronizado · há X min · N posts", "Falha na última sincronização" com o motivo em linguagem de produto e a correção esperada, ou "Instagram ainda não configurado" — e tem um botão "Tentar sincronizar de novo" para feedback imediato. O board público permanece fail-closed como hoje (artigos + YouTube quando o IG falha); a home pública não muda em nada.
- **O que NÃO negociar:** painel de status por plataforma na própria global, só estado atual + motivo (sem histórico, sem alertas, sem dashboard); sem wizard OAuth; sem embeds no site público; token nunca exposto fora do admin (`read` admin-only preservado); copy em pt; identificadores em inglês; a home pública nunca quebra nem vaza erro.
- **O que reavaliar:** a hipótese da intenção apontava `InstagramPostExclusionPicker` como superfície do status — o picker é a lista de exclusão (S3) e **fica intacto**; o painel de status é um **irmão** (`InstagramSyncStatusPanel`), mesma família de componente `admin.components`, mesmo padrão do rascunho (caixa de status separada da lista). `revalidateRequest.ts` realmente não muda (a tag `social-feed` já cobre). O diagnóstico da ocorrência em produção é impossível deste worktree (sem acesso ao DB de prod — `PROD_DATABASE_URL` não existe localmente): o próprio painel vira o instrumento de diagnóstico (1 clique após o deploy confirma a causa), e o runbook documenta validação/geração de token.
## Abordagem recomendada
```mermaid
flowchart LR
A[Global SocialFeedSettings<br/>+ instagramSyncStatus jsonb] --> H[afterChange<br/>revalidate + sync se credencial mudou]
H --> S[syncInstagramFeed<br/>load + snapshot + status]
B[Botão Tentar sincronizar] --> R[POST /api/social-feed/sync<br/>auth admin + same-origin]
R --> S
C[Render home<br/>getInstagramFeed · unstable_cache] -->|sucesso| P[persist snapshot + status<br/>SQL cru, sem hooks]
C -->|falha| Q[persist status erro +<br/>fallback snapshot]
S --> P
S --> Q
P --> G[(social_feed_settings)]
Q --> G
G --> PANEL[InstagramSyncStatusPanel<br/>ui field · 5 estados]
```
**Opções consideradas:** A | B | C
**Recomendação:** A — estado da sincronização **persistido** numa coluna `jsonb` da própria global (escrito por render, hook e botão, todos pelo mesmo módulo `instagramSync.ts`); painel admin como campo `ui` irmão do picker; rota `POST /api/social-feed/sync` (auth de sessão admin + `isSameOriginRequest`) para o botão; erro da API mapeado para copy de produto (`InstagramApiError` + `describeInstagramError`). O caminho de render ganha apenas a persistência do status dentro do try/catch existente — zero mudança visual na home, fail-closed intacto.
**Rejeitadas:** B) estado derivado em runtime (o painel admin não renderiza a home — sem estado não há como mostrar nada; e a intenção exige o estado da **última** sincronização, que precisa sobreviver a restart) | C) server action no botão (primeiro uso sem precedente no shell do admin do Payload; rota HTTP é previsível, testável e segue o precedente de `/api/revalidate`).
### Decisões de engenharia
1. **Estado persistido como `instagramSyncStatus` (jsonb, campo hidden) — uma coluna, shape `{ lastSyncAt?, postCount?, error?, errorAt? }`.**
Opções: A) uma coluna jsonb (padrão do `instagramFeedSnapshot`) | B) 3 colunas (`lastSyncAt`, `postCount`, `lastError`) | C) estado em memória/cache.
Recomendação: A — o snapshot já é jsonb escrito por SQL cru; uma coluna nova espelha o padrão; o shape é autodescritivo e muda sem migration futura. `lastSyncAt`/`postCount` são da última sincronização **bem-sucedida**; `error`/`errorAt` da última **falha** (falha após sucesso preserva os campos de sucesso? **não** — o painel em falha mostra só o erro, cena 1 do rascunho; overwrite é mais simples e sem leitura-modificação-escrita).
Rejeitadas: B 3 colunas = 3 DDLs para o mesmo dado; C não sobrevive a restart e o painel admin não compartilha o render.
→ **Migration:** `pnpm migrate:create add_instagram_sync_status` (ADD COLUMN jsonb nullable, não destrutiva; revisar o DDL gerado — precedente S2/S3 de drift em migrações hand-written).
2. **`InstagramApiError` (status + apiMessage) no `loadInstagramFeed` + `describeInstagramError` para copy de produto.**
Opções: A) o fetch parseia o body de erro da Graph API (`{ error: { message, type } }`) e lança erro tipado; `describeInstagramError` mapeia para pt-BR com a correção | B) mensagem genérica "falhou com status N" (status quo) | C) mapa estático por status.
Recomendação: A — o produto exige o "porquê" (token recusado vs ID inválido vs rede); o body da Graph API distingue OAuthException (token inválido/expirado/emitido via Facebook Login — copy do draft) de "Invalid user id"; status 5xx = API indisponível; `TypeError` de fetch = rede. O mesmo `describeInstagramError` serve render, hook e rota (uma spelling).
Rejeitadas: B é o bug atual (assessoria cega); C não lê o body e não cobre casos reais.
- Mapeamentos: `400|401|403` + OAuthException/`expired|invalid|revoked` → "O token do Instagram foi recusado pela Graph API — está inválido/expirado ou foi emitido via Facebook Login (a Graph API só aceita tokens gerados pelo Instagram Login). Gere um novo token de longa duração pelo Instagram Login e atualize o campo acima."; `400` + menção a user/id → "O ID do usuário não foi reconhecido pela Graph API. Confira se é o ID numérico da conta Business/Creator."; `5xx`/outros → "A Graph API está indisponível no momento (status N). Tente novamente em alguns minutos."; `TypeError` de fetch → "Não foi possível falar com a API do Instagram (rede indisponível)."; desconhecido → "Erro inesperado ao sincronizar o Instagram."
3. **Rota `POST /api/social-feed/sync` para o botão — auth de sessão admin + CSRF same-origin.**
Opções: A) route handler em `(frontend)/api/social-feed/sync` com `payload.auth({ headers })` exigindo `collection === 'users'` + `isSameOriginRequest` (helper já existente) | B) server action | C) rota sem auth (confia no botão).
Recomendação: A — o precedente `sameOriginRequest.ts` é literalmente para "cookie-authenticated JSON route handlers"; `payload.auth` lê o cookie `payload-token` da sessão admin (precedente `campaignAuth`); a rota vive ao lado de `/api/revalidate` (grupo `(frontend)` — route handlers não passam por layouts; o URL é `/api/social-feed/sync`). Retorna `{ ok, status }`; status `null`/400 quando IG não configurado (o botão nem renderiza nesse estado — defesa).
Rejeitadas: B sem precedente no shell admin (risco de incompatibilidade com o RSC do Payload, custo de spike); C permite abuso do rate da Graph API.
→ Depois do sync, a rota chama `revalidateTag('social-feed')` (fora de cache — permitido).
4. **Sync no `afterChange` da global quando a credencial IG muda (awaited, bounded, à prova de erro).**
Opções: A) hook `afterChange` [revalidateFeed, syncInstagramAfterChange] — sync **awaited** quando `doc` ≠ `previousDoc` em `instagramAccessToken`/`instagramUserId`/`instagramEnabled` (false→true); `AbortSignal.timeout(10_000)` no fetch; try/catch total — erro de sync nunca quebra o save | B) sync em todo save | C) sem hook — só botão e render.
Recomendação: A — o fluxo da intenção é "preenche token + ID e salva → o admin mostra o estado": com o sync awaited, o reload da global após o salvar já mostra o resultado **com as credenciais novas** (o estado antigo mostraria a falha da credencial antiga — confuso e o problema do status quo). Gate por mudança: save de exclusões/YouTube não gasta chamada de API nem adiciona latência. Timeout: Graph API responde em <1s mesmo em erro; 10s cap em API pendurada.
Rejeitadas: B latência e custo em todo save sem ganho; C deixa o fluxo principal da intenção em dois cliques e o estado inicialmente mentiroso.
→ O hook usa o MESMO `syncInstagramFeed` da rota (uma implementação, dois call sites).
5. **Painel `InstagramSyncStatusPanel` (campo `ui`, irmão do picker) — 5 estados.**
Opções: A) componente client próprio lendo os campos do form via `useAllFormFields` + estado local pós-botão | B) estender o picker | C) campo `json` não-hidden renderizado cru.
Recomendação: A — o picker prova o padrão (campo `ui` + componente client + `useAllFormFields`); o painel lê `instagramSyncStatus` (inicial), `instagramAccessToken`/`instagramUserId`/`instagramEnabled` e mantém estado local só para o pós-clique (o status já está persistido no servidor — o form não precisa ser modificado). Estados: (1) `instagramEnabled === false` → "Instagram desativado — os posts não aparecem no board."; (2) sem token ou sem ID → "Instagram ainda não configurado" + explicação (cena 3); (3) `error` → "Falha na última sincronização" + mensagem + botão "Tentar sincronizar de novo" (cena 1); (4) `lastSyncAt` → "Sincronizado · há X min · N posts" + "Próxima atualização automática em ~5 min. Os N posts mais recentes estão no board 'Acompanhe de perto' da home." (cena 2, sem botão — fiel ao rascunho); (5) configurado sem status → "Aguardando a primeira sincronização" + botão. Pós-clique: `pending` ("Sincronizando…") → sucesso cena 2 / falha cena 1 com o motivo retornado. Tempo relativo com `Intl.RelativeTimeFormat('pt-BR')` local (o painel é client; `formatRelativePostDate` é server-only — `posts.ts` abre com `import 'server-only'`).
Rejeitadas: B mistura duas responsabilidades no picker (o picker é lista de exclusão; o status é estado operacional — o rascunho os desenha como caixas separadas); C expõe JSON cru (anti-produto).
→ **UI:** Impeccable C — encaixe na linguagem do admin (caixas zinc/red do draft, `text-sm`, bordas `rounded-lg`), shape → craft → critique → polish no navegador (admin, 3 cenas + estados).
6. **`loadInstagramFeed` ganha `signal?: AbortSignal` (repasse ao `fetchImpl`).**
O hook precisa do deadline; o `LoadInstagramFeedArgs` aceita `init` no fetchImpl — o signal flui sem mudar a assinatura do fetch. Unit-testado.
7. **Stub IG ganha estado `invalid-token` (400 com body `{ error: { type: 'OAuthException', ... } }`).**
A cena 1 do draft é literalmente esse erro; o e2e do painel precisa exercitá-lo (o estado `fail` atual responde 500 genérico). O painel de falha com a copy do token é o aceite central do item.
### Componentes / mudanças
- **`InstagramApiError` + `parseInstagramErrorBody`** (`src/utilities/socialFeed/instagramFeed.ts`, estender): parseia body de erro em resposta não-2xx, lança `InstagramApiError` (`status` + `apiMessage`); `loadInstagramFeed` aceita `signal` opcional repassado ao `fetchImpl` (fetch do usuário, media e refresh).
- **`src/utilities/socialFeed/instagramSync.ts`** (novo, `server-only`, dentro do subfolder `socialFeed/` — a pin de top-level da `codebaseConventions.unit.spec.ts` não se aplica a subfolders, o comentário já cobre o domínio):
- `InstagramSyncStatus` (`{ lastSyncAt?: string; postCount?: number; error?: string; errorAt?: string }`).
- `describeInstagramError(cause: unknown): string` — mapeamentos da Decisão 2 (puro, exportado p/ unit).
- `successInstagramSyncStatus(postCount)` / `failedInstagramSyncStatus(error)` — builders puros.
- `persistInstagramSyncStatus(payload, status)` — SQL cru UPDATE+INSERT (mesmo padrão/precondição do snapshot; rodado DENTRO de `unstable_cache` no render — sem hooks).
- `syncInstagramFeed(payload, { signal? }): Promise<{ ok: true; status } | { ok: false; status }>` — lê a global, retorna "não configurado" sem tocar na API quando `enabled === false`/`instagramEnabled === false`/sem token/ID; sucesso → `persistInstagramSnapshot` + persist token renovado (reuso dos privados? **não** — os `persist*` atuais são module-private do `instagramFeed.ts`; exportar `persistInstagramSnapshot`/`persistInstagramAccessToken` do `instagramFeed.ts` para o sync module — o dono do persist é o feed, o sync é o orquestrador) + status de sucesso; falha → status de erro via `describeInstagramError`; nunca lança (status é o resultado).
- **`getInstagramFeed`** (`instagramFeed.ts`, estender): no try (sucesso) persiste também o status de sucesso; no catch persiste o status de erro ANTES do fallback snapshot (a ordem não importa para o board; importa para o painel). Nada mais muda no caminho público.
- **`SocialFeedSettings`** (`src/globals/SocialFeedSettings.ts`, estender): campo `instagramSyncStatus` (json, `admin.hidden: true` — o painel renderiza, o dado não aparece cru); campo `ui` `instagramSyncStatusPanel` (após `instagramEnabled`, antes do token — status no topo do bloco IG, fiel ao rascunho; `admin.components.Field` → painel); `afterChange` = `[revalidateFeed, syncInstagramAfterChange]` (gate da Decisão 4, try/catch total). Copy do campo token/`admin.description`: ajuste de 1 linha para apontar o painel de status ("veja o estado da sincronização abaixo/ao lado") — opcional, não bloquear.
- **`src/app/(frontend)/api/social-feed/sync/route.ts`** (novo): `POST`; `isSameOriginRequest` (401 se cross-origin); `payload.auth({ headers })` exigindo `user.collection === 'users'` (401 sem sessão admin); lê a global, IG desconfigurado → `400 { ok: false, error }`; `syncInstagramFeed` com `AbortSignal.timeout(10_000)`; `revalidateTag(REVALIDATE_SOCIAL_FEED_TAG)`; `200 { ok, status }`. Erro interno → `500` (sem stack).
- **`src/components/admin/InstagramSyncStatusPanel.tsx`** (novo, `'use client'`): estados da Decisão 5; fetch para a rota no clique; `aria-busy` no pending; sem escrita no form (status é persistido no servidor).
- **`tests/e2e/instagram-stub.mjs`** (estender): estado `'invalid-token'` → `400 { error: { type: 'OAuthException', message: 'Error validating access token' } }` em `/user` e `/media` (e refresh → 400); `POST /__stub/state` aceita o novo estado.
- **Migration:** `add_instagram_sync_status` (jsonb nullable). **Types/importmap:** `pnpm generate:types` + `pnpm generate:importmap` (com envs `S3_*` dummy setadas — OPS69). **Access/Consent:** sem consent (estado operacional interno, não PII); global segue admin-only; a rota exige sessão admin. **UI:** Impeccable C (encaixe no admin; shape → craft → critique → polish).
### Dados → forma
- O painel mostra **estado operacional**, não métrica: contagem de posts sincronizados (cru, da API), tempo desde a última boa, motivo da última falha. Nada de histórico, alertas ou engajamento (corte da intenção). Forma: caixas do rascunho (verde/zinc neutro sucesso, red falha, zinc neutro não-configurado) — contraste e semântica visuais primeiro, sem cor inventada.
## Fases verificáveis
1. **Schema + servidor** (~metade do appetite): migration + `generate:types`; `InstagramApiError` + parse do body + `signal` no `loadInstagramFeed`; `instagramSync.ts` (status builders + `describeInstagramError` + persist SQL + `syncInstagramFeed`); persistência de status no `getInstagramFeed`; rota `/api/social-feed/sync`. Unit tests: `describeInstagramError` (OAuthException→token, invalid-user→ID, 5xx→indisponível, TypeError→rede, unknown→inesperado), `loadInstagramFeed` com fetchImpl 400+body de erro lançando `InstagramApiError`, repasse de `signal`, builders de status, gate `instagramCredentialsChanged` (doc/previousDoc: token mudou, ID mudou, enabled false→true, exclusão não dispara). Gates parciais: tsc + `pnpm test`.
2. **Painel + hook + runbook** (restante): `InstagramSyncStatusPanel` + campo `ui` + hook `afterChange` com sync awaited; stub `invalid-token`; runbook `docs/ops/instagram-feed-token-runbook.md` (o que o painel mostra; validação do token via refresh/me com curl; geração pelo Instagram Login vs Facebook Login; a provável causa da ocorrência atual e como o painel confirma em 1 clique pós-deploy); alinhar a copy do vazio do picker com o rascunho ("Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts aparecem aqui…"); `generate:importmap` + AGENTS.md (1 linha: painel de status + rota de sync na seção do `social-feed`). Polish no navegador (admin, 3 cenas).
3. **e2e + gates finais**: admin smoke — seed com token+ID → painel "Aguardando a primeira sincronização" → clique → "Sincronizado" com contagem + assert do `instagramSyncStatus` via REST; stub `invalid-token` → falha com copy do token + botão; stub `fail` → falha 500; reset do helper `resetSocialFeedSettings` ganha `instagramSyncStatus: null` (determinismo do serial); smoke do picker existente deve continuar verde (o hook passa a sincronizar no seed com credenciais — stub ok, determinístico). Gates: `pnpm gate:fast`, `pnpm test`, `pnpm build` local, e2e no CI; changelog `docs/changelog/2026-08-19-s11.md` + `pnpm changelog:build`.
## Rabbit holes / Não escopo (engenharia)
- Não sincronizar no salvar de exclusões/YouTube (gate por credencial — Decisão 4).
- Não fazer histórico/alerta/dashboard de saúde; não tocar no board público nem nos cards.
- Não mexer no fluxo de refresh de token existente (o `loadInstagramFeed` já refresca e persiste; o sync só reporta).
- Não paginar o feed, não buscar stories/métricas.
- Não backfill de status retroativo (a primeira sincronização após o deploy escreve o estado).
- Não acesso ao DB de prod para o diagnóstico (fora do alcance do worktree); o painel + runbook cobrem o aceite.
- Não mudar a pin de `codebaseConventions` (o módulo novo fica no subfolder `socialFeed/` já documentado).
## Riscos e mitigação
- **Rota auth no admin:** `payload.auth({ headers })` com cookie `payload-token` — precedente `campaignAuth`; sem sessão → 401 silencioso (o painel mostra o erro como falha de sincronização? **não** — o painel trata 401 como "sessão expirada": recarrega a página para o admin re-autenticar; copy honesta). e2e cobre.
- **Hook sync com latência no save:** cap `AbortSignal.timeout(10_000)`; sync nunca lança (try/catch); save de credenciais é raro (assessoria configura uma vez).
- **`revalidateTag` dentro de cache:** a persistência de status no render usa SQL cru (sem hooks) — mesma restrição documentada do snapshot (Next 15.4 lança `revalidateTag` dentro de `unstable_cache`); a rota/hook revalidam fora do cache (permitido).
- **e2e serial com hook novo:** todo seed de credenciais via REST dispara o sync (stub determinístico, ok/fail por estado); resets incluem `instagramSyncStatus: null`; orçamento de polling do serial mantido.
- **Formato do body de erro da Graph API:** documentado (`{ error: { message, type } }`); parse defensivo — body inesperado → mensagem genérica por status (nunca quebra o fluxo).
- **Painel com estado stale após save:** o sync awaited no hook resolve (status escrito antes do reload); botão cobre o resto; "há X min" é honesto (última boa real).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: painel de status na global (sincronizado/falha com motivo+não configurado), botão "Tentar sincronizar de novo", home pública inalterada e fail-closed, runbook curto com validação/geração de token, token nunca fora do admin
- [ ] Invariantes AGENTS/engineering-standards: migration não destrutiva; sem consent novo; identificadores em inglês; copy/admin em pt; access admin-only preservado; rota com auth de sessão + CSRF same-origin; tag `social-feed` já cobre revalidação; SQL cru fora de cache
- [ ] Testes de domínio previstos: unit (mapeamentos de erro, `InstagramApiError`, signal, builders, gate do hook) + e2e (painel ok/falha token/falha 500, status persistido via REST, serial determinístico)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View file

@ -1,315 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>UI draft — Feed do Instagram configurado não aparece na home</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-6xl p-8">
<p class="text-sm text-zinc-400">
Assessoria de comunicação configura o feed do Instagram no admin (global "Feed de redes
sociais") e precisa ver, no mesmo lugar, por que os posts não aparecem na home pública.
</p>
<!-- Cena: falha na sincronização (o caso real em produção) -->
<section data-shot="admin-falha-token" class="mt-10">
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 1 — token recusado pela Graph API</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<p class="mt-1 max-w-2xl text-sm text-zinc-600">
Fonte automática do board "Acompanhe de perto" da home de campanha. Sem chave
configurada, os cards da plataforma não aparecem.
</p>
<div class="mt-5 rounded-lg border border-red-300 bg-red-50 p-4">
<p class="text-sm font-bold text-red-800">Falha na última sincronização</p>
<p class="mt-1 max-w-2xl text-sm text-red-700">
A Graph API recusou o token configurado (token inválido/expirado ou emitido via
Facebook Login — a Graph API só aceita tokens gerados pelo Instagram Login).
Enquanto isso, nenhum post do Instagram aparece na home.
</p>
<p class="mt-1 max-w-2xl text-sm text-red-700">
<strong>Correção:</strong> gere um token de longa duração pelo Instagram Login
(conta Business/Creator vinculada à página) e atualize o campo abaixo.
</p>
<button
class="mt-3 rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
>
Tentar sincronizar de novo
</button>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
IGAA...abc123
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
17841400000000000
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
<div
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
3
</div>
</div>
</div>
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
<p class="mt-1 text-sm text-zinc-500">
Nenhum post sincronizado. Após uma sincronização bem-sucedida, os últimos posts
aparecem aqui para marcar os que não devem ir para a home.
</p>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
<!-- Cena: sincronizado, picker com posts -->
<section data-shot="admin-ok" class="mt-12">
<p class="mb-3 text-sm font-medium text-zinc-300">Cena 2 — sincronizado com sucesso</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<div class="mt-5 rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Sincronizado · há 2 min · 4 posts</p>
<p class="mt-1 max-w-2xl text-sm text-zinc-500">
Próxima atualização automática em ~5 min. Os 4 posts mais recentes estão no board
"Acompanhe de perto" da home.
</p>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
IGAA...abc123
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
17841400000000000
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700">Máximo de posts</label>
<div
class="mt-1 w-24 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-700"
>
3
</div>
</div>
</div>
<div class="mt-6 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram — posts recentes</p>
<p class="mt-1 text-sm text-zinc-500">
Marque "Não exibir" para tirar um post do board (ex.: posts de grade). O board pula
para o próximo elegível.
</p>
<ul class="mt-3 divide-y divide-zinc-200">
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Caravana pelo interior: chegada em Vitória da Conquista
</span>
<span class="block text-xs text-zinc-500"
>há 2 h · ID 17841400000000000_001</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Foto do plenário: votação da pauta da saúde
</span>
<span class="block text-xs text-zinc-500"
>ontem · ID 17841400000000000_002</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Post de grade (sem legenda) — para marcar "Não exibir"
</span>
<span class="block text-xs text-zinc-500"
>ontem · ID 17841400000000000_003</span
>
</span>
<span class="rounded bg-red-100 px-2 py-1 text-xs font-semibold text-red-700">
Não exibir
</span>
</li>
<li class="flex items-center gap-3 py-2.5">
<span class="h-10 w-10 shrink-0 rounded bg-zinc-300"></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-zinc-800">
Agenda da semana: agendas abertas em Salvador
</span>
<span class="block text-xs text-zinc-500"
>há 2 dias · ID 17841400000000000_004</span
>
</span>
<span class="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold text-zinc-700">
Exibir
</span>
</li>
</ul>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
<!-- Cena: ainda não configurado (primeiro acesso) -->
<section data-shot="admin-nao-configurado" class="mt-12">
<p class="mb-3 text-sm font-medium text-zinc-300">
Cena 3 — sem token nem ID (primeiro acesso)
</p>
<div class="rounded-xl border border-zinc-700 bg-zinc-100 text-zinc-900">
<div class="flex items-center justify-between border-b border-zinc-300 px-6 py-4">
<div class="flex items-center gap-2">
<span class="rounded bg-zinc-900 px-2 py-0.5 text-xs font-semibold text-zinc-100">
Configurações
</span>
<span class="text-sm text-zinc-600">/ Feed de redes sociais</span>
</div>
<span class="rounded border border-zinc-400 bg-white px-3 py-1 text-xs text-zinc-600">
Draft · Salvar · Cancelar
</span>
</div>
<div class="px-6 py-5">
<h1 class="text-lg font-bold">Feed de redes sociais</h1>
<div class="mt-5 max-w-2xl rounded-lg border border-zinc-300 bg-white p-4">
<p class="text-sm font-bold text-zinc-800">Instagram ainda não configurado</p>
<p class="mt-1 text-sm text-zinc-500">
Informe o token de acesso e o ID da conta para publicar posts do Instagram na home.
Sem configuração, o board funciona com artigos e YouTube.
</p>
</div>
<div class="mt-6 grid max-w-2xl grid-cols-1 gap-4">
<div>
<label class="text-xs font-semibold text-zinc-700"
>Token de acesso (Instagram Graph API)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
>
(vazio)
</div>
</div>
<div>
<label class="text-xs font-semibold text-zinc-700"
>ID do usuário (conta Business/Creator)</label
>
<div
class="mt-1 rounded-md border border-zinc-400 bg-white px-3 py-2 text-sm text-zinc-400"
>
(vazio)
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2 border-t border-zinc-300 pt-4">
<button
class="rounded-md border border-zinc-400 bg-white px-4 py-1.5 text-sm font-semibold text-zinc-700"
>
Cancelar
</button>
<button class="rounded-md bg-red-600 px-4 py-1.5 text-sm font-semibold text-white">
Salvar
</button>
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -1,93 +0,0 @@
# Feed do Instagram configurado não aparece na home pública
Status: aprovado
Atualizado em: 2026-08-19
Issue: #115
Priority: P1
Model: cursor-grok-4.5-medium
Impeccable: C — encaixe na global do admin (painel de status de sincronização)
Rascunho UI: docs/plans/feed-instagram-nao-aparece-home-ui-draft.html + PNGs embutidos abaixo
Appetite: ~1 dia eng + diagnóstico da causa em produção
Responsável: —
## Intenção
A assessoria preencheu o token e o ID do Instagram na configuração do feed (global "Feed de redes sociais", entregue na S3) e os posts **não aparecem** na seção "Acompanhe de perto" da home pública — em produção, o board mostra YouTube e artigos, nenhum card do Instagram e nenhum link "Seguir no Instagram →" (verificado em 2026-08-19 em jorgesolla1313.com.br). O desenho da S3 é fail-closed **silencioso**: qualquer falha da API (token recusado, ID inválido, rede) degrada para "sem cards" sem deixar rastro — o único indício hoje é o picker do admin vazio ("Nenhum post recente"), que não diz **por quê**. A assessoria fica num ciclo cego de tentativa-e-erro de credenciais.
## Persona e fluxo
- **Persona / contexto:** assessoria de comunicação, configurando a global no admin do Payload (grupo "Configurações"), sem acesso ao código nem a logs.
- **Job principal:** saber, no mesmo lugar onde configurou o feed, se a sincronização do Instagram funcionou — e, se não, **por quê** e **o que fazer**.
- **Fluxo desejado:**
1. A assessoria preenche token + ID e salva a global.
2. O admin mostra o estado da sincronização: "Sincronizado · há X min · N posts" (e os posts aparecem no picker, prontos para exclusão), ou "Falha na última sincronização" com o motivo e a correção esperada (ex.: token inválido/expirado ou emitido via Facebook Login — gerar pelo Instagram Login).
3. Com sincronização OK, os posts aparecem na home pública em minutos (cache revalidado no salvar).
4. A causa da ocorrência atual fica diagnosticada e documentada num runbook curto para a assessoria não repetir o ciclo.
- **Anti-goals de produto:** nada de wizard OAuth "conectar com Facebook" dentro do admin; nada de dashboard de saúde de feeds; nada de embeds/scripts do Instagram no site público; nada de métricas de engajamento.
### Esboço de fluxo (B/C/D)
```text
[admin: global Feed de redes sociais]
→ salvar token+ID → [status: Sincronizado · N posts] → [home pública: cards IG em minutos]
→ [status: Falha + motivo + correção] → [assessoria corrige credencial e salva] → [status OK]
```
### Rascunho UI (B/C/D)
Painel de status de sincronização por plataforma na global do admin, embutido junto aos campos do Instagram (e reaproveitável para YouTube depois, se a paridade for pedida).
![Rascunho UI — admin, falha de token](feed-instagram-nao-aparece-home-ui-draft-admin-falha-token.png)
![Rascunho UI — admin, sincronizado com picker](feed-instagram-nao-aparece-home-ui-draft-admin-ok.png)
![Rascunho UI — admin, não configurado](feed-instagram-nao-aparece-home-ui-draft-admin-nao-configurado.png)
## Objetivo e aceite
- Com credenciais válidas (token gerado pelo Instagram Login + ID da conta Business/Creator), os posts do Instagram aparecem na seção "Acompanhe de perto" da home em até alguns minutos após salvar a configuração — badge "Instagram", legenda, data relativa, clique abre na plataforma. Sem ação manual além da config.
- Quando a Graph API recusa as credenciais ou a sincronização falha, o admin mostra o motivo em linguagem de produto e a correção esperada (ex.: "token inválido/expirado ou emitido via Facebook Login — gere pelo Instagram Login"). A assessoria deixa de ficar cega.
- A home pública continua fail-closed: API fora ou credenciais inválidas nunca quebram a página nem vazam erro — o board segue com artigos + YouTube (comportamento atual preservado).
- A causa da ocorrência em produção fica diagnosticada e registrada em runbook curto (passos para gerar/validar o token), para o ciclo de tentativa-e-erro não se repetir.
- Guardrails: token nunca exposto fora do admin (read da global admin-only, como hoje); zero embeds/scripts do Instagram no site público (LCP); identificadores em inglês, copy em pt.
## Dados (intenção)
- **Vou apresentar dados?** Não — o painel mostra estado operacional (última sincronização, nº de posts, motivo de erro), não métricas. Nenhuma decisão de análise desbloqueada.
- **Forma:** adiada ao plano de implementação — sem contadores, sem engajamento.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/utilities/socialFeed/instagramFeed.ts` (onde o erro é engolido no fail-closed — fonte do "porquê"), `src/globals/SocialFeedSettings.ts` (superfície do status), `src/components/admin/InstagramPostExclusionPicker.tsx` (pickers/superfície admin já existente, mesma família de componente), `src/utilities/revalidateRequest.ts` (tag `social-feed` — o salvar já revalida; não espera-se tocar).
- **Precedente a olhar:** plano/impl da S3 (`docs/plans/secao-conteudos-home-instagram*.md`), irmão `youtubeFeed.ts`, e2e do describe serial da seção (stub IG) e smoke do picker no admin.
- **Risco de acoplamento:** o fetch roda dentro de `unstable_cache` durante render — qualquer persistência de status precisa respeitar a restrição de não chamar `revalidateTag` dentro de cache (precedente documentado da S2/S3). A home pública é ISR; revalidação via tag já cobre o salvar do admin.
## Dependências
- **S3** (pai, `done`/`in-prod` — imutável; este é item **sucessor**).
## Fora de escopo
- Setup OAuth / "conectar com Facebook" dentro do admin (o fluxo Meta fica com a assessoria; runbook documenta).
- Redesenho do board público ou dos cards (superfície aprovada na S3 não muda).
- Paridade de status para o YouTube (pode virar followup se a plataforma pedir; o desenho do painel é por plataforma).
- Backfill retroativo de snapshot.
## Rabbit holes de produto
- **Dashboard de saúde de feeds.** Se alguém "só completar" a ideia de status, vira painel de monitoramento com histórico e alertas. **Corte neste item:** um painel de status por plataforma na própria global, só estado atual + motivo; sem histórico, sem alertas.
- **Wizard OAuth no admin.** Completar o desejo com o fluxo completo da Meta explode o escopo (app Meta, permissões, redirects). **Corte neste item:** credenciais continuam preenchidas manualmente; o valor é diagnóstico e correção, não aquisição de token.
- **Investigação infinita da causa em produção.** A causa pode ser credencial da assessoria (troca fora do nosso controle). **Corte neste item:** diagnóstico com prazo — o que não for descoberto num período curto vira instrução de validação no runbook, não bloqueio do item.
## Questões em aberto (produto)
- **Onde a assessoria vê o "porquê"?** **Opções:** A) painel de status na própria global do feed (ao lado dos campos do Instagram) — o lugar onde a assessoria já está quando configura | B) só logs/exceção no servidor (exige acesso técnico — não resolve para a persona) | C) nada além do picker vazio (status quo). **Recomendação:** A — o fracasso acontece onde a configuração acontece; o sinal pertence a esse mesmo lugar. _(assumido — validar com produto)_
- **O item cobre a ocorrência atual em produção?** **Opções:** A) diagnóstico da causa atual + painel de status (a correção de credencial fica com a assessoria, documentada) | B) só painel de status; a causa atual fica para followup | C) só runbook de credenciais. **Recomendação:** A — sem o diagnóstico, não sabemos se há também um defeito de código por trás do sintoma; sem o painel, o sintoma se repete cegamente. _(assumido — validar com produto)_
- **Paridade de status para YouTube agora?** **Opções:** A) só Instagram nesta fatia (o YouTube funciona em produção; o painel nasce desenhado por plataforma) | B) YouTube e Instagram juntos. **Recomendação:** A — fatia mínima útil; paridade vira followup barato se pedida. _(assumido — validar com produto)_
## Referências
- Issue pai S3 (#20, `done`/`in-prod`) + `docs/plans/secao-conteudos-home-instagram.md` e `-impl.md`
- Rascunho UI (gate): `docs/plans/feed-instagram-nao-aparece-home-ui-draft.html` + PNGs acima
- Primeiras pistas para o executor: `src/utilities/socialFeed/instagramFeed.ts` (caminho de falha silenciosa), `src/globals/SocialFeedSettings.ts`, `src/components/admin/InstagramPostExclusionPicker.tsx`, `tests/e2e/frontend.e2e.spec.ts` (describe serial da seção)
- `docs/AGENT-OPS.md` (serializes: nenhum recurso compartilhado — `serializes: []`)

View file

@ -1,75 +0,0 @@
# Impl: Pre-push sem e2e: e2e roda uma vez, no CI (gate local fica com os outros testes)
Status: aprovado
Atualizado em: 2026-08-18
Issue: #46
Intenção: docs/plans/gate-push-sem-e2e.md
Appetite restante: ~0,5 dia eng (herdado — cabe com folga)
## Leitura da intenção
- **Outcome:** `pnpm push` / `.husky/pre-push` não rodam mais e2e (sem playwright install, sem build `.next-e2e`, sem `test:e2e`); mantêm lint, format:check, typecheck, knip, check:cycles, test:unit, test:int e build na cascata/escopo atuais. O e2e do CI continua como está e gateia PR (job `e2e` do `ci-pr.yml` → rollup `checks`) e main (`e2e` do `ci.yml` → `checks` → `deploy`). Docs do fluxo atualizadas para "e2e = verificado no CI após o push; local opcional (`pnpm test:e2e:affected`)".
- **O que NÃO negociar:** CI intocado (não remover/alterar e2e de PR ou main); nenhum flag novo de "pular e2e" (escape `--no-verify` documentado continua sendo o único bypass); política "Dono do PR, dono do CI" inalterada.
- **O que reavaliar:** a hipótese de "áreas prováveis" aponta só para `scripts/gate-ci.mjs` + docs. Verificado no código: além do bloco e2e de `gate-ci.mjs`, existe **um teste unit que ancora o e2e no gate local** (`ciSkipInvariants` lista `scripts/gate-ci.mjs` entre os arquivos que devem conter `.next-e2e`) — a remoção do bloco quebra esse teste se ele não for ajustado junto. Também verificado: `AGENTS.md` (checklist local item 3) cita `pnpm test:e2e` como passo local obrigatório — precisa virar "e2e no CI; local opcional". `AGENT-OPS.md`, `work-issue`/`agent-work-issue`/`worktree-next-issue`/`local-database` skills **não** citam e2e no pre-push (verificado por grep) — nada a mudar nelas. Workflows do CI: e2e → `checks` (PR) e e2e → `checks` → `deploy` (main) comprovados — verificação, sem mudança.
## Abordagem recomendada
```mermaid
flowchart LR
P[push / pre-push] --> G[gate:push = gate:ci]
G --> F1[fase 1: lint format typecheck knip cycles unit]
F1 --> F2[fase 2: preflight teqo_test migrate seed int build]
F2 -. e2e: print informativo .-> CI[CI: job e2e PR + main]
```
**Opções consideradas:**
- **A — Editar o espelho (`scripts/gate-ci.mjs`):** remover o bloco e2e da cascata, manter o readout do classificador como aviso informativo ("o que o CI fará"), ajustar o invariante `ciSkipInvariants` e atualizar os 3 docs que descrevem o gate.
- B — Flag/env `--skip-e2e` no gate para o agente optar por pular: vira o bypass um recurso (anti-goal explícito da intenção).
- C — Novos scripts de gate (ex. `gate:push` sem e2e + `gate:ci` completo para quem quiser): twin do espelho, duplicação de cascata, viola "edit the owner, don't twin".
**Recomendação:** A — a intenção manda cortar o bloco do espelho, não criar caminho paralelo; `gate:push`/`gate:ci`/`.husky/pre-push`/`git-push.mjs` continuam como a cadeia única (sem mudança em package.json, hook ou git-push.mjs).
**Rejeitadas:** B porque cria o bypass como feature; C porque duplica a cascata que o espelho existe para manter DRY.
### Componentes / mudanças
- **`scripts/gate-ci.mjs`** — corta o bloco e2e (linhas ~146–166: playwright install, migrate/seed de e2e, build `.next-e2e`, `test:e2e`); o e2e sai de `needsDb` (preflight passa a depender só de test/build); o readout do escopo mantém `e2e=${scope.e2e.mode}` e o bloco vira **print informativo** do que o CI rodará (mode `full`/`selected`/`none` + specs) com a nota "e2e roda no CI — não no pre-push (OPS59)"; remove o check `unmapped` duplicado (o próprio `ci-scope.mjs` já imprime os unmapped no stderr antes do parse — e sem e2e local ele virou puramente informativo); docstring atualizada (espelho do `ci-pr.yml` **sem e2e** + corrige o path `.github/workflows/ci-pr.yml` → `.forgejo/workflows/ci-pr.yml`, obsoleto desde OPS50). Linha final do sucesso: "ci-pr mirror without e2e (e2e verified in CI)".
- **`tests/unit/ciSkipInvariants.unit.spec.ts`** — tira `scripts/gate-ci.mjs` da lista do invariante `.next-e2e` (o invariante existe para manter artefatos de build e2e fora do dist de dev; o gate local não builda mais e2e). `scripts/run-e2e-affected.mjs` e `playwright.config.ts` continuam na lista — o e2e local opcional e o CI ainda usam `.next-e2e`.
- **`.agents/rules/engineering-standards.mdc`** (linha do gate em duas velocidades) — "gate:push → gate:ci (espelho serial do ci-pr.yml, inclui format:check)" ganha a cláusula OPS59: espelho **sem e2e**; e2e roda uma vez no CI (PR e main); local opcional via `pnpm test:e2e:affected`.
- **`.agents/rules/agent-pr-workflow.mdc`** (passo 2 do push canônico) — idem: "espelho do ci-pr.yml (inclui format:check)" → "sem e2e (e2e é verificado pelo CI após o push; local opcional `pnpm test:e2e:affected`)".
- **`AGENTS.md`** (checklist local item 3) — sai `pnpm test:e2e`/`pnpm test:all` da sequência obrigatória local; entra "e2e roda no CI (PR e main) — local opcional `pnpm test:e2e:affected`".
- **`docs/plans/gate-push-sem-e2e.md`** (intenção) — Status → `entregue` no fechamento (padrão dos planos entregues, ex. `gate-push-local.md`).
- **`docs/changelog/2026-08-18-ops59.md`** + `pnpm changelog:build` (OPS44, agregado insert-only).
- **Migration:** sem migration. **Access / Consent:** N/A. **UI:** N/A (Impeccable A).
### Dados → forma (se aplicável)
N/A — chore de DX/processo, sem superfície de dados.
## Fases verificáveis
1. **Gate + invariante** — `scripts/gate-ci.mjs` (corte do bloco e2e + readout informativo) e `tests/unit/ciSkipInvariants.unit.spec.ts`. Verificação: `pnpm test:unit -- tests/unit/ciSkipInvariants.unit.spec.ts` e inspeção do diff do gate.
2. **Docs do fluxo** — engineering-standards.mdc, agent-pr-workflow.mdc, AGENTS.md, changelog OPS44, status da intenção. Verificação: varredura final por citações residuais (`gate:push`/`gate:ci`/`test:e2e`/`test:all` nas rules/skills/AGENT-OPS — nada deve descrever e2e como etapa do pre-push).
3. **Gates** — `pnpm gate:ci` local (roda a cascata nova: fase 1 + migrate/seed/int/build em `teqo_test`; e2e vira print) + `pnpm push`. O e2e em si NÃO roda local — por design; o CI roda full nesta PR (`gate-ci.mjs` é HIGH_RISK → `test`/`e2e` full no ci-pr).
## Rabbit holes / Não escopo (engenharia)
- Remodelar a cascata do espelho (ex.: "já que removo e2e, simplifico o build/preflight") — corte só o bloco e2e; o restante fica fiel ao CI.
- Flag/env de pular e2e no gate — bypass como recurso, anti-goal.
- Tocar nos workflows `.forgejo/*` — CI é o verificado, intocado (verificação apenas).
- Mudar `scripts/run-e2e-affected.mjs`/`e2e-affected.mjs`/manifestos — o e2e local opcional permanece como está.
- Tirar `.next-e2e` de gitignore/prettierignore/playwright.config — ainda usado pelo CI e pelo e2e local opcional.
- Editar `docs/plans/gate-push-local.md` (histórico entregue — não é lock, é registro).
## Riscos e mitigação
- **Invariante CI quebra ao remover o bloco** → `ciSkipInvariants` atualizado na mesma mudança (fase 1 junto do gate); a suíte unit roda antes do push.
- **Citação residual de e2e no pre-push em doc** → varredura explícita na fase 2 (regras/skills/AGENT-OPS/AGENTS.md) antes do push; `engineering-standards.mdc` e `agent-pr-workflow.mdc` são a fonte contratual do gate.
- **Gate local fica "mais fraco" que o CI por engano (skips divergentes)** → o restante da cascata e os skips do classificador ficam intactos; o readout do escopo (incl. e2e) é impresso para o agente saber o que o CI fará.
- **Docs-only diff sem e2e no CI**: comportamento pré-existente (classificador pula e2e em docs-only) — nada muda.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto (push sem e2e; CI gateando PR e main — workflows verificados: `ci-pr.yml` e2e → `checks`; `ci.yml` e2e → `checks` → `deploy`)
- [x] Invariantes AGENTS/engineering-standards (nenhum write de dado; docs em pt-BR; sem acesso/Consent/migration)
- [x] Testes previstos: `ciSkipInvariants` ajustado; `pnpm gate:ci` valida a cascata nova; CI roda full (HIGH_RISK `gate-ci.mjs`)

View file

@ -1,77 +0,0 @@
# Pre-push sem e2e: e2e roda uma vez, no CI (gate local fica com os outros testes)
Status: entregue (2026-08-18)
Atualizado em: 2026-08-18
Issue: #46
Priority: P2
Model: composer-2.5
Impeccable: A — N/A (sem superfície UI)
Rascunho UI: N/A — sem UI
Appetite: ~0,5 dia eng; um outcome verificável (push não roda e2e; CI continua gateando merge/deploy)
Responsável: —
## Intenção
O CI agora roda na nossa própria máquina (Forgejo + runner self-hosted, OPS50/OPS53), com o e2e completo gateando PRs e deploy em main. O pre-push local (`pnpm push` → `gate:ci`) ainda roda a suíte e2e inteira — playwright install, build `.next-e2e`, testes — **antes** do push, duplicando o trabalho mais pesado do fluxo duas vezes: local e CI. O custo por push é de dezenas de minutos que não decidem nada que o CI já não decida.
Decisão de produto: e2e roda **uma vez, no CI** (para todo PR e no merge em main). O pre-push mantém todos os outros testes/checagens (lint, format, typecheck, knip, cycles, unit, int, build). O agente continua dono do PR até o merge e é responsável por consertar falhas de e2e que aparecerem no CI — política existente, que este item reforça em vez de enfraquecer.
## Persona e fluxo
- **Persona / contexto:** agente (ou humano) fechando um PR de feature/chore — quer feedback rápido do gate local antes do push, sem pagar 2× o teste mais caro.
- **Job principal:** saber antes do push que o PR não quebra o que o CI vai verificar — com a confiança de que o e2e (não rodado localmente) será rodado pelo CI e bloqueia merge/deploy se falhar.
- **Fluxo desejado:**
1. Agente termina a feature e roda `pnpm push` (ou `git push` via hook).
2. O gate local roda os testes/checagens que continuam no pre-push — **sem e2e** — e deixa o push passar.
3. O CI roda o e2e no PR (full ou afetado, conforme o blast radius); se falhar, o job `checks` falha, o merge não acontece e o agente conserta no mesmo PR (sem abrir mão — política "Dono do PR, dono do CI").
4. No merge em main, o CI roda o e2e completo de novo; `checks` verde é pré-requisito do deploy.
- **Anti-goals de produto:**
- Não remover e2e do CI (nem de PR nem de main) — só do gate local de push.
- Não criar gate/flag novo de "pular e2e" por PR (o escape `--no-verify` documentado continua sendo o único bypass).
- Não enfraquecer a responsabilidade do agente pelo CI do PR (o e2e de CI falhou → agente conserta, até o merge).
## Objetivo e aceite
- `pnpm push` / `.husky/pre-push` **não** rodam mais e2e (sem playwright install, sem build `.next-e2e`, sem `test:e2e`) — mantêm lint, format:check, typecheck, knip, check:cycles, test:unit e test:int/build (na cascata e escopo atuais).
- O e2e do CI continua como está e comprovadamente gateia: (a) todo PR de código para main (job `e2e` em ci-pr.yml; docs-only segue sem e2e, por design do classificador), e (b) todo merge em main antes do deploy (job `e2e` full em ci.yml, `checks` → `deploy`).
- O agente segue acompanhando o PR até o merge: falha de e2e no CI é do dono do PR (política já documentada em `docs/AGENT-OPS.md` e `.agents/rules/engineering-standards.mdc` — não muda).
- A documentação do fluxo (regras/skills/AGENT-OPS) deixa de citar e2e como etapa do pre-push e passa a dizer que e2e é verificado no CI após o push (local opcional, ex. `pnpm test:e2e:affected` para feedback rápido).
## Dados (intenção)
Dados: N/A — chore de DX/processo; sem métricas de produto. (O ganho é tempo de push e carga da máquina; medição fica a critério da execução.)
## Direção no codebase (hipótese)
- **Áreas prováveis:** `scripts/gate-ci.mjs` (o bloco e2e do espelho local — remover da cascata local), `package.json` (scripts `gate:*` — provavelmente sem mudança, `gate:ci` continua), `.husky/pre-push` / `scripts/git-push.mjs` (devem continuar delegando a `gate:ci` — sem duplicação de lógica), `.forgejo/workflows/ci-pr.yml` e `ci.yml` (**intocados** — verificação de que continuam corretos, não mudança).
- **Precedente a olhar:** `docs/plans/gate-push-local.md` (criou o gate; entregue — o novo item ajusta a decisão), `docs/plans/politica-ci-dono-do-pr.md` (política que permanece), `docs/plans/ci-e2e-paralelizar-job.md` (shards do e2e no CI — intocados).
- **Risco de acoplamento:** `scripts/gate-ci.mjs` espelha `ci-pr.yml` em cascata — ao remover o bloco e2e, manter o restante da cascata com os mesmos skips do classificador (`scripts/ci-scope.mjs`); não tocar no CI em si. Docs que citam o gate: `engineering-standards.mdc`, `agent-pr-workflow.mdc`, skills (`work-issue`, `agent-work-issue`, `worktree-next-issue`, `local-database`) — atualizar citações de "gate:push roda e2e" para o novo contrato.
## Dependências
- Nenhuma. (Não serializa com outras Issues; CI/classificador são donos de outra área e ficam intactos.)
## Fora de escopo
- Mudar o que o CI roda (full/selected/none, shards, deploy) → CI atual é o verificado e fica como está.
- Alterar os scripts de teste em si (`test:e2e`, `test:all`, `test:e2e:affected`) — continuam disponíveis para uso manual/verificação.
- Mexer na política "Dono do PR, dono do CI" → permanece.
## Rabbit holes de produto
- **"Já que removo e2e, removo o build pesado também / simplifico o gate".** Não: o build segue necessário para as outras etapas e o espelho deve continuar fiel ao CI no que permanece. **Corte:** remover só o bloco e2e, sem redesenhar a cascata.
- **"Adiciono um flag `--skip-e2e` no gate".** Faria do bypass um recurso; o escape `--no-verify` documentado já cobre casos de transição. **Corte:** nenhum flag novo.
## Questões em aberto (produto)
- **Manter a citação de e2e na checklist de verificação de entregas (AGENTS.md / skills)?** **Opções:** A) tirar e2e da checklist local obrigatória (fica "e2e = responsabilidade do CI; local opcional") | B) manter como passo recomendado quando o agente quiser feedback antecipado. **Recomendação:** A — e2e no pre-push sai por decisão deste item; a verificação passa a ser "push → acompanhar CI até checks verde", com `test:e2e:affected` opcional para o agente que preferir rodar antes. _(assumido — validar com produto)_
- **O agente deve rodar e2e local antes de abrir o PR em caso de dúvida?** **Opções:** A) nunca obrigatório — CI decide | B) obrigatório só quando o diff toca área sem e2e mapeado (unmapped). **Recomendação:** A — o classificador já força full nesses casos no CI; rodar local duplicaria a exceção que estamos eliminando. _(assumido — validar com produto)_
## Referências
- `docs/plans/gate-push-local.md` — origem do gate (entregue; decisão ajustada por este item)
- `docs/plans/politica-ci-dono-do-pr.md` — política que permanece (CI do PR é do dono até o merge)
- `docs/plans/ci-e2e-paralelizar-job.md` — como o e2e do CI roda (intocado)
- `.forgejo/workflows/ci-pr.yml` / `ci.yml` — o e2e do CI que este item preserva (verificação, não mudança)
- `scripts/gate-ci.mjs`, `scripts/ci-scope.mjs`, `scripts/git-push.mjs`, `.husky/pre-push`
- `docs/AGENT-OPS.md`, `.agents/rules/engineering-standards.mdc`, `.agents/rules/agent-pr-workflow.mdc`

Some files were not shown because too many files have changed in this diff Show more