Merge pull request #431 from fsolla/worktree-plan-command

worktree: comando plan — worktree dedicado ao /plan-issue
This commit is contained in:
Francisco Solla 2026-08-08 16:08:41 -03:00 committed by GitHub
commit 819a015911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 116 additions and 11 deletions

View file

@ -11,6 +11,9 @@
# Uso (terminal interativo):
# worktree next [--go] cria/reutiliza o worktree da próxima Issue claimável;
# com --go, cd para dentro dele
# worktree plan [--go] cria/reutiliza o worktree de planejamento do /plan-issue
# (branch fixo plans/plan-issue, sem vínculo com a fila);
# com --go, cd para dentro dele
# worktree kill [--force]
#
# Read-only no GitHub — claim continua sendo `pnpm agent:claim`.

View file

@ -30,7 +30,7 @@ Parallel agents must not fight over port 3000, the shared `teqo_test`, or a seco
- `slot` = numeric part of the issue code (`C15` → 15, `B164` → 164; hash fallback without digits; bumped on collision among live worktrees).
- Dev server port `3100+slot`; databases `teqo_wt<slot>` (dev) and `teqo_wt<slot>_test` (tests), created on the **one shared container** with the full migration chain applied (one-time, ~30 s each).
- Writes `.env.local` (DATABASE_URL, PORT, NEXT_PUBLIC_SITE_URL, PLAYWRIGHT_BASE_URL, PAYLOAD_SECRET + Blob/VAPID copied from the main repo) and `.env.test.local` (test DATABASE_URL). Both carry the marker `# teqo worktree env (generated by pnpm worktree next)`; **manual env files are never overwritten** — remove them and re-run `pnpm worktree next` to regenerate.
- Writes `.env.local` (DATABASE_URL, PORT, NEXT_PUBLIC_SITE_URL, PLAYWRIGHT_BASE_URL, PAYLOAD_SECRET + Blob/VAPID copied from the main repo) and `.env.test.local` (test DATABASE_URL). Both carry the marker `# teqo worktree env (generated by pnpm worktree next)`; **manual env files are never overwritten** — remove them and re-run `pnpm worktree next` (or `pnpm worktree plan` for the /plan-issue worktree) to regenerate.
- `--no-migrate` skips the migration runs; `pnpm worktree kill` drops the worktree's generated databases (best-effort, `DROP DATABASE … WITH (FORCE)`).
- Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning and no port override.
- Pure derivation: `scripts/lib/worktree-env.mjs` (unit-tested in `tests/unit/worktree.unit.spec.ts`).

View file

@ -20,11 +20,14 @@ A lógica determinística (fila → código+slug → branch → worktree) vive e
```bash
pnpm worktree next [--go] [--no-migrate] # cria worktree de origin/main + provisiona o ambiente
pnpm worktree plan [--go] [--no-migrate] # worktree de PLANEJAMENTO (/plan-issue), branch fixo plans/plan-issue
pnpm worktree kill [--force] # destrói o worktree atual + remove seus bancos gerados
```
No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.opencode/commands/worktree.md`), que só repassa `$ARGUMENTS` para o script.
`plan` é o primo do `next`: worktree de **planejamento** (branch fixo `plans/plan-issue`) para rodar a skill `/plan-issue` sem ocupar o main. Não é nomeado por Issue nenhuma — proposital, para um `next` posterior da próxima Issue nunca colidir em branch nem em slot. O fluxo desta skill (fila → próxima Issue → implementação) é só `next`.
No terminal interativo, para `--go` trocar de diretório de verdade, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile): o script imprime `cd <dir>` e a função o aplica no shell que te chamou — node não consegue mudar o cwd do shell pai.
## Fluxo quando invocado como skill (agentes que não têm opencode)

View file

@ -1,10 +1,11 @@
---
description: Cria o worktree da próxima Issue claimável (next [--go]) ou destrói o worktree atual (kill [--force])
description: Cria o worktree da próxima Issue claimável (next [--go]), o worktree de planejamento do /plan-issue (plan [--go]) ou destrói o worktree atual (kill [--force])
---
Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa.
- `next [--go]`: cria (ou reutiliza) o worktree da próxima Issue claimável. Com `--go`, o script imprime `cd <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) e reporte o novo diretório.
- `plan [--go]`: cria (ou reutiliza) o worktree de planejamento (branch fixo `plans/plan-issue`, sem vínculo com a fila) para rodar `/plan-issue` sem ocupar o main; nunca conflita com `next`.
- `kill [--force]`: destrói o worktree em que você está.
!`node scripts/worktree.mjs $ARGUMENTS`

