From 60a71eea4baf60b24f12b9065839044a3fc4c531 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 24 Aug 2026 13:30:16 -0300 Subject: [PATCH] =?UTF-8?q?feat(OPS85):=20matar=20o=20agregado=20commitado?= =?UTF-8?q?=20do=20changelog=20=E2=80=94=20registro=20por-arquivo=20+=20HI?= =?UTF-8?q?STORY=20congelado=20+=20leitura=20sob=20demanda=20(Closes=20#83?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/rules/agent-pr-workflow.mdc | 2 +- .agents/rules/engineering-standards.mdc | 2 +- .../skills/work-issue/execution-pipeline.md | 11 +- .github/pull_request_template.md | 4 +- .github/workflows/ci-pr.yml | 2 - .gitignore | 3 + AGENTS-infra.md | 2 +- AGENTS.md | 2 +- docs/AGENT-OPS.md | 10 +- ...-AGENTS.md => CHANGELOG-AGENTS-HISTORY.md} | 1 + docs/changelog/2026-08-24-ops85.md | 1 + docs/roadmap.md | 2 +- package.json | 1 + scripts/build-changelog.mjs | 65 +++++++-- scripts/check-changelog-append-only.mjs | 46 +++--- scripts/gate-ci.mjs | 3 +- scripts/lib/agent-pool-prompt.mjs | 2 +- scripts/lib/changelog.mjs | 138 +++++++++++------- tests/unit/agentPoolPrompt.unit.spec.ts | 6 +- tests/unit/changelog.unit.spec.ts | 92 ++++++------ 20 files changed, 239 insertions(+), 156 deletions(-) rename docs/{CHANGELOG-AGENTS.md => CHANGELOG-AGENTS-HISTORY.md} (99%) create mode 100644 docs/changelog/2026-08-24-ops85.md diff --git a/.agents/rules/agent-pr-workflow.mdc b/.agents/rules/agent-pr-workflow.mdc index dbbec9db..702ec9fb 100644 --- a/.agents/rules/agent-pr-workflow.mdc +++ b/.agents/rules/agent-pr-workflow.mdc @@ -38,7 +38,7 @@ Closes # **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` 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/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, e marcadores de conflito — o escape `changelog-rewrite:` fica só no CI, body do PR) 3. `git push --no-verify` — só porque o gate já rodou no passo 2 (não é escape hatch) ```bash diff --git a/.agents/rules/engineering-standards.mdc b/.agents/rules/engineering-standards.mdc index f3d1a181..412195a5 100644 --- a/.agents/rules/engineering-standards.mdc +++ b/.agents/rules/engineering-standards.mdc @@ -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` **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/OPS85: changelog additions-only + HISTORY congelado + agregado não-commitado, 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`). 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 ` first (knip still cannot load `payload.config.ts`, so its graph is incomplete; ledgered as P3). ## Type honesty diff --git a/.agents/skills/work-issue/execution-pipeline.md b/.agents/skills/work-issue/execution-pipeline.md index e6deb450..4fd3675f 100644 --- a/.agents/skills/work-issue/execution-pipeline.md +++ b/.agents/skills/work-issue/execution-pipeline.md @@ -62,11 +62,12 @@ antes do deploy**. ## Fechar em main 1. Branch do ator (ver deltas) — nunca crie branch nova fora dela. -2. **Changelog da entrega (OPS44):** escreva `docs/changelog/-.md` - (ex. `2026-08-13-ops44.md`) — uma entrada curta no formato do agregado — - 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. +2. **Changelog da entrega (OPS44, OPS85):** escreva `docs/changelog/-.md` + (ex. `2026-08-13-ops44.md`) — uma entrada curta no formato do agregado. É o + único registro commitado: **não rode `pnpm changelog:build` nem commite o + agregado** (ele é gitignored desde OPS85). Opcional: `pnpm changelog:read` + para conferir a leitura completa localmente (seed do HISTORY). O diff + commitado é só a entrada nova. 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= node scripts/github-pr.mjs --head --title " — " --body-file ` — **Ready** (nunca draft; o script não tem flag draft), base `main`, body com `Closes #` (ou `Related #N` em plans-only). Em Cursor Cloud: `ManagePullRequest` com `draft: false`. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 569151a4..6fe4b3f1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,8 +11,8 @@ Closes # # Changelog de contexto para agentes (fatias do AGENTS.md) Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no `AGENTS.md`. **Desde OPS44 (2026-08-13)** cada entrega registra UMA entrada em `docs/changelog/-.md` e o agregado é regenerado por `pnpm changelog:build` (insert-only — este arquivo nunca é editado na mão; guard de CI `docs-guards` e escape `changelog-rewrite:` documentados em AGENT-OPS). diff --git a/docs/changelog/2026-08-24-ops85.md b/docs/changelog/2026-08-24-ops85.md new file mode 100644 index 00000000..95b101e1 --- /dev/null +++ b/docs/changelog/2026-08-24-ops85.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-24):** **OPS85** (Issue #830) — eliminar a classe de conflito de merge no changelog: o agregado commitado `docs/CHANGELOG-AGENTS.md` (171 blocos reescritos no mesmo anchor a cada entrega paralela) morre — `docs/changelog/-.md` vira o **único registro commitado** e a leitura "Recently resolved" é gerada sob demanda: `pnpm changelog:build` grava o artefato **gitignored** no mesmo path, `pnpm changelog:read` imprime no stdout, ambos com seed do snapshot congelado **`docs/CHANGELOG-AGENTS-HISTORY.md`** (cópia integral do agregado na data da migração, incluindo as 106 entradas pré-OPS44 que só existiam lá; nunca é editado). Guard de CI `docs-guards` reescrito: entradas additions-only (imutável), HISTORY congelado (só a criação é permitida) e agregado proibido no diff (a remoção única desta entrega foi a exceção que criou o HISTORY); o multiset do agregado (perda de linha) morre — a garantia de não-perda vira additions-only + pós-condição do gerador ("toda entrada parseada aparece no agregado"). O step "Aggregate is up to date" sai do `ci-pr.yml` e do `gate-ci.mjs` (o `--check` vira sanidade local opcional); escape `changelog-rewrite:` permanece CI-only (agora também cobre correção do HISTORY). Contratos atualizados: AGENTS.md, AGENT-OPS.md (item 4 + "Registries compartilhados" + seção nova **"Decisões arquivadas"**), execution-pipeline (entrada sem build), PR template, roadmap, agent-pool-prompt, engineering-standards, agent-pr-workflow, AGENTS-infra. **Docs agent-facing NÃO migram para fora do repo — arquivado com evidência:** o conflito era do agregado, não das entradas por-arquivo; alvo registrado se um dia houver custo novo: Wiki.js. Sem migration/access/Consent/UI/schema. Esta entrega é o tracer bullet do fluxo novo (entrada commitada, agregado não). diff --git a/docs/roadmap.md b/docs/roadmap.md index ce39d519..ef8242b4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -3,5 +3,5 @@ **Congelado em 2026-07-30.** Este arquivo não é mais editado. - **Fonte canônica de spec/status/deps/prio/modelo:** [Issues do Forgejo](https://git.solla.dev/fsolla/teqo/issues) (frontmatter `id/depends/serializes/priority/model`; estado em labels `ready | in-progress | blocked | done | in-prod`). Fluxo: skills `plan-issue` → `work-issue` → `project-status` (ver `docs/AGENT-OPS.md`). Status rápido: `pnpm agent:status`. -- **Histórico e entregas:** [`docs/CHANGELOG-AGENTS.md`](CHANGELOG-AGENTS.md) + [`docs/plans/`](plans/). +- **Histórico e entregas:** [`docs/changelog/`](changelog/) (registro por-arquivo) + [`docs/plans/`](plans/) — leitura agregada sob demanda: `pnpm changelog:read` (ou `pnpm changelog:build`, gitignored). - **Conteúdo integral deste roadmap:** preservado no git history (última versão viva no commit imediatamente anterior a este stub). diff --git a/package.json b/package.json index 6180fcd3..5cb15e44 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "push": "node scripts/git-push.mjs", "check:cycles": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src", "changelog:build": "node scripts/build-changelog.mjs", + "changelog:read": "node scripts/build-changelog.mjs --stdout", "changelog:check": "node scripts/build-changelog.mjs --check", "generate:importmap": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload generate:types", diff --git a/scripts/build-changelog.mjs b/scripts/build-changelog.mjs index c829107e..2dd46073 100644 --- a/scripts/build-changelog.mjs +++ b/scripts/build-changelog.mjs @@ -1,13 +1,17 @@ #!/usr/bin/env node /** - * build-changelog — regenerate docs/CHANGELOG-AGENTS.md from the per-delivery - * entries in docs/changelog/ (OPS44). Deliveries write one - * `-.md` file and run `pnpm changelog:build`; the aggregate keeps - * the single readable "Recently resolved" history. Insert-only: historical - * blocks are never touched. + * build-changelog — regenerate the aggregated changelog (OPS44/OPS85). * - * Usage: node scripts/build-changelog.mjs [--check] - * --check verify the aggregate is up to date without writing (CI use) + * docs/changelog/-.md is the only committed changelog record. + * The readable aggregate docs/CHANGELOG-AGENTS.md is a gitignored artifact + * generated on demand, seeded from the frozen snapshot + * docs/CHANGELOG-AGENTS-HISTORY.md when the local file is absent. Insert-only: + * historical blocks are never touched. + * + * Usage: node scripts/build-changelog.mjs [--stdout] [--check] + * --stdout print the aggregate to stdout instead of writing the file + * --check verify the local aggregate (when present) is up to date — + * local sanity only, not a CI/gate requirement since OPS85 */ import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' @@ -16,10 +20,14 @@ import { buildChangelog, CHANGELOG_AGGREGATE, CHANGELOG_DIR, + CHANGELOG_HEADER, + CHANGELOG_HISTORY, listChangelogEntries, + splitChangelogBody, } from './lib/changelog.mjs' const repoRoot = process.cwd() +const toStdout = process.argv.includes('--stdout') const checkOnly = process.argv.includes('--check') function die(message) { @@ -30,11 +38,23 @@ function die(message) { const aggregatePath = join(repoRoot, CHANGELOG_AGGREGATE) const changelogDir = join(repoRoot, CHANGELOG_DIR) -let aggregateContent +let localContent = null try { - aggregateContent = readFileSync(aggregatePath, 'utf8') + localContent = readFileSync(aggregatePath, 'utf8') } catch { - die(`não consegui ler ${CHANGELOG_AGGREGATE}`) + // absent — regenerable artifact; seed from the frozen HISTORY snapshot +} + +let aggregateContent = localContent +if (aggregateContent === null) { + let historyContent + try { + historyContent = readFileSync(join(repoRoot, CHANGELOG_HISTORY), 'utf8') + } catch { + die(`não consegui ler ${CHANGELOG_AGGREGATE} nem o seed ${CHANGELOG_HISTORY}`) + } + const { blocks } = splitChangelogBody(historyContent) + aggregateContent = [CHANGELOG_HEADER, ...blocks].join('\n\n') + '\n' } let files @@ -49,26 +69,43 @@ if (errors.length > 0) { die(`entradas inválidas em ${CHANGELOG_DIR}/:\n ${errors.join('\n ')}`) } -const bodies = entries.map(({ path }) => { +const withContent = entries.map(({ path, name, date, id }) => { try { - return readFileSync(join(repoRoot, path), 'utf8') + return { path, name, date, id, content: readFileSync(join(repoRoot, path), 'utf8') } } catch { die(`não consegui ler ${path}`) } }) const next = buildChangelog({ - entries: bodies.map((content) => ({ content })), + entries: withContent.map(({ content }) => ({ content })), aggregateContent, }) +// Post-condition (OPS85): no parsed entry may silently vanish from the +// generated aggregate — replaces the old committed-aggregate multiset. +for (const { path, content } of withContent) { + if (!next.includes(content.trimEnd())) { + die(`entrada ausente do agregado gerado (perda silenciosa?): ${path}`) + } +} + if (checkOnly) { - if (next !== aggregateContent) { + if (localContent === null) { + console.log('[build-changelog] ok — agregado local ausente (artefato regenerável)') + process.exit(0) + } + if (next !== localContent) { die(`${CHANGELOG_AGGREGATE} desatualizado — rode \`pnpm changelog:build\``) } console.log('[build-changelog] ok — aggregate up to date') process.exit(0) } +if (toStdout) { + process.stdout.write(next) + process.exit(0) +} + writeFileSync(aggregatePath, next) console.log(`[build-changelog] regenerado ${CHANGELOG_AGGREGATE} com ${entries.length} entrada(s)`) diff --git a/scripts/check-changelog-append-only.mjs b/scripts/check-changelog-append-only.mjs index fc7c7e98..bf07d2cc 100644 --- a/scripts/check-changelog-append-only.mjs +++ b/scripts/check-changelog-append-only.mjs @@ -1,23 +1,26 @@ #!/usr/bin/env node /** - * check-changelog-append-only — CI guard (OPS44): the aggregated changelog - * must never lose an existing line, and docs/changelog/ entries are - * additions-only. Legitimate restorations (D8 pattern) and header rewrites - * escape via the documented `changelog-rewrite:` token in the PR body. + * check-changelog-append-only — CI + pre-push guard (OPS44/OPS85): the + * committed changelog record is per-file only. docs/changelog/ entries are + * additions-only, docs/CHANGELOG-AGENTS-HISTORY.md is a frozen snapshot + * (only its creation is allowed), and the regenerable aggregate + * docs/CHANGELOG-AGENTS.md must never be committed — its one-time removal + * is allowed only together with the HISTORY creation (the OPS85 migration). + * Legitimate exceptions (D8-style restorations, HISTORY corrections) escape + * via the documented `changelog-rewrite:` token in the PR body. * * Paths from git diff (merge-base…HEAD); body from PR_BODY env or `gh pr * view` when PR_NUMBER is set. The escape is honored only when the diff - * actually touches the aggregate or docs/changelog/, and only as a - * standalone line (never the PR template's own checkbox text). + * actually touches the changelog paths, and only as a standalone line + * (never the PR template's own checkbox text). */ import { execFileSync } from 'node:child_process' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { assertChangelogAppendOnly, CHANGELOG_AGGREGATE, CHANGELOG_DIR, + CHANGELOG_HISTORY, CHANGELOG_REWRITE_ESCAPE_RE, } from './lib/changelog.mjs' @@ -42,7 +45,15 @@ try { function listChangedPaths() { const raw = execFileSync( 'git', - ['diff', '--name-status', `${mergeBase}...HEAD`, '--', CHANGELOG_AGGREGATE, CHANGELOG_DIR], + [ + 'diff', + '--name-status', + `${mergeBase}...HEAD`, + '--', + CHANGELOG_AGGREGATE, + CHANGELOG_HISTORY, + CHANGELOG_DIR, + ], { encoding: 'utf8' }, ) @@ -79,7 +90,7 @@ if (diffLines.length === 0) { } // R (rename): status + old path + new path — use the new path. -const changelogDiff = diffLines.map((line) => { +const changed = diffLines.map((line) => { const [status, ...rest] = line.split('\t') return { status, path: status.startsWith('R') ? rest[rest.length - 1] : rest.join('\t') } }) @@ -90,19 +101,10 @@ if (CHANGELOG_REWRITE_ESCAPE_RE.test(body)) { process.exit(0) } -function blob(ref, path) { - return execFileSync('git', ['show', `${ref}:${path}`], { encoding: 'utf8' }) -} - -const aggregateDiff = changelogDiff.filter(({ path }) => path === CHANGELOG_AGGREGATE) -const oldAggregate = aggregateDiff.length > 0 ? blob(mergeBase, CHANGELOG_AGGREGATE) : '' -const newAggregate = - aggregateDiff.length > 0 ? readFileSync(join(process.cwd(), CHANGELOG_AGGREGATE), 'utf8') : '' - const result = assertChangelogAppendOnly({ - oldAggregate, - newAggregate, - changelogDiff: changelogDiff.filter(({ path }) => path.startsWith(`${CHANGELOG_DIR}/`)), + changelogDiff: changed.filter(({ path }) => path.startsWith(`${CHANGELOG_DIR}/`)), + historyDiff: changed.filter(({ path }) => path === CHANGELOG_HISTORY), + aggregateDiff: changed.filter(({ path }) => path === CHANGELOG_AGGREGATE), }) if (!result.ok) { diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index cf709ccd..509d5be5 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -87,9 +87,8 @@ const main = async () => { run('lint', 'pnpm', ['lint']) run('format:check', 'pnpm', ['format:check']) - // --- Docs guards (OPS63): same checks as the CI `docs-guards` job --- + // --- Docs guards (OPS63/OPS85): same checks as the CI `docs-guards` job --- run('docs-guards (changelog append-only)', 'node', ['scripts/check-changelog-append-only.mjs']) - run('docs-guards (aggregate sync)', 'node', ['scripts/build-changelog.mjs', '--check']) run('docs-guards (conflict markers)', 'node', ['scripts/check-docs-conflict-markers.mjs']) if (scope.code.mode === 'none') { diff --git a/scripts/lib/agent-pool-prompt.mjs b/scripts/lib/agent-pool-prompt.mjs index dd3a3ab1..14556b70 100644 --- a/scripts/lib/agent-pool-prompt.mjs +++ b/scripts/lib/agent-pool-prompt.mjs @@ -37,7 +37,7 @@ export const buildPoolWorkerPrompt = ({ issueNumber, issueTitle, issueId, planPa : '2. O body da Issue é a spec de intenção (não há plano linkado). Em Plan mode, escreva mesmo assim um `docs/plans/-impl.md` a partir do body.', '3. Execute o impl plan. Gates: `pnpm gate:fast` na iteração; entrega com `pnpm push` (não `git push` nu). Comandos bare, nunca piped.', '4. Rode `/simplify` completo; `capture-review-debts` em modo autônomo (só expensive_lock ≥4).', - `5. PR obrigatoriamente com \`gh pr create --base main\` e "Closes #${issueNumber}" no body (Ready, nunca draft). Escreva a entrada do changelog (\`docs/changelog/-.md\` + \`pnpm changelog:build\`) antes do push (OPS44).`, + `5. PR obrigatoriamente com \`gh pr create --base main\` e "Closes #${issueNumber}" no body (Ready, nunca draft). Escreva a entrada do changelog (\`docs/changelog/-.md\` — OPS44; o agregado é gitignored e não é commitado, OPS85) antes do push.`, '6. `gh pr merge --auto --rebase ` e acompanhe `gh pr checks --watch --required` até o merge. Falha de CI no seu PR é sua (docs/AGENT-OPS.md — "Dono do PR, dono do CI"): corrija na mesma branch.', '', '## Proibido', diff --git a/scripts/lib/changelog.mjs b/scripts/lib/changelog.mjs index d37c370d..b58489ca 100644 --- a/scripts/lib/changelog.mjs +++ b/scripts/lib/changelog.mjs @@ -1,20 +1,34 @@ /** - * changelog — pure policy for the per-delivery changelog format (OPS44). + * changelog — pure policy for the per-delivery changelog format (OPS44/OPS85). * * Deliveries write `docs/changelog/-.md` (one entry per file, * immune to merge conflicts by construction — same pattern as docs/plans/). - * `pnpm changelog:build` inserts those entries into the single readable - * docs/CHANGELOG-AGENTS.md in chronological position, keeping the - * "Recently resolved" reading intact. The build is insert-only: it never - * removes or rewrites existing blocks (historical entries stay as-is — - * OPS44 scope), so the aggregate is append-only by construction. CI guards - * that property (scripts/check-changelog-append-only.mjs) and that docs - * diffs never carry conflict markers + * Since OPS85 the committed changelog record is exactly those files: the + * readable aggregate docs/CHANGELOG-AGENTS.md is a gitignored artifact + * regenerated on demand (`pnpm changelog:build` writes it, `changelog:read` + * prints it), seeded from the frozen snapshot + * docs/CHANGELOG-AGENTS-HISTORY.md when the local file is absent. The build + * stays insert-only: it never removes or rewrites existing blocks. CI guards + * (scripts/check-changelog-append-only.mjs) that docs/changelog/ entries are + * additions-only, the HISTORY snapshot is frozen, and the regenerable + * aggregate is never committed; docs diffs must never carry conflict markers * (scripts/check-docs-conflict-markers.mjs). */ export const CHANGELOG_DIR = 'docs/changelog' export const CHANGELOG_AGGREGATE = 'docs/CHANGELOG-AGENTS.md' +export const CHANGELOG_HISTORY = 'docs/CHANGELOG-AGENTS-HISTORY.md' + +/** + * Header of the regenerated aggregate (OPS85). Used as the seed header when + * the local aggregate is absent and the build starts from the HISTORY + * snapshot. Must contain the `---` separator (splitChangelogBody contract). + */ +export const CHANGELOG_HEADER = `# Changelog de contexto para agentes (fatias do AGENTS.md) + +Conteúdo movido do \`AGENTS.md\` em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no \`AGENTS.md\`. **Este arquivo é um artefato regenerado sob demanda (OPS85):** é gitignored e nunca é commitado. Cada entrega registra UMA entrada em \`docs/changelog/-.md\` (único registro versionado); o agregado é gerado por \`pnpm changelog:build\` (grava aqui) ou \`pnpm changelog:read\` (stdout), com seed do snapshot congelado \`docs/CHANGELOG-AGENTS-HISTORY.md\` quando o arquivo local não existe. Guard de CI \`docs-guards\` (entradas additions-only, HISTORY congelado, agregado não-commitado) e escape \`changelog-rewrite:\` documentados em AGENT-OPS. + +---` /** `2026-08-13-ops44.md` — date desc, then id asc. */ const ENTRY_FILE_RE = /^(\d{4})-(\d{2})-(\d{2})-([a-z0-9-]+)\.md$/ @@ -151,68 +165,80 @@ export function entryDateOf(block) { } /** - * Append-only policy for the changelog (CI guard). + * Changelog policy for the committed diff (CI + pre-push guard). * - * The aggregate must never lose an existing line: every line of the old - * content must still appear in the new content with equal or greater - * count (multiset inclusion — honest under line moves/reordering, unlike - * a diff parser). Files under docs/changelog/ must be additions only - * (entries are immutable once committed; a deletion or modification is a - * silent data-loss path). + * Since OPS85 the committed changelog record is per-file only: + * - files under docs/changelog/ must be additions only (entries are + * immutable once committed; a deletion or modification is a silent + * data-loss path); + * - docs/CHANGELOG-AGENTS-HISTORY.md is a frozen snapshot of the old + * aggregate — only its creation (status A) is allowed, so any later + * modification or deletion fails; + * - the regenerable docs/CHANGELOG-AGENTS.md must never appear in the + * diff; the one-time removal (status D) is allowed only in the same + * diff that creates the HISTORY snapshot (the OPS85 migration). * - * Legitimate restorations (D8-style) and header rewrites escape via the - * documented `changelog-rewrite:` token in the PR body (see AGENT-OPS). + * Legitimate exceptions (D8-style restorations, HISTORY corrections) escape + * via the documented `changelog-rewrite:` token in the PR body (see + * AGENT-OPS). The old committed-aggregate multiset check died with OPS85: + * the "no silent entry loss" guarantee now lives in the per-file + * additions-only rule plus the generator post-condition in + * scripts/build-changelog.mjs. * - * @param {{ oldAggregate: string, newAggregate: string, - * changelogDiff: { status: string, path: string }[] }} input + * @param {{ changelogDiff: { status: string, path: string }[], + * historyDiff: { status: string, path: string }[], + * aggregateDiff: { status: string, path: string }[] }} input * @returns {{ ok: true } | { ok: false, message: string }} */ -export function assertChangelogAppendOnly({ oldAggregate, newAggregate, changelogDiff }) { - const missing = missingLines(oldAggregate, newAggregate) - if (missing.length > 0) { +export function assertChangelogAppendOnly({ changelogDiff, historyDiff, aggregateDiff }) { + const touchedEntries = changelogDiff.filter((change) => change.status !== 'A') + if (touchedEntries.length > 0) { return { ok: false, message: - `docs/CHANGELOG-AGENTS.md não é append-only: ${missing.length} linha(s) existente(s) ` + - `somem (ex.: ${JSON.stringify(missing[0])}). Restaurações legítimas: use ` + - '`changelog-rewrite: ` no body do PR (linha própria; definição em AGENT-OPS).', - } - } - - const touched = changelogDiff.filter((change) => change.status !== 'A') - if (touched.length > 0) { - return { - ok: false, - message: - `docs/changelog/ não é additions-only: ${touched + `docs/changelog/ não é additions-only: ${touchedEntries .map((change) => `${change.status} ${change.path}`) .join(', ')}. Entradas são imutáveis; restaurações legítimas: use ` + '`changelog-rewrite: ` no body do PR (linha própria; definição em AGENT-OPS).', } } - return { ok: true } -} - -/** - * @param {string} oldContent - * @param {string} newContent - * @returns {string[]} - distinct old lines missing or under-counted in new - */ -export function missingLines(oldContent, newContent) { - const counts = new Map() - for (const line of newContent.split('\n')) { - counts.set(line, (counts.get(line) ?? 0) + 1) - } - - const missing = [] - for (const line of oldContent.split('\n')) { - const remaining = counts.get(line) ?? 0 - if (remaining <= 0) { - missing.push(line) - } else { - counts.set(line, remaining - 1) + const touchedHistory = historyDiff.filter((change) => change.status !== 'A') + if (touchedHistory.length > 0) { + return { + ok: false, + message: + `${CHANGELOG_HISTORY} é um snapshot congelado: ${touchedHistory + .map((change) => `${change.status} ${change.path}`) + .join(', ')} não é permitido. Correções legítimas: use ` + + '`changelog-rewrite: ` no body do PR (linha própria; definição em AGENT-OPS).', } } - return [...new Set(missing)] + + const nonDeletions = aggregateDiff.filter((change) => change.status !== 'D') + if (nonDeletions.length > 0) { + return { + ok: false, + message: + `${CHANGELOG_AGGREGATE} não deve ser commitado — é um artefato gitignored regenerado ` + + `sob demanda (OPS85): ${nonDeletions + .map((change) => `${change.status} ${change.path}`) + .join(', ')}.`, + } + } + + const aggregateDeleted = aggregateDiff.some((change) => change.status === 'D') + const historyCreated = historyDiff.some((change) => change.status === 'A') + if (aggregateDeleted && !historyCreated) { + return { + ok: false, + message: + `${CHANGELOG_AGGREGATE} removido sem criar ${CHANGELOG_HISTORY} — a remoção do ` + + 'agregado commitado só é válida na entrega que congela o snapshot (OPS85). ' + + 'Exceções legítimas: use `changelog-rewrite: ` no body do PR (linha ' + + 'própria; definição em AGENT-OPS).', + } + } + + return { ok: true } } diff --git a/tests/unit/agentPoolPrompt.unit.spec.ts b/tests/unit/agentPoolPrompt.unit.spec.ts index 6d03ce99..f949bf99 100644 --- a/tests/unit/agentPoolPrompt.unit.spec.ts +++ b/tests/unit/agentPoolPrompt.unit.spec.ts @@ -25,10 +25,12 @@ describe('buildPoolWorkerPrompt', () => { expect(prompt).toContain('plano de intenção como contrato de engenharia') }) - it('pins the PR contract: base main, Closes #N, changelog entry, rebase auto-merge, CI watch', () => { + it('pins the PR contract: base main, Closes #N, changelog entry (no aggregate build), rebase auto-merge, CI watch', () => { expect(prompt).toContain('gh pr create --base main') expect(prompt).toContain('Closes #42') - expect(prompt).toContain('changelog:build') + expect(prompt).toContain('docs/changelog/-.md') + expect(prompt).toContain('não é commitado, OPS85') + expect(prompt).not.toContain('changelog:build') expect(prompt).toContain('gh pr merge --auto --rebase') expect(prompt).toContain('gh pr checks --watch') }) diff --git a/tests/unit/changelog.unit.spec.ts b/tests/unit/changelog.unit.spec.ts index 63746829..302328d5 100644 --- a/tests/unit/changelog.unit.spec.ts +++ b/tests/unit/changelog.unit.spec.ts @@ -5,10 +5,11 @@ import { describe, expect, it } from 'vitest' import { assertChangelogAppendOnly, buildChangelog, + CHANGELOG_AGGREGATE, + CHANGELOG_HISTORY, CHANGELOG_REWRITE_ESCAPE_RE, entryDateOf, listChangelogEntries, - missingLines, parseChangelogEntryName, splitChangelogBody, } from '../../scripts/lib/changelog.mjs' @@ -189,28 +190,6 @@ describe('buildChangelog', () => { }) }) -describe('missingLines', () => { - it('returns [] for a pure addition', () => { - expect(missingLines('a\nb\n', 'x\na\nb\n')).toEqual([]) - }) - - it('flags a removed line', () => { - expect(missingLines('a\nb\n', 'a\n')).toEqual(['b']) - }) - - it('treats a moved line as present (multiset, not position)', () => { - expect(missingLines('a\nb\nc\n', 'c\na\nb\n')).toEqual([]) - }) - - it('flags a line whose count decreases (duplicate lost)', () => { - expect(missingLines('a\na\n', 'a\n')).toEqual(['a']) - }) - - it('returns distinct missing lines', () => { - expect(missingLines('a\nb\nb\n', 'a\n')).toEqual(['b']) - }) -}) - describe('CHANGELOG_REWRITE_ESCAPE_RE', () => { it('matches a standalone changelog-rewrite line in a PR body', () => { expect( @@ -229,44 +208,73 @@ describe('CHANGELOG_REWRITE_ESCAPE_RE', () => { }) describe('assertChangelogAppendOnly', () => { - it('passes on a pure addition with no changelog file touched', () => { + it('passes on a pure addition in docs/changelog/ with no other changelog diff', () => { expect( assertChangelogAppendOnly({ - oldAggregate: 'a\nb\n', - newAggregate: 'a\nb\nc\n', changelogDiff: [{ status: 'A', path: 'docs/changelog/2026-08-13-ops44.md' }], + historyDiff: [], + aggregateDiff: [], }), ).toEqual({ ok: true }) }) - it('fails when an aggregate line disappears', () => { + it('passes on the OPS85 migration — aggregate removed together with HISTORY creation', () => { + expect( + assertChangelogAppendOnly({ + changelogDiff: [{ status: 'A', path: 'docs/changelog/2026-08-24-ops85.md' }], + historyDiff: [{ status: 'A', path: CHANGELOG_HISTORY }], + aggregateDiff: [{ status: 'D', path: CHANGELOG_AGGREGATE }], + }), + ).toEqual({ ok: true }) + }) + + it('fails when the aggregate is added or modified (it must never be committed)', () => { + for (const status of ['A', 'M']) { + const result = assertChangelogAppendOnly({ + changelogDiff: [], + historyDiff: [], + aggregateDiff: [{ status, path: CHANGELOG_AGGREGATE }], + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain('não deve ser commitado') + } + } + }) + + it('fails when the aggregate is removed without creating the HISTORY snapshot', () => { const result = assertChangelogAppendOnly({ - oldAggregate: 'a\nb\n', - newAggregate: 'a\n', changelogDiff: [], + historyDiff: [], + aggregateDiff: [{ status: 'D', path: CHANGELOG_AGGREGATE }], }) expect(result.ok).toBe(false) if (!result.ok) { - expect(result.message).toContain('não é append-only') + expect(result.message).toContain('removido sem criar') expect(result.message).toContain('changelog-rewrite') } }) - it('passes on an addition inside docs/changelog/', () => { - expect( - assertChangelogAppendOnly({ - oldAggregate: 'a\n', - newAggregate: 'a\nb\n', - changelogDiff: [{ status: 'A', path: 'docs/changelog/2026-08-13-ops44.md' }], - }), - ).toEqual({ ok: true }) + it('fails when the HISTORY snapshot is modified or deleted', () => { + for (const status of ['M', 'D']) { + const result = assertChangelogAppendOnly({ + changelogDiff: [], + historyDiff: [{ status, path: CHANGELOG_HISTORY }], + aggregateDiff: [], + }) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain('congelado') + expect(result.message).toContain('changelog-rewrite') + } + } }) it('fails when a docs/changelog file is modified', () => { const result = assertChangelogAppendOnly({ - oldAggregate: 'a\n', - newAggregate: 'a\nb\n', changelogDiff: [{ status: 'M', path: 'docs/changelog/2026-08-13-ops44.md' }], + historyDiff: [], + aggregateDiff: [], }) expect(result.ok).toBe(false) if (!result.ok) { @@ -277,9 +285,9 @@ describe('assertChangelogAppendOnly', () => { it('fails when a docs/changelog file is deleted', () => { const result = assertChangelogAppendOnly({ - oldAggregate: 'a\n', - newAggregate: 'a\n', changelogDiff: [{ status: 'D', path: 'docs/changelog/2026-08-12-b200.md' }], + historyDiff: [], + aggregateDiff: [], }) expect(result.ok).toBe(false) if (!result.ok) {