From 06d34ed23b017decf4a3b7026fa170ee5de8b36f Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 10:44:16 -0300 Subject: [PATCH] =?UTF-8?q?OPS27:=20worktree=20new=20=E2=80=94=20worktree?= =?UTF-8?q?=20neutro=20sem=20fun=C3=A7=C3=A3o=20pr=C3=A9-definida=20(work/?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/shell/worktree.sh | 5 + .agents/skills/local-database/SKILL.md | 4 +- .agents/skills/worktree-next-issue/SKILL.md | 5 +- .opencode/commands/worktree.md | 3 +- AGENTS.md | 2 +- docs/CHANGELOG-AGENTS.md | 2 + .../worktree-new-worktree-generico-impl.md | 81 ++++++++++++ scripts/lib/worktree.mjs | 73 +++++++---- scripts/worktree.mjs | 121 +++++++++++++----- tests/unit/worktree.unit.spec.ts | 84 +++++++++++- 10 files changed, 318 insertions(+), 62 deletions(-) create mode 100644 docs/plans/worktree-new-worktree-generico-impl.md diff --git a/.agents/shell/worktree.sh b/.agents/shell/worktree.sh index 1fecdd9c..5efd229b 100644 --- a/.agents/shell/worktree.sh +++ b/.agents/shell/worktree.sh @@ -27,6 +27,11 @@ # já existir), sem bag o próximo plans/plan-issue- # sequencial; cd para dentro dele por padrão; --stay não # troca +# worktree new [bag] [--stay] cria um worktree NEUTRO (sem função pré-definida) +# DIFERENTE a cada chamada: com bag, branch work/ +# (sufixo -2/-3 se o nome já existir), sem bag o próximo +# work/ sequencial; cd para dentro dele por padrão; +# --stay não troca # worktree kill [--force] destrói o worktree atual e cd para o main por padrão # # `--go` explícito continua aceito como no-op (era o antigo padrão). diff --git a/.agents/skills/local-database/SKILL.md b/.agents/skills/local-database/SKILL.md index e7ec13c8..78b9d90e 100644 --- a/.agents/skills/local-database/SKILL.md +++ b/.agents/skills/local-database/SKILL.md @@ -26,11 +26,11 @@ VMs Cloud **não rodam** `docker compose` / `pnpm db:start`. Postgres nativo: `. ## Per-worktree environments (`pnpm worktree next`) -Parallel agents must not fight over port 3000, the shared `teqo_test`, or a second Postgres container. `pnpm worktree next [--stay]` provisions each worktree with a deterministic isolated env (`next`/`plan` print `cd ` by default — apply it, or pass `--stay` to suppress; `--go` is still accepted as a no-op): +Parallel agents must not fight over port 3000, the shared `teqo_test`, or a second Postgres container. `pnpm worktree next [--stay]` provisions each worktree with a deterministic isolated env (`next`/`plan`/`new` print `cd ` by default — apply it, or pass `--stay` to suppress; `--go` is still accepted as a no-op): - `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` (dev) and `teqo_wt_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, NEXT_PUBLIC_SITE_URL, **PLAYWRIGHT_BASE_URL** — both URL vars derived from the same slot port so a parallel e2e run never falls back to :3000; see `worktreeEnvFileContents` in `scripts/lib/worktree-env.mjs`). 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. +- 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, NEXT_PUBLIC_SITE_URL, **PLAYWRIGHT_BASE_URL** — both URL vars derived from the same slot port so a parallel e2e run never falls back to :3000; see `worktreeEnvFileContents` in `scripts/lib/worktree-env.mjs`). 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, `pnpm worktree new` for a neutral one) 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`). diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md index e85a4495..d4bc1037 100644 --- a/.agents/skills/worktree-next-issue/SKILL.md +++ b/.agents/skills/worktree-next-issue/SKILL.md @@ -21,14 +21,15 @@ A lógica determinística (fila → código+slug → branch → worktree) vive e ```bash pnpm worktree next [--stay] [--no-migrate] # cria worktree de origin/main + provisiona o ambiente pnpm worktree plan [bag] [--stay] [--no-migrate] # worktree de PLANEJAMENTO (/plan-issue): um DIFERENTE por invocação +pnpm worktree new [bag] [--stay] [--no-migrate] # worktree NEUTRO (sem função pré-definida): um DIFERENTE por invocação 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** para rodar a skill `/plan-issue` sem ocupar o main. **Cada invocação cria um DIFERENTE** (sessões paralelas): com `bag` opcional → branch `plans/plan-issue-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag` → próximo sequencial `plans/plan-issue-` livre. Não é nomeado por Issue nenhuma — proposital, para um `next` posterior da próxima Issue nunca colidir em branch nem em slot (prefixo minúsculo `plans/…` vs branch `-` sempre uppercase-led). O fluxo desta skill (fila → próxima Issue → implementação) é só `next`. +`plan` é o primo do `next`: worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main. **Cada invocação cria um DIFERENTE** (sessões paralelas): com `bag` opcional → branch `plans/plan-issue-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag` → próximo sequencial `plans/plan-issue-` livre. Não é nomeado por Issue nenhuma — proposital, para um `next` posterior da próxima Issue nunca colidir em branch nem em slot (prefixo minúsculo `plans/…` vs branch `-` sempre uppercase-led). `new` é o irmão **neutro** (sem função pré-definida — explorar ideia, conversar, planejar sem registrar): mesma semântica de "um DIFERENTE por invocação", branch `work/` (sufixo `-2`/`-3` em colisão) ou `work/` sequencial, prefixo `work/…` que não colide com `next` nem `plan`. O fluxo desta skill (fila → próxima Issue → implementação) é só `next`. -**Default-go:** `next` e `plan` imprimem `cd ` 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
` no fim (a sessão nunca fica num diretório destruído). +**Default-go:** `next`, `plan` e `new` imprimem `cd ` 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
` no fim (a sessão nunca fica num diretório destruído). **Launch do opencode (OPS26):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan` imprimem também a diretiva `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue` já enviado; `plan` abre sem prompt (`/plan-` + autocomplete do TUI). 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.** diff --git a/.opencode/commands/worktree.md b/.opencode/commands/worktree.md index 2c314b23..18a84e08 100644 --- a/.opencode/commands/worktree.md +++ b/.opencode/commands/worktree.md @@ -1,11 +1,12 @@ --- -description: Cria o worktree da próxima Issue claimável (next [--stay]), um worktree novo de planejamento do /plan-issue (plan [bag] [--stay]) ou destrói o worktree atual (kill [--force]) +description: Cria o worktree da próxima Issue claimável (next [--stay]), um worktree novo de planejamento do /plan-issue (plan [bag] [--stay]), um worktree neutro sem função pré-definida (new [bag] [--stay]) 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 [--stay]`: cria (ou reutiliza) o worktree da próxima Issue claimável. Por padrão o script imprime `cd ` na última linha — aplique-o na sessão de shell atual (`cd `) e reporte o novo diretório. `--stay` suprime o `cd` (não troque de diretório). `--go` explícito continua aceito como no-op. - `plan [bag] [--stay]`: cria um worktree de planejamento **diferente a cada invocação** (branch `plans/plan-issue-`, ou `plans/plan-issue-` sequencial sem bag; sufixo `-2`/`-3` em colisão) para rodar `/plan-issue` sem ocupar o main; nunca conflita com `next`. Mesmo contrato de `cd` do `next`: aplique a última linha `cd ` por padrão; `--stay` suprime. +- `new [bag] [--stay]`: cria um worktree **neutro** (sem função pré-definida — explorar ideia, conversar, planejar sem registrar nada) **diferente a cada invocação** (branch `work/`, ou `work/` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `work/…` nunca conflita com `next` nem `plan`. Mesmo contrato de `cd` do `plan`: aplique a última linha `cd ` por padrão; `--stay` suprime. - `kill [--force]`: destrói o worktree em que você está e remove seus bancos. O script imprime `cd
` na última linha — aplique-o (`cd
`) para a sessão sair do diretório destruído. > **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch do opencode (OPS26) acontece apenas na função `worktree()` de `.agents/shell/worktree.sh` (terminal interativo); nunca abra um TUI aninhado a partir daqui. diff --git a/AGENTS.md b/AGENTS.md index 8587605d..524e93e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--stay]` 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` (dev) and `teqo_wt_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. **`next`/`plan` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10): `next` opens the opencode TUI in the worktree with `/work-issue` already sent (the OPS25 command runs the whole cycle), `plan` opens with the same presets and NO prompt (`/plan-` + TUI autocomplete — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; gap registered for the opencode repo). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch, the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree\*\* so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, 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__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; default-go 2026-08-10):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--stay]` 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` (dev) and `teqo_wt_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. **`next`/`plan`/`new` print `cd ` as the last line BY DEFAULT** — the caller applies it (shell function `worktree()` in `.agents/shell/worktree.sh`, or the `/worktree` opencode command); `--stay` suppresses the cd, `--go` explicit stays accepted as a no-op (it was the old default), and `kill` ends with `cd
` so the shell never sits in a destroyed directory. **In the interactive terminal the shell function sets `TEQO_WORKTREE_TERMINAL=1` and `next`/`plan`/`new` additionally print a `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10): `next` opens the opencode TUI in the worktree with `/work-issue` already sent (the OPS25 command runs the whole cycle), `plan` and `new` open with the same presets and NO prompt (`plan`: `/plan-` + TUI autocomplete; `new`: "apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; gap registered for the opencode repo). The launch is without `exec` (exiting the TUI returns to the shell in the worktree, no auto-`kill`), `--stay` suppresses both the cd and the launch, the presets are constants in `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, unit-tested), and **the `/worktree` opencode command never launches a TUI** (no marker — no nested TUI). `worktree plan [bag] [--stay]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, so a later `next` never collides in branch or slot. `worktree new [bag] [--stay]` (OPS27, 2026-08-10) is the NEUTRAL sibling: a worktree with **no pre-defined function** (explore an idea, chat, plan without registering anything) — also a DIFFERENT one per invocation, branch `work/` (suffixed `-2`/`-3` on collision) or the next free sequential `work/` (`WORK_BRANCH_PREFIX`/`workBranchName`, same `namespaceBranchName` core as `plan`); the lowercase `work/…` prefix collides with neither `next` nor `plan`, and the provisioning is identical with `purpose: 'new'` (env comment `gerado por pnpm worktree new`; the frozen env marker keeps its historical `…next` text). `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__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. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 36e695af..b0f5b470 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-10):** **OPS26** (Issue #571) — `worktree next`/`plan` **no terminal** terminam com o TUI do opencode aberto no worktree: a função `worktree()` de `.agents/shell/worktree.sh` marca `TEQO_WORKTREE_TERMINAL=1` e o script imprime a diretiva `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` **antes** do `cd` (que segue sendo a última linha — contrato OPS24 intacto); a função aplica o cd e executa a linha (sem `exec`: sair do TUI volta ao shell no worktree; sem auto-`kill`). `next` envia `/work-issue` (o comando OPS25 roda o ciclo completo); `plan` abre **sem prompt** — a CLI do opencode não tem pre-fill sem submit (`--prompt` sempre auto-envia; fallback acordado: `/plan-` + autocomplete; gap registrado para o repo do opencode). Presets são constantes em `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, `opencodeLaunchDirective` — pura, unit-testada; trocar de modelo é editar a constante). **Sem o marcador (comando `/worktree` do opencode, automação) a diretiva não é impressa** — nunca abre TUI aninhado; `--stay` suprime cd e launch; `kill` intocado. Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue`. Sem migration/dados/access/UI. Intenção [`worktree-go-abre-opencode-com-presets.md`](plans/worktree-go-abre-opencode-com-presets.md); impl [`worktree-go-abre-opencode-com-presets-impl.md`](plans/worktree-go-abre-opencode-com-presets-impl.md). +**Recently resolved (2026-08-10):** **OPS27** (Issue #572) — `pnpm worktree new [bag] [--stay] [--no-migrate]`: worktree **neutro**, sem função pré-definida (explorar ideia, conversar, planejar sem registrar nada) — cada invocação cria UM DIFERENTE, como o `plan`: com `bag`, branch `work/` (sufixo `-2`/`-3` em colisão); sem `bag`, o próximo sequencial `work/` livre (`WORK_BRANCH_PREFIX`/`workBranchName`, `scripts/lib/worktree.mjs`). Provisionamento idêntico ao `plan` (`purpose: 'new'`; comentário `gerado por pnpm worktree new`; porta `3100+slot`, bancos próprios, migrations, `--no-migrate` respeitado); herda o default-go da OPS24 (`cd ` na última linha, `--stay` suprime) e `kill` funciona de dentro; **no terminal, herda o launch da OPS26 prompt-less** (`new: null` no `OPENCODE_SKILL_COMMAND_BY_PURPOSE` — "apenas conversar", nenhuma skill enviada). **Decisões de engenharia:** o molde `cmdPlan` virou o runner compartilhado `cmdNamespaceBranch` (`scripts/worktree.mjs` — fetch → taken-set → add → provision → launch+cd; `cmdPlan` refatorado sem mudança de comportamento, `printLaunchDirective` parametrizado por `purpose`); a nomeação de branch generalizou num helper interno `namespaceBranchName` (a matemática de sufixo/sequencial/truncamento do `plan` é idêntica à do `new` — `planBranchName` vira wrapper fino, contrato e testes intactos); **`GENERATED_ENV_MARKER` continua congelado** no texto histórico `…worktree next` (unit-pinado e usado por `readLiveSlots`/`worktreeDatabaseNamesOf` para detectar envs vivas — o propósito real sai na linha `generatedBy` do header). Superfícies: `worktree.sh` + comando `/worktree` do opencode (genéricos, só ganharam o subcomando no help), AGENTS.md, skills `worktree-next-issue`/`local-database`. Unit novos de `workBranchName` (10 casos: sequencial, bag, colisão, disjunção com next/plan, fallback, truncamento, budget do sufixo) + `new` prompt-less no launch + verificação manual do caminho feliz (criação e kill de um `work/…` descartável). Sem migration/dados/access; libs puras e marker intocados. Intenção [`worktree-new-worktree-generico.md`](plans/worktree-new-worktree-generico.md); impl [`worktree-new-worktree-generico-impl.md`](plans/worktree-new-worktree-generico-impl.md). + **Recently resolved (2026-08-10):** **OPS24** (Issue #569) — `--go` virou o **padrão** do worktree CLI e `kill` volta ao main: `worktree next`/`plan` imprimem `cd ` na última linha **por padrão** (antes exigiam `--go`); `--stay` suprime; `--go` explícito continua aceito como no-op (não quebra hábito/automação). `kill` troca a mensagem "Volte ao main: cd …" (que nunca casava com o sed `s/^cd //p` da função shell) pela **linha máquina `cd
` como última linha** — depois do drop dos DBs, que rodava antes — e **não aceita `--stay`** (ficar em cwd destruído é exatamente o bug corrigido; `--go` no kill é no-op). Vale nas duas superfícies que aplicam o cd: função `worktree()` de `.agents/shell/worktree.sh` (agora sempre captura/applica, `--stay` retorna cedo) e comando `/worktree` do opencode (agente aplica o `cd` da última linha sempre, exceto `--stay`). Docs: header/uso do script, AGENTS.md ("Per-worktree environments"), skills `worktree-next-issue`/`local-database`. Sem migration/dados/access; derivação pura (`worktree-env.mjs`/`worktree.mjs` libs) intocada. Intenção [`worktree-go-default-e-kill-volta-ao-main.md`](plans/worktree-go-default-e-kill-volta-ao-main.md); impl [`worktree-go-default-e-kill-volta-ao-main-impl.md`](plans/worktree-go-default-e-kill-volta-ao-main-impl.md). **Recently resolved (2026-08-10):** **OPS25** (Issue #570) — atalhos diretos no TUI do opencode: `.opencode/commands/work-issue.md` e `plan-issue.md` (mecanismo nativo de commands, mesmo padrão de `issue.md`/`worktree.md`). O frontmatter leva `description` pt-BR (autocomplete do TUI) + `model: deepseek/deepseek-v4-flash` (pin do modelo de preferência, alinhado ao launch da OPS26 com `--model`); o corpo instrui carregar a skill de **mesmo nome** pela ferramenta de skills e segui-la ponta a ponta, apontando a fonte canônica `.agents/skills//SKILL.md` — **sem transcrever** (anti-goal da intenção) — com `$ARGUMENTS` repassado no fim. **Guard de acoplamento** `tests/unit/opencodeCommands.unit.spec.ts`: comando↔skill presos por nome exato (renome de skill → build falha em vez de comando quebrado silencioso). Sem migration/access/Consent/UI. Intenção [`opencode-comandos-work-issue-e-plan-issue.md`](plans/opencode-comandos-work-issue-e-plan-issue.md); impl [`opencode-comandos-work-issue-e-plan-issue-impl.md`](plans/opencode-comandos-work-issue-e-plan-issue-impl.md). diff --git a/docs/plans/worktree-new-worktree-generico-impl.md b/docs/plans/worktree-new-worktree-generico-impl.md new file mode 100644 index 00000000..05074b13 --- /dev/null +++ b/docs/plans/worktree-new-worktree-generico-impl.md @@ -0,0 +1,81 @@ +# Impl: Worktree `new`: worktree genérico sem função pré-definida + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #572 +Intenção: docs/plans/worktree-new-worktree-generico.md +Appetite restante: ~0,5–1 dia eng (herdado — scripts/docs/testes, sem runtime nem DB de dados) + +## Leitura da intenção + +- **Outcome:** `worktree new [bag]` cria um worktree **neutro** a partir de `origin/main` com branch `work/` (colisão → sufixo `-2`/`-3`) ou `work/` sequencial livre, provisionado de forma idêntica ao `plan` (slot/porta/bancos próprios, migrations aplicadas, `--no-migrate` respeitado, comentário `gerado por pnpm worktree new`), cada invocação cria UM DIFERENTE, herda o default-go da OPS24 (`--stay` suprime, `--go` no-op), `kill` funciona de dentro dele, help/uso + AGENTS.md atualizados. +- **O que NÃO negociar:** prefixo minúsculo `work/…` que nunca colide com `-` do `next` nem com `plans/plan-issue-…` do `plan`; provisionamento idêntico ao `plan`; `new` **não** é um segundo `plan` (sem função de planejamento embutida); OPS26 (launch do opencode) é dependência **opcional e não está em main** — o `new` nasce só com o default-go, sem duplicar o launch. +- **O que reavaliar:** + - A hipótese "generalizar `planBranchName` se valer a pena" — **vale**: a matemática de sufixo de colisão (`-2`/`-3`), sequencial livre e truncamento é idêntica e sutil entre os dois namespaces; um helper único impede drift (mesma família de guarda que o repo já aplica nos prefixos next/plan). + - **`GENERATED_ENV_MARKER` é congelado:** o unit test pina o texto exato `generated by pnpm worktree next` e `readLiveSlots`/`worktreeDatabaseNamesOf`/`envFileIsGenerated` dependem dele para detectar envs geradas (inclusive de worktrees `next` vivos). O `new` **não** parametriza o marker — o propósito real aparece na linha `generatedBy` do header (`gerado por pnpm worktree new`), que o `provision` já deriva de `purpose`. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[pnpm worktree new [bag]] --> B[namespaceBranchName: work/ ou work/] + A2[pnpm worktree plan [bag]] --> B2[planBranchName: plano existente] + B --> C[runner compartilhado cmdNamespaceBranch: fetch → taken-set → add → provision → cd] + B2 --> C + C --> D[worktree.mjs: purpose 'new'/'plan' + linhas cd por padrão] + D --> E[superfícies: worktree.sh + /worktree opencode aplicam cd] +``` + +**Decisão 1 — nomeação de branch (`scripts/lib/worktree.mjs`).** +Opções: A) extrair helper interno `namespaceBranchName({ prefix, bag, taken, maxLength, fallback })`; `planBranchName` vira wrapper fino (contrato + testes intactos) e novo `workBranchName` com `WORK_BRANCH_PREFIX = 'work'`; B) parametrizar `planBranchName` com prefixo opcional; C) duplicar a lógica em `workBranchName`. +**Recomendação: A** — a matemática de colisão/sequencial/truncamento é idêntica e sutil; um único lugar evita drift (os dois namespaces vivem no mesmo arquivo há uma semana de evolução ativa). **Rejeitadas:** B — `planBranchName` produzindo `work/…` mente no nome e obriga default paramétrico; C — duplica ~25 linhas de truncamento delicado (budget 60, `keep` mínimo 1). +Detalhes do contrato `workBranchName`: sem bag → próximo `work/` sequencial livre (começa em `work/1`); com bag → `work/`; colisão → `work/-2`, `-3`, …; slug vazio → fallback `'work'` (o `plan` preserva o fallback `'plano'`); bag numérico (`work new 3`) compartilha o namespace sequencial (precedente uniforme do plan); budget total 60 (espelha `branchNameForIssue`/`PLAN_BRANCH_MAX_LENGTH`); o prefixo `work/` é lowercase-led → estruturalmente disjunto do `-` uppercase-led e do `plans/plan-issue-…`. + +**Decisão 2 — `cmdNew` (`scripts/worktree.mjs`).** +Opções: A) extrair de `cmdPlan` o runner compartilhado `cmdNamespaceBranch({ purpose, branchName, label })` (fetch → taken-set → `check-ref-format` → `worktree add` → `provision` → summary do ambiente → `cd`), com `cmdPlan` refatorado para o runner e `cmdNew` como segundo call site; B) clonar `cmdPlan` em `cmdNew` (~60 linhas); C) mode flag dentro de `cmdPlan`. +**Recomendação: A** — o CLI está em evolução ativa (OPS24/OPS26/OPS27 na mesma semana); duplicar ~60 linhas de git/provisionamento drifta. **Rejeitadas:** B — duplicação cara de manter; C — embaralha propósito em flag e cresce o teste mental do dispatch. + +**Decisão 3 — marker de env.** +Opções: A) `GENERATED_ENV_MARKER` inalterado (texto histórico `…next`) para todos os purposes; B) parametrizar o marker por purpose. +**Recomendação: A** — o marker é contrato congelado e pinado: `readLiveSlots`, `worktreeDatabaseNamesOf`, `envFileIsGenerated` e o unit test dependem do texto exato; mudar quebraria a detecção de envs de worktrees `next` vivos e exigiria "migração" de marker. O comentário real por-worktree já diz o propósito (`generatedBy`). **Rejeitada:** B. + +### Componentes / mudanças + +- **`scripts/lib/worktree.mjs`**: helper interno `namespaceBranchName`; exports `WORK_BRANCH_PREFIX = 'work'` e `workBranchName`; `planBranchName` refatorado para wrapper (contrato idêntico, testes intactos). +- **`scripts/worktree.mjs`**: runner `cmdNamespaceBranch` extraído de `cmdPlan` (taken-set, branch, add, provision, summary, cd); `cmdNew(stay, skipMigrate, bag)` com `purpose: 'new'`; dispatch com `new`; bloco de uso (`pnpm worktree` sem args) e docblock do header atualizados (`new [bag] [--stay] [--no-migrate]`). +- **`tests/unit/worktree.unit.spec.ts`**: describe novo `workBranchName` — sequencial livre (skip de taken), bag → `work/`, colisão `-2`/`-3`, slug vazio → fallback, bag numérico compartilha o namespace, truncamento ≤60, nunca colide com `next` nem `plan` (regex de lowercase-led), invocações paralelas → branches diferentes. +- **`.agents/shell/worktree.sh`**: header de uso com `new [bag] [--stay]` (a função já é genérica — repassa args e aplica o `cd`). +- **`.opencode/commands/worktree.md`**: frontmatter `description` + corpo com `new [bag] [--stay]` (mesmo contrato de `cd` do `plan`). +- **`AGENTS.md`** (seção "Per-worktree environments"): subcomando `new [bag]` + namespace `work/…` + `purpose: 'new'`. +- **`.agents/skills/worktree-next-issue/SKILL.md`**: linha dos subcomandos com `new [bag] [--stay] [--no-migrate]`. +- **`docs/CHANGELOG-AGENTS.md`**: entrada curta da entrega. +- **Migration:** sem migration (nenhum schema). **Access / Consent:** N/A — CLI de dev fora do runtime. **UI:** Impeccable A — N/A (sem superfície de produto). + +## Fases verificáveis + +1. **Lib pura + testes** — `namespaceBranchName`/`workBranchName`/`WORK_BRANCH_PREFIX` + `workBranchName.unit.spec.ts`; `pnpm test:unit` no arquivo. +2. **Runner + comando** — `cmdNamespaceBranch` (refactor de `cmdPlan`) + `cmdNew` + dispatch + uso + docblock; superfícies (shell/opencode) + docs (AGENTS.md, skill, CHANGELOG). +3. **Verificação manual do caminho feliz** — um `worktree new teste-gate` real (cria worktree + bancos + migrations no container local), conferir branch `work/teste-gate`, env com `gerado por pnpm worktree new` e porta própria, e derrubar com `pnpm worktree kill --force` de dentro (valida também o `kill` num branch `work/…`). `pnpm worktree` sem args mostra o `new` no uso. +4. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` na entrega (format/knip/cycles/test/build). + +## Rabbit holes / Não escopo (engenharia) + +- **Launch do opencode no `new`**: OPS26 é o dono e não está em main — o `new` nasce com default-go; a superfície é a mesma que a OPS26 estenderá (nada a construir). +- Não tocar `scripts/lib/worktree-env.mjs` nem `GENERATED_ENV_MARKER` (pinados por teste). +- Não criar terceira função embutida (`worktree new --issue` etc.) — para isso existem `next` e `plan`. +- Não renomear/normalizar branches `work/…` manuais existentes nem "migrar" markers de envs vivas. +- Sem e2e: CLI fora do runtime; cobertura = unit da lib pura + verificação manual descartável. + +## Riscos e mitigação + +- **Refatorar `cmdPlan` introduz regressão no fluxo existente** — mitigação: extração com diff minimal (movimentação pura de corpo + parâmetros `purpose`/`label`/branch fn), mensagens de saída idênticas; conferir por diff e pelo caminho feliz do `new` (compartilha o runner). +- **`work/…` colide com branch manual do usuário** — taken-set local+origin já cobre (sufixo `-2`/`-3`); comportamento idêntico ao `plan`, documentado. +- **Slot do `new` colide com worktrees vivos** — herdado: `readLiveSlots` + bump no `worktreeEnvironment` (já testado). +- **Texto do marker `next` em envs de `new` confundir leitura** — a linha `generatedBy` carrega o propósito real; registrado neste impl plan. +- **`worktree new` sem Docker degrada para env compartilhado** — herdado do `provision` (mesmo aviso do `next`/`plan`). + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (`new [bag]` de `origin/main`; branch `work/`/`work/` sem colisão; provision igual ao `plan` com `purpose: 'new'` e `--no-migrate` respeitado; default-go/`--stay`; `kill` funciona; uso + AGENTS.md atualizados) +- [ ] Invariantes AGENTS/engineering-standards (marker e `worktree-env.mjs` intocados; nenhum DB de prod; libs puras unit-testadas) +- [ ] Testes de domínio previstos: `workBranchName` (unit) + caminho feliz manual descartável diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index ca10b41e..7f337ab4 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -25,15 +25,23 @@ export const OPENCODE_PRESET_MODEL = 'deepseek/deepseek-v4-flash' /** * Skill command sent as the launch's initial message per purpose. `next` * sends `/work-issue` (the OPS25 command executes the full cycle); `plan` - * sends nothing — the opencode CLI's `--prompt` always auto-submits, there is - * no prefill-without-submit flag, and the TUI autocomplete completes `/plan-` - * instead (gap registered for the opencode repo). Switch `plan` to - * `/plan-issue` here if the CLI ever gains prefill-without-submit. + * and `new` send nothing — the opencode CLI's `--prompt` always auto-submits, + * there is no prefill-without-submit flag, and the TUI autocomplete completes + * `/plan-` instead (`new` is just "apenas conversar", no skill at all; gap + * registered for the opencode repo). Switch `plan` to `/plan-issue` here if + * the CLI ever gains prefill-without-submit. */ -export const OPENCODE_SKILL_COMMAND_BY_PURPOSE = { next: '/work-issue', plan: null } +export const OPENCODE_SKILL_COMMAND_BY_PURPOSE = { next: '/work-issue', plan: null, new: null } -/** Total branch-name budget for plan branches — mirrors `branchNameForIssue`. */ -const PLAN_BRANCH_MAX_LENGTH = 60 +/** + * Prefix of every neutral-worktree branch (`pnpm worktree new`). Lowercase-led + * `work/…` — structurally disjoint from `next`'s uppercase-led `-` + * and from `plan`'s `plans/plan-issue-…`, in branch name and slot space alike. + */ +export const WORK_BRANCH_PREFIX = 'work' + +/** Total branch-name budget — mirrors `branchNameForIssue` (60). */ +const NAMESPACE_BRANCH_MAX_LENGTH = 60 /** Strip the leading ` — ` (or any dash variant) off a title. */ const stripCodePrefix = (title, code) => { @@ -71,37 +79,38 @@ export const branchNameForIssue = (issue, maxLength = 60) => { } /** - * Branch for a `/plan-issue` planning worktree. Every invocation must land on - * a DIFFERENT branch so parallel planning sessions coexist: - * - `bag` given → `plans/plan-issue-`; if that name is already - * taken, `plans/plan-issue--2`, `-3`, … - * - no `bag` → the next free sequential `plans/plan-issue-1`, `-2`, `-3`, … - * `taken` = branch short-names already alive (local refs + origin); when a - * name is free it is reused only as a name-free slot — the branch is created - * fresh from `origin/main` each time. The lowercase `plans/…` prefix cannot - * collide with `next`'s uppercase-led `-` branches. + * Shared branch naming for namespace worktrees NOT tied to the claim queue + * (`plan`, `new`). Every invocation must land on a DIFFERENT branch so + * parallel sessions coexist: + * - `bag` given → ``; if that name is already taken, + * `-2`, `-3`, … + * - no `bag` → the next free sequential `1`, `2`, … + * `prefix` carries the namespace AND its separator (`plans/plan-issue-` for + * plan, `work/` for new), so the sequential and bag forms share one spelling + * per namespace. `taken` = branch short-names already alive (local refs + + * origin); when a name is free it is reused only as a name-free slot — the + * branch is created fresh from `origin/main` each time. Both namespaces are + * lowercase-led, so neither can collide with `next`'s uppercase-led + * `-` branches. */ -export const planBranchName = ({ bag = '', taken = new Set() }) => { +const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback }) => { const hasBag = typeof bag === 'string' && bag.trim().length > 0 if (!hasBag) { for (let n = 1; ; n += 1) { - const candidate = `${PLAN_BRANCH_PREFIX}-${n}` + const candidate = `${prefix}${n}` if (!taken.has(candidate)) return candidate } } - const slug = slugify(bag) || 'plano' - const base = `${PLAN_BRANCH_PREFIX}-${slug.slice( - 0, - PLAN_BRANCH_MAX_LENGTH - PLAN_BRANCH_PREFIX.length - 1, - )}` + const slug = slugify(bag) || fallback + const base = `${prefix}${slug.slice(0, NAMESPACE_BRANCH_MAX_LENGTH - prefix.length)}` if (!taken.has(base)) return base for (let n = 2; ; n += 1) { const suffix = `-${n}` - const keep = Math.max(1, PLAN_BRANCH_MAX_LENGTH - PLAN_BRANCH_PREFIX.length - 1 - suffix.length) - const candidate = `${PLAN_BRANCH_PREFIX}-${slug.slice(0, keep)}${suffix}` + const keep = Math.max(1, NAMESPACE_BRANCH_MAX_LENGTH - prefix.length - suffix.length) + const candidate = `${prefix}${slug.slice(0, keep)}${suffix}` if (!taken.has(candidate)) return candidate } } @@ -123,3 +132,17 @@ export const opencodeLaunchDirective = ({ dir, purpose, terminal = false }) => { if (prompt) args.push('--prompt', prompt) return `launch opencode ${args.join(' ')}` } + +/** + * Branch for a `/plan-issue` planning worktree — see `namespaceBranchName` + * (namespace `plans/plan-issue-…`, fallback label `plano`). + */ +export const planBranchName = ({ bag = '', taken = new Set() }) => + namespaceBranchName({ prefix: `${PLAN_BRANCH_PREFIX}-`, bag, taken, fallback: 'plano' }) + +/** + * Branch for a neutral worktree (`pnpm worktree new`) — see + * `namespaceBranchName` (namespace `work/…`, fallback label `work`). + */ +export const workBranchName = ({ bag = '', taken = new Set() }) => + namespaceBranchName({ prefix: `${WORK_BRANCH_PREFIX}/`, bag, taken, fallback: 'work' }) diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index 1f885bc7..ed2b5fed 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -44,8 +44,21 @@ * terminal, mesma diretiva `launch` — porém SEM * `--prompt` (a CLI do opencode não tem pre-fill * sem submit; `/plan-` + autocomplete do TUI). - * `--stay` suprime a linha `cd`; `--go` explícito - * continua aceito como no-op (era o antigo padrão). + * pnpm worktree new [bag] [--stay] [--no-migrate] + * cria um worktree NEUTRO novo — sem função + * pré-definida (explorar ideia, conversar, ou + * planejar sem registrar nada): cada invocação + * cria UM DIFERENTE; com `bag`, branch + * `work/` (e `-2`, `-3`, … se o nome já + * estiver vivo); sem `bag`, o próximo sequencial + * `work/` livre. Prefixo minúsculo `work/…` — + * nunca colide com `-` de `next` nem + * com `plans/plan-issue-…` de `plan`. + * Mesmo provisionamento isolado do `next`/`plan`; + * no terminal, mesma diretiva `launch` — SEM + * `--prompt` (apenas conversar, nenhuma skill). + * `--stay` suprime a linha `cd` e a diretiva; + * `--go` explícito continua aceito como no-op. * 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); por @@ -79,6 +92,7 @@ import { branchNameForIssue, opencodeLaunchDirective, planBranchName, + workBranchName, WORKTREE_TERMINAL_ENV, } from './lib/worktree.mjs' @@ -250,7 +264,7 @@ 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 + * `purpose` labels the env comment (`next` | `plan` | `new`); `issue` is optional and * fills the `issue #N` part of the comment only when present. */ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpose = 'next' }) => { @@ -373,22 +387,8 @@ const cmdNext = async (stay, skipMigrate) => { } } -/** - * `plan` — a `/plan-issue` planning worktree. Deliberately NOT tied to the - * claim queue and NOT named after any Issue: the branch is derived either from - * an optional `bag` slug (`plans/plan-issue-`, suffixed `-2`, `-3`, … on - * collision) or from the next free sequential `plans/plan-issue-`, so a - * later `pnpm worktree next` for the next claimable Issue never collides with - * it (branch name nor slot — each plan env registers its hashed slot via the - * marker and `next` bumps around it). Every invocation creates a DIFFERENT - * worktree — parallel `/plan-issue` sessions never share one. Same isolated - * env provisioning as `next`. - */ -const cmdPlan = async (stay, skipMigrate, bag) => { - git(['fetch', 'origin']) - - const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain'])) - +/** Branch short-names already alive — local refs plus origin (shortened). */ +const buildTakenBranchNames = () => { const taken = new Set() for (const scope of [ ( @@ -406,24 +406,38 @@ const cmdPlan = async (stay, skipMigrate, bag) => { ]) { for (const name of scope) if (name) taken.add(name) } + return taken +} - const branch = planBranchName({ bag, taken }) +/** + * Shared runner for namespace worktrees NOT tied to the claim queue (`plan` + * and `new`): fetches origin, picks a FRESH branch in the namespace (via + * `branchName`), provisions the same isolated env as `next` (with `purpose`) + * and prints the `cd ` line by default (`--stay` suppresses). Every + * invocation creates a DIFFERENT worktree — parallel sessions never share one. + */ +const cmdNamespaceBranch = async ({ stay, skipMigrate, purpose, noun, sessionLabel, branchName }) => { + git(['fetch', 'origin']) + + const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain'])) + const taken = buildTakenBranchNames() + + const branch = branchName(taken) if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) { - die(`Branch plan inválido para refname: ${branch}`) + die(`Branch ${purpose} inválido para refname: ${branch}`) } const dir = join(WORKTREES_ROOT, branch) if (entries.some((entry) => resolve(entry.path) === resolve(dir))) { die( - `Já existe um worktree de planejamento em ${dir} (branch ${branch} não detectado nos refs). Rode \`pnpm worktree kill\` de dentro dele.`, + `Já existe um worktree ${noun} em ${dir} (branch ${branch} não detectado nos refs). Rode \`pnpm worktree kill\` de dentro dele.`, ) } - const label = bag && bag.trim() ? `lote "${bag}"` : 'sequencial' git(['worktree', 'add', '-b', branch, dir, 'origin/main']) - console.log('Worktree de planejamento criado:') - console.log(` sessão: ${label}`) + console.log(`Worktree ${noun} criado:`) + console.log(` sessão: ${sessionLabel}`) console.log(` branch: ${branch}`) console.log(` path: ${dir}`) console.log(' origem: origin/main') @@ -434,19 +448,58 @@ const cmdPlan = async (stay, skipMigrate, bag) => { code: null, takenSlots: readLiveSlots(entries, dir), }) - await provision({ dir, branch, env, skipMigrate, mainRoot, purpose: 'plan' }) + await provision({ dir, branch, env, skipMigrate, mainRoot, purpose }) - console.log(`\nAmbiente isolado do worktree de planejamento (slot ${env.slot}):`) + console.log(`\nAmbiente isolado do worktree ${noun} (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 (!stay) { - printLaunchDirective({ dir, purpose: 'plan' }) + printLaunchDirective({ dir, purpose }) console.log(`cd ${dir}`) } } +/** + * `plan` — a `/plan-issue` planning worktree. Deliberately NOT tied to the + * claim queue and NOT named after any Issue: the branch is derived either from + * an optional `bag` slug (`plans/plan-issue-`, suffixed `-2`, `-3`, … on + * collision) or from the next free sequential `plans/plan-issue-`, so a + * later `pnpm worktree next` for the next claimable Issue never collides with + * it (branch name nor slot — each plan env registers its hashed slot via the + * marker and `next` bumps around it). Same isolated env provisioning as + * `next`. + */ +const cmdPlan = async (stay, skipMigrate, bag) => + cmdNamespaceBranch({ + stay, + skipMigrate, + purpose: 'plan', + noun: 'de planejamento', + sessionLabel: bag && bag.trim() ? `lote "${bag}"` : 'sequencial', + branchName: (taken) => planBranchName({ bag, taken }), + }) + +/** + * `new` — a NEUTRAL worktree with no pre-defined function (explore an idea, + * chat, or plan without registering anything). Deliberately NOT tied to the + * claim queue nor to `/plan-issue`: with `bag`, branch `work/` (suffixed + * `-2`, `-3`, … on collision); without, the next free sequential `work/`. + * The lowercase `work/…` prefix can never collide with a `next` branch + * (uppercase-led `-`) nor with a `plan` branch (`plans/…`), in + * branch name or slot space. Same isolated env provisioning as `plan`. + */ +const cmdNew = async (stay, skipMigrate, bag) => + cmdNamespaceBranch({ + stay, + skipMigrate, + purpose: 'new', + noun: 'neutro', + sessionLabel: bag && bag.trim() ? `bag "${bag}"` : 'sequencial', + branchName: (taken) => workBranchName({ bag, taken }), + }) + /** Generated database names referenced by a worktree's own env files. */ const worktreeDatabaseNamesOf = (dir) => { const names = [] @@ -533,7 +586,7 @@ const subcommand = positional[0] if (!subcommand) { console.log( - 'Uso: pnpm worktree next [--stay] [--no-migrate] | plan [bag] [--stay] [--no-migrate] | kill [--force]', + 'Uso: pnpm worktree next [--stay] [--no-migrate] | plan [bag] [--stay] [--no-migrate] | new [bag] [--stay] [--no-migrate] | kill [--force]', ) console.log(' next [--stay] [--no-migrate]') console.log(' cria worktree da próxima Issue claimável (branch -) e provisiona o') @@ -555,6 +608,12 @@ if (!subcommand) { ' minúsculo plans/… nunca colide com o branch - de `next`; no terminal,', ) console.log(' mesma diretiva `launch` porém sem --prompt (autocomplete completa /plan-)') + console.log(`\n new [bag] [--stay] [--no-migrate]`) + console.log(' cria um worktree NEUTRO (sem função pré-definida) DIFERENTE a cada invocação:') + console.log(' com bag, branch work/ (sufixo -2/-3 se o nome já existir); sem bag, o') + console.log(' próximo work/ sequencial livre; o prefixo minúsculo work/… nunca colide com') + console.log(' o branch - de `next` nem com plans/plan-issue-… de `plan`; no') + console.log(' terminal, mesma diretiva `launch` porém sem --prompt (apenas conversar)') console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force),') console.log(' remove os bancos gerados do worktree (best-effort) e imprime') console.log(' `cd
` no fim — o shell sempre volta ao worktree principal') @@ -565,10 +624,12 @@ try { if (subcommand === 'next') await cmdNext(Boolean(flags.stay), Boolean(flags['no-migrate'])) else if (subcommand === 'plan') await cmdPlan(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1]) + else if (subcommand === 'new') + await cmdNew(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1]) else if (subcommand === 'kill') { if (flags.stay) die('`--stay` não se aplica a `kill` — ele sempre volta ao main.') await cmdKill(Boolean(flags.force)) - } else die(`subcomando desconhecido: ${subcommand} (esperado: next | plan | kill)`) + } else die(`subcomando desconhecido: ${subcommand} (esperado: next | plan | new | kill)`) } catch (error) { if (error?.stderr) die(error.stderr.toString().trim()) die(error?.message ?? String(error)) diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index 695b50e7..89b1d35f 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -21,6 +21,8 @@ import { opencodeLaunchDirective, PLAN_BRANCH_PREFIX, planBranchName, + WORK_BRANCH_PREFIX, + workBranchName, WORKTREE_TERMINAL_ENV, } from '../../scripts/lib/worktree.mjs' @@ -152,10 +154,16 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26)', () => ) }) + it('new launches prompt-less too — "apenas conversar", no skill sent', () => { + expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true })).toBe( + `launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto`, + ) + }) + it('pins the preset constants — changing the model is editing a constant', () => { expect(OPENCODE_PRESET_MODEL).toBe('deepseek/deepseek-v4-flash') expect(WORKTREE_TERMINAL_ENV).toBe('TEQO_WORKTREE_TERMINAL') - expect(OPENCODE_SKILL_COMMAND_BY_PURPOSE).toEqual({ next: '/work-issue', plan: null }) + expect(OPENCODE_SKILL_COMMAND_BY_PURPOSE).toEqual({ next: '/work-issue', plan: null, new: null }) }) it('an unknown purpose degrades to a prompt-less launch (fail-safe direction)', () => { @@ -165,6 +173,80 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26)', () => }) }) +describe('workBranchName (per-invocation neutral worktrees)', () => { + it('no bag → first free sequential work/', () => { + expect(workBranchName({})).toBe('work/1') + expect(workBranchName({ bag: '' })).toBe('work/1') + expect(workBranchName({ bag: ' ' })).toBe('work/1') + }) + + it('no bag → skips taken sequential names (parallel sessions)', () => { + const taken = new Set(['work/1', 'work/2']) + expect(workBranchName({ taken })).toBe('work/3') + }) + + it('named bag → work/ when free', () => { + expect(workBranchName({ bag: 'Ideia solta' })).toBe('work/ideia-solta') + }) + + it('named bag whose name is taken → suffixed -2, -3, …', () => { + const taken = new Set(['work/ideia']) + expect(workBranchName({ bag: 'ideia', taken })).toBe('work/ideia-2') + + taken.add('work/ideia-2') + expect(workBranchName({ bag: 'ideia', taken })).toBe('work/ideia-3') + }) + + it('each invocation returns a different branch for the same bag (live name)', () => { + const first = workBranchName({ bag: 'teste' }) + expect(workBranchName({ bag: 'teste', taken: new Set([first]) })).toBe('work/teste-2') + }) + + it('never collides with a `next` branch nor with a `plan` branch', () => { + const taken = new Set(['C15-fullcalendar-em-campanha-agenda', 'plans/plan-issue-agenda']) + expect(WORK_BRANCH_PREFIX).toBe('work') + expect(`${WORK_BRANCH_PREFIX}/`).not.toMatch(/^[A-Z][A-Za-z0-9]*-/) + for (const bag of ['agenda', 'municipios', 'C15']) { + const branch = workBranchName({ bag, taken }) + expect(branch).toMatch(/^work\//) + expect(branch).not.toMatch(/^[A-Z][A-Za-z0-9]*-/) + expect(branch).not.toMatch(/^plans\//) + } + }) + + it('a numeric bag shares the sequential namespace (uniform)', () => { + expect(workBranchName({ bag: '3' })).toBe('work/3') + }) + + it('unslugifiable bag falls back to the namespace label', () => { + expect(workBranchName({ bag: '!!!' })).toBe('work/work') + }) + + it('truncates long slugs within the branch budget', () => { + const branch = workBranchName({ bag: 'palavra '.repeat(20).trim() }) + expect(branch.startsWith('work/')).toBe(true) + expect(branch.length).toBeLessThanOrEqual(60) + expect(branch).toBe(`work/${'palavra-'.repeat(6)}palavra`) + }) + + it('keeps the -2 suffix inside the branch budget when the base is taken', () => { + const bag = 'palavra '.repeat(20).trim() + const base = workBranchName({ bag }) + expect(base.length).toBe(60) + const suffixed = workBranchName({ bag, taken: new Set([base]) }) + expect(suffixed.length).toBe(60) + expect(suffixed.endsWith('-2')).toBe(true) + expect(suffixed).toBe(`work/${'palavra-'.repeat(6)}palav-2`) + }) + + it('planBranchName keeps its own namespace contract untouched', () => { + expect(planBranchName({})).toBe('plans/plan-issue-1') + expect(planBranchName({ bag: 'agenda', taken: new Set(['plans/plan-issue-agenda']) })).toBe( + 'plans/plan-issue-agenda-2', + ) + }) +}) + describe('worktreeEnvironment (per-worktree ports and databases)', () => { it('derives slot, port and database names deterministically from the branch', () => { const env = worktreeEnvironment({ branch: 'C15-foo', code: 'C15' })