View file

@ -34,7 +34,7 @@ These are product/architecture constraints — not a ban on editing existing cod
- `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit.
- To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data).
- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory.
- **Per-worktree environments (`pnpm worktree next`, 2026-08-08):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--go]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt<slot>` (dev) and `teqo_wt<slot>_test` (tests) created on the shared container with the full migration chain applied; writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo_<worktree>_test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning.
- **Per-worktree environments (`pnpm worktree next`, 2026-08-08):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--go]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt<slot>` (dev) and `teqo_wt<slot>_test` (tests) created on the shared container with the full migration chain applied; writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. `worktree plan [--go]` (2026-08-08) is the sibling for the `/plan-issue` skill: same provisioning on the fixed branch `plans/plan-issue` (`PLAN_WORKTREE_BRANCH`, `scripts/lib/worktree.mjs`), deliberately **not** named after any Issue so a later `next` never collides in branch or slot. `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo_<worktree>_test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning.
- To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below.
- To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data (TSE 2022)" below.

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-08):** **`pnpm worktree plan [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: branch fixo `plans/plan-issue` (`PLAN_WORKTREE_BRANCH`, `scripts/lib/worktree.mjs`), deliberadamente **não** nomeado por Issue nenhuma — um `next` posterior da próxima Issue nunca colide em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Constant unit-testada.
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*<worktree>\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested).

View file

@ -8,6 +8,13 @@
import { slugify } from '../../src/lib/slug.ts'
/**
* Generic branch of the /plan-issue planning worktree (`pnpm worktree plan`).
* Deliberately NOT tied to any Issue: it must never collide with the
* `<code>-<slug>` branch `worktree next` derives for the next claimable Issue.
*/
export const PLAN_WORKTREE_BRANCH = 'plans/plan-issue'
/** Strip the leading `<code> — ` (or any dash variant) off a title. */
const stripCodePrefix = (title, code) => {
const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

View file

@ -17,6 +17,14 @@
* paralelo não disputarem porta 3000 nem o
* `teqo_test` compartilhado. `--no-migrate` pula a
* aplicação das migrations nos bancos novos.
* pnpm worktree plan [--go] [--no-migrate]
* cria/reutiliza o worktree de PLANEJAMENTO
* (branch fixo `plans/plan-issue`) para rodar a
* skill /plan-issue sem ocupar o main; NÃO é
* nomeado nem derivado de Issue nenhuma, então um
* `next` posterior da próxima Issue nunca colide
* (nem no branch, nem no slot). Mesmo provisionamento
* isolado do `next`.
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
* (recusa worktree sujo sem `--force`) e remove os
* bancos gerados do worktree (best-effort)
@ -41,7 +49,7 @@ import {
isGeneratedDatabaseName,
worktreeEnvironment,
} from './lib/worktree-env.mjs'
import { branchNameForIssue } from './lib/worktree.mjs'
import { branchNameForIssue, PLAN_WORKTREE_BRANCH } from './lib/worktree.mjs'
const die = dieAgent('worktree')
const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo')
@ -197,8 +205,10 @@ const writeFallbackEnv = (dir, branch) => {
* Deterministic per-worktree environment: shared container + own databases +
* own dev-server port + env files + migrations. Skips everything when a
* manual `.env.local`/`.env.test.local` (no marker) exists — never clobbers.
* `purpose` labels the env comment (`next` | `plan`); `issue` is optional and
* fills the `issue #N` part of the comment only when present.
*/
const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot }) => {
const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpose = 'next' }) => {
const manual =
(existsSync(join(dir, '.env.local')) && !envFileIsGenerated(dir, '.env.local')) ||
(existsSync(join(dir, '.env.test.local')) && !envFileIsGenerated(dir, '.env.test.local'))
@ -231,9 +241,12 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot }) =>
const payloadSecret = mainEnv.PAYLOAD_SECRET ?? randomBytes(24).toString('hex')
const copy = (key) => (mainEnv[key] ? [`${key}=${mainEnv[key]}`] : [])
const issueLabel = issue ? ` · issue #${issue.number}` : ''
const generatedBy = `gerado por pnpm worktree ${purpose}`
const devEnv = [
GENERATED_ENV_MARKER,
`# branch ${branch} · issue #${issue.number} · slot ${env.slot} — gerado por pnpm worktree next`,
`# branch ${branch}${issueLabel} · slot ${env.slot} — ${generatedBy}`,
`DATABASE_URL=${devUrl}`,
`PORT=${env.devPort}`,
`NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`,
@ -254,7 +267,7 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot }) =>
const testEnv = [
GENERATED_ENV_MARKER,
`# branch ${branch} · issue #${issue.number} · slot ${env.slot} — gerado por pnpm worktree next`,
`# branch ${branch}${issueLabel} · slot ${env.slot} — ${generatedBy}`,
'# Lido DEPOIS de .env.test (vitest.setup.ts / playwright.config.ts) — suites isoladas.',
`DATABASE_URL=${testUrl}`,
`NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`,
@ -321,6 +334,54 @@ const cmdNext = async (go, skipMigrate) => {
if (go) console.log(`cd ${dir}`)
}
/**
* `plan` — the /plan-issue planning worktree. Deliberately NOT tied to the
* claim queue and NOT named after any Issue: `PLAN_WORKTREE_BRANCH` is a fixed
* generic branch, so a later `pnpm worktree next` for the next claimable Issue
* never collides with it (branch name nor slot — the plan env registers its
* hashed slot via the marker and `next` bumps around it). Same isolated env
* provisioning as `next`.
*/
const cmdPlan = async (go, skipMigrate) => {
const branch = PLAN_WORKTREE_BRANCH
if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) {
die(`Branch plan inválido para refname: ${branch}`)
}
const dir = join(WORKTREES_ROOT, branch)
git(['fetch', 'origin'])
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
if (entries.some((entry) => resolve(entry.path) === resolve(dir))) {
console.log(`Worktree de planejamento já existe em ${dir} — reutilizando, sem duplicar.`)
} else {
const branchExists =
git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { okIfFails: true }) !== null
const flag = branchExists ? '-B' : '-b'
git(['worktree', 'add', flag, branch, dir, 'origin/main'])
console.log('Worktree de planejamento criado:')
console.log(` branch: ${branch}`)
console.log(` path: ${dir}`)
console.log(' origem: origin/main')
}
const mainRoot = entries[0]?.path
const env = worktreeEnvironment({
branch,
code: null,
takenSlots: readLiveSlots(entries, dir),
})
await provision({ dir, branch, env, skipMigrate, mainRoot, purpose: 'plan' })
console.log(`\nAmbiente isolado do worktree de planejamento (slot ${env.slot}):`)
console.log(` dev server: http://localhost:${env.devPort} (pnpm dev)`)
console.log(` banco dev: postgresql://teqo:teqo@localhost:5432/${env.devDatabase}`)
console.log(` banco test: postgresql://teqo:teqo@localhost:5432/${env.testDatabase}`)
if (go) console.log(`cd ${dir}`)
}
/** Generated database names referenced by a worktree's own env files. */
const worktreeDatabaseNamesOf = (dir) => {
const names = []
@ -405,12 +466,20 @@ const { flags, positional } = parseArgs(process.argv.slice(2), new Set())
const subcommand = positional[0]
if (!subcommand) {
console.log('Uso: pnpm worktree next [--go] [--no-migrate] | kill [--force]')
console.log(
'Uso: pnpm worktree next [--go] [--no-migrate] | plan [--go] [--no-migrate] | kill [--force]',
)
console.log(' next [--go] [--no-migrate]')
console.log(' cria worktree da próxima Issue claimável (branch <code>-<slug>) e provisiona o')
console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);')
console.log(' com --go imprime `cd <dir>` no fim (quem aplica o cd: opencode command, ou a')
console.log(' função `worktree()` de .agents/shell/worktree.sh); --no-migrate pula migrations')
console.log(' plan [--go] [--no-migrate]')
console.log(
' cria/reutiliza o worktree de planejamento (branch plans/plan-issue) para rodar a',
)
console.log(' skill /plan-issue sem ocupar o main nem travar na fila; provisiona o mesmo')
console.log(' ambiente isolado de `next`; nunca colide com o branch <code>-<slug> de `next`')
console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force) e')
console.log(' remove os bancos gerados do worktree (best-effort)')
process.exit(1)
@ -418,10 +487,11 @@ if (!subcommand) {
try {
if (subcommand === 'next') await cmdNext(Boolean(flags.go), Boolean(flags['no-migrate']))
else if (subcommand === 'plan') await cmdPlan(Boolean(flags.go), Boolean(flags['no-migrate']))
else if (subcommand === 'kill') {
if (flags.go) die('`--go` só faz sentido com `next`.')
await cmdKill(Boolean(flags.force))
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | kill)`)
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | plan | kill)`)
} catch (error) {
if (error?.stderr) die(error.stderr.toString().trim())
die(error?.message ?? String(error))

View file

@ -3,16 +3,20 @@
import { describe, expect, it } from 'vitest'
import {
GENERATED_ENV_MARKER,
devDatabaseForSlot,
devPortForSlot,
GENERATED_ENV_MARKER,
hashSlotOf,
isGeneratedDatabaseName,
numericSlotOfCode,
testDatabaseForSlot,
worktreeEnvironment,
} from '../../scripts/lib/worktree-env.mjs'
import { branchNameForIssue, issueCodeAndSubject } from '../../scripts/lib/worktree.mjs'
import {
branchNameForIssue,
issueCodeAndSubject,
PLAN_WORKTREE_BRANCH,
} from '../../scripts/lib/worktree.mjs'
type TestIssue = {
number: number
@ -125,6 +129,21 @@ describe('worktreeEnvironment (per-worktree ports and databases)', () => {
expect(testDatabaseForSlot(env.slot)).toBe(env.testDatabase)
})
it('plan worktree derives a stable hashed slot and never collides with `next`', () => {
expect(PLAN_WORKTREE_BRANCH).toBe('plans/plan-issue')
expect(PLAN_WORKTREE_BRANCH).not.toMatch(/^[A-Z][A-Za-z0-9]*-/)
const env = worktreeEnvironment({ branch: PLAN_WORKTREE_BRANCH, code: null })
expect(env.slot).toBe(hashSlotOf(PLAN_WORKTREE_BRANCH))
expect(numericSlotOfCode('')).toBeNull()
expect(env).toEqual(worktreeEnvironment({ branch: PLAN_WORKTREE_BRANCH, code: null }))
const bumped = worktreeEnvironment({
branch: PLAN_WORKTREE_BRANCH,
code: null,
takenSlots: new Set([env.slot]),
})
expect(bumped.slot).toBe(env.slot + 1)
})
it('caps huge codes into the hash range instead of absurd ports', () => {
const env = worktreeEnvironment({ branch: 'C999999-foo', code: 'C999999' })
expect(env.slot).toBeLessThanOrEqual(999)