diff --git a/.agents/shell/worktree.sh b/.agents/shell/worktree.sh
index 2906a14d..3dc2e7f3 100644
--- a/.agents/shell/worktree.sh
+++ b/.agents/shell/worktree.sh
@@ -7,10 +7,12 @@
# shell que o chamou.
#
# No terminal (esta função), o script também imprime a diretiva `launch
-# opencode
--model deepseek/deepseek-v4-flash --auto [--prompt …]` (OPS26):
-# a função executa o cd e então a linha, e o TUI do opencode abre no worktree —
-# `next` com `/work-issue` já enviado, `plan` com `/plan-issue` já enviado
-# (OPS31). Sem `exec` de propósito: ao sair do opencode, o terminal volta ao
+# opencode --model deepseek/deepseek-v4-flash --auto [--prompt "…"]` (OPS26):
+# a função executa o cd e então a linha (tokenizada por xargs — honra as aspas
+# do prompt, nunca eval), e o TUI do opencode abre no worktree — `next` com
+# `/work-issue --issue ` já enviado (OPS33: a Issue claimada vai no prompt),
+# `plan` com `/plan-issue` já enviado (OPS31), `new` sem prompt (apenas
+# conversar). Sem `exec` de propósito: ao sair do opencode, o terminal volta ao
# shell dentro do worktree. Presets são constantes em scripts/lib/worktree.mjs;
# o marcador TEQO_WORKTREE_TERMINAL=1 é o que separa esta superfície da do comando
# `/worktree` do opencode (que nunca lança TUI). `--stay` suprime cd e launch.
@@ -19,8 +21,11 @@
# source /.agents/shell/worktree.sh
#
# Uso (terminal interativo):
-# worktree next [--stay] cria/reutiliza o worktree da próxima Issue claimável
-# e cd para dentro dele por padrão; --stay não troca
+# worktree next [--issue N] [--stay]
+# CLAIMA a próxima Issue claimável e cria/reutiliza o
+# worktree dela, cd para dentro por padrão; --issue N
+# claima a Issue direcionada ou reabre a já claimada
+# (sem re-claim); --stay não troca
# worktree plan [bag] [--stay] cria um worktree de planejamento do /plan-issue
# DIFERENTE a cada chamada (sessões paralelas): com bag,
# branch plans/plan-issue- (sufixo -2/-3 se o nome
@@ -35,7 +40,8 @@
# 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).
-# Read-only no GitHub — claim continua sendo `pnpm agent:claim`.
+# Claim determinístico: `next` claima antes de criar o worktree (mesma fila e
+# lock de `pnpm agent:claim`); `plan`/`new`/`kill` não tocam Issues.
worktree() {
local src="${BASH_SOURCE[0]:-$0}"
@@ -69,14 +75,19 @@ worktree() {
fi
# Diretiva `launch` (só existe quando TEQO_WORKTREE_TERMINAL=1 e sem --stay): o
- # script a gera a partir de constantes + dir slugificado (sem espaços), então o
- # split por IFS=' ' abaixo é intencional e seguro — nunca eval. Falha do launch
- # (ex.: opencode fora do PATH) só avisa: o worktree já está pronto e utilizável.
+ # script a gera a partir de constantes + dir slugificado (sem espaços), e desde
+ # o OPS33 o valor do `--prompt` carrega espaço e vem CITADO (`"/work-issue
+ # --issue "`) — o split por IFS=' ' não honra aspas, então a tokenização usa
+ # xargs (processa aspas duplas como um shell, NÃO é eval; o conteúdo é 100%
+ # gerado por constantes + número, sem input livre). Falha do launch (ex.:
+ # opencode fora do PATH) só avisa: o worktree já está pronto e utilizável.
local launch
launch="$(printf '%s\n' "$out" | sed -n 's/^launch //p' | tail -n 1)"
if [ -n "$launch" ]; then
local -a launch_args
- IFS=' ' read -r -a launch_args <<< "$launch"
+ while IFS= read -r token; do
+ launch_args+=("$token")
+ done < <(printf '%s\n' "$launch" | xargs -n 1 printf '%s\n')
"${launch_args[@]}" || printf 'worktree: launch do opencode falhou (código %s) — o worktree segue pronto em %s.\n' "$?" "$PWD" >&2
fi
}
diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md
index 53957bf7..0660e48a 100644
--- a/.agents/skills/worktree-next-issue/SKILL.md
+++ b/.agents/skills/worktree-next-issue/SKILL.md
@@ -1,25 +1,25 @@
---
name: worktree-next-issue
description: >-
- Cria um worktree git a partir de main para a próxima Issue claimável da fila
- do projeto, com branch legível no formato - (ex.
- C15-fullcalendar-em-campanha-agenda, slug pt-BR do título sem acentos).
- Read-only no GitHub — NÃO claima, NÃO altera Issues. Usar quando o usuário
- pedir "worktree da próxima issue", "cria o worktree para o próximo da fila",
+ Claima deterministicamente a próxima Issue da fila (ou a direcionada com
+ --issue N), cria o worktree git dela a partir de main com branch legível no
+ formato - (ex. C15-fullcalendar-em-campanha-agenda, slug pt-BR
+ do título sem acentos) e provisiona o ambiente. Usar quando o usuário pedir
+ "worktree da próxima issue", "cria o worktree para o próximo da fila",
"prepara o ambiente do próximo item", ou quiser começar a trabalhar a próxima
Issue sem abrir o pool/Cursor.
---
# Worktree da próxima Issue claimável
-Prepara o ambiente git para a próxima Issue da fila de claim, **sem** tocá-la no GitHub (read-only por contrato — claim continua sendo `pnpm agent:claim`, feito à parte).
+Prepara o ambiente git para a próxima Issue da fila de claim — **o claim é parte do `next`** (OPS33): mesma fila/ordem e lock otimista do `pnpm agent:claim`, rodado **antes** de criar o worktree; claim falhou → motivo e saída sem worktree. Reabrir uma Issue já claimada (`--issue N` com `in-progress`) não re-claima.
## Fonte canônica: `pnpm worktree` (script)
A lógica determinística (fila → código+slug → branch → worktree) vive em **`scripts/worktree.mjs`** — fonte única, coberta por `tests/unit/worktree.unit.spec.ts`. Não a reescreva à mão aqui.
```bash
-pnpm worktree next [--stay] [--no-migrate] # cria worktree de origin/main + provisiona o ambiente
+pnpm worktree next [--issue N] [--stay] [--no-migrate] # CLAIMA a Issue + 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
@@ -31,22 +31,22 @@ No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.op
**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 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt ]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue` já enviado (o comando OPS25 roda o ciclo completo); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
+**Launch do opencode (OPS26 + OPS33 + OPS31):** só a função shell marca `TEQO_WORKTREE_TERMINAL=1`; com o marcador, `next`/`plan`/`new` imprimem também a diretiva `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt "/work-issue --issue "]` **antes** do `cd`, e a função a executa depois do cd — `next` abre o TUI com `/work-issue --issue ` já enviado (a Issue claimada vai no prompt; a skill lê o resto do GitHub); `plan` abre com `/plan-issue` já enviado (OPS31: o TUI já cai no fluxo de planejamento); `new` abre sem `--prompt` ("apenas conversar"). Sem `exec`: sair do opencode volta ao shell no worktree. Presets são constantes em `scripts/lib/worktree.mjs`. **O comando `/worktree` do opencode (e qualquer automação sem o marcador) nunca lança o TUI.**
## Fluxo quando invocado como skill (agentes que não têm opencode)
1. Rode `pnpm worktree next` e leia a saída inteira. Se a fila estiver vazia, ele para sozinho — não crie worktree sem Issue.
2. Se a saída reclamar de conflito (worktree/branch já existentes), o script já reporta o que reutilizar.
-3. Reporte ao usuário: código, `#`, branch (`-`), path — e que a Issue **não** foi claimada.
+3. Reporte ao usuário: código, `#`, branch (`-`), path — e que a Issue **já foi claimada** pelo próprio `next` (não rodar `pnpm agent:claim`).
## O que o script garante (contrato)
-- **Fila = `pnpm agent:claim --dry-run`** (ready + desbloqueadas, por `prio:P*` e mais antiga primeiro — mesma ordenação do pool e de `project-status`).
+- **Claim determinístico primeiro** (OPS33): a próxima `ready` desbloqueada (mesma fila/ordem do `pnpm agent:claim` — por `prio:P*` e mais antiga primeiro) é claimada com o **mesmo lock otimista** antes de qualquer `git`; race → o script para com o motivo, **sem worktree órfão**. `--issue N` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; worktree reutilizado/criado). A saída imprime o brief do claim e avisa "já claimada — NÃO rodar `pnpm agent:claim`".
- **Branch `-`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`.
- **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/`.
- **Provisiona o ambiente isolado do worktree** (determinístico, ver "Ambiente provisionado"): dev server na porta `3100+slot` e bancos próprios `teqo_wt` / `teqo_wt_test` no container compartilhado (`-p teqo`), com migrations aplicadas — agentes em paralelo não disputam porta 3000 nem o `teqo_test`. `--no-migrate` pula as migrations (bancos criados, envs escritos).
- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch; remove os bancos gerados do worktree (best-effort).
-- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e é **read-only no GitHub**.
+- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e nunca desfaz claim (reverter `in-progress` → `ready` é outra entrega).
## Ambiente provisionado (leia a saída do script)
@@ -60,6 +60,7 @@ Cada worktree gerado ganha um ambiente próprio — reporte ao agente/humano os
## NÃO faz
-- Não roda `pnpm agent:claim` (mudaria labels no GitHub).
+- Não roda `pnpm agent:claim` por conta própria (o `next` claima sozinho; rodar de novo tentaria claim duplo).
- Não usa `git worktree add --force` nem destrói worktrees existentes (isso é `kill`, explícito).
+- Não desfaz claim (reverter `in-progress` → `ready` é outra entrega).
- Não duplica a lógica: se o script falhar ou faltar (gh fora do ar), reporte e pare.
diff --git a/.opencode/commands/worktree.md b/.opencode/commands/worktree.md
index 18a84e08..ca68c599 100644
--- a/.opencode/commands/worktree.md
+++ b/.opencode/commands/worktree.md
@@ -1,14 +1,14 @@
---
-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])
+description: Claima a próxima Issue (next [--issue N] [--stay]), cria o worktree dela e provisiona o ambiente; também cria worktree de planejamento (plan [bag] [--stay]), worktree neutro (new [bag] [--stay]) ou destrói o 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.
+- `next [--issue N] [--stay]`: **claima** a próxima Issue claimável da fila (mesma fila/ordem e lock otimista de `pnpm agent:claim`) e cria (ou reutiliza) o worktree dela. Claim falhou → reporte o motivo — não há worktree. `--issue N` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress`, sem re-claim). A saída avisa "já claimada — NÃO rodar `pnpm agent:claim`". 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) mas o claim ainda acontece. `--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`. Não claima nada. 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`. Não claima nada. 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.
+> **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch do opencode (OPS26/OPS33) acontece apenas na função `worktree()` de `.agents/shell/worktree.sh` (terminal interativo); nunca abra um TUI aninhado a partir daqui.
!`node scripts/worktree.mjs $ARGUMENTS`
diff --git a/AGENTS.md b/AGENTS.md
index f5f639e6..17ef92f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,7 +34,10 @@ 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 AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); 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 ]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue` already sent (the OPS25 command runs the whole cycle), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). 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 — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks).
+ <<<<<<< HEAD
+- # **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 AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); 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 ]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue` already sent (the OPS25 command runs the whole cycle), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). 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 — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks).
+- **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. **`next` claims BEFORE creating the worktree (OPS33, 2026-08-10):** the same queue/order and optimistic lock as `pnpm agent:claim` (shared `claimIssue` in `scripts/lib/agent-github.mjs`); a failed claim prints the reason and stops with no orphan worktree; `--issue ` claims the directed issue (`ready`) or **reopens** an already-claimed one (`in-progress`, no re-claim, worktree reuse stays idempotent); the claim brief is printed and the output warns "já claimada — não rodar `pnpm agent:claim`" (`plan`/`new`/`kill` never touch Issues; the pool's coordinated `claimIssueForPool` is untouched). `worktree next [--issue N] [--stay]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, capped at 999 with 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 AND the minimal seed applied (`pnpm migrate` + `pnpm db:seed:minimal` per database — OPS28 parity: CI runs the same pair before e2e, so a worktree's `pnpm test:e2e` mirrors CI instead of failing on seed-dependent specs; the seed is idempotent and local-guarded, and `--no-migrate` skips both); 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 --issue "]` directive BEFORE the `cd` line, which the function executes after the cd (OPS26, 2026-08-10; OPS33: `next` sends `/work-issue --issue ` — the claimed issue — with the prompt value quoted, and the shell tokenizes the line via xargs, quote-aware, never eval; OPS31 for `plan`): `next` opens the opencode TUI in the worktree with `/work-issue --issue ` already sent (the OPS25 command runs the whole cycle; the skill reads the rest from the Issue), `plan` opens with `/plan-issue` already sent (the TUI lands straight in the planning flow), `new` opens with no prompt ("apenas conversar", no skill — the opencode CLI's `--prompt` always auto-submits, there is no prefill-without-submit; that stays an upstream-only feature request, annotated in the opencode proposition). 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 claim still happens), 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 — and still runs migrate + seed mínimo on those shared databases (same parity; the seed re-pins campaignGoals and 5 municipalities there, overwriting manual tweaks).
+ > > > > > > > 9e18bec8 (OPS33: worktree next claima deterministicamente a Issue (--issue N, reopen) e launch com /work-issue --issue N)
- 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/AGENT-OPS.md b/docs/AGENT-OPS.md
index 18f0e674..395c66c4 100644
--- a/docs/AGENT-OPS.md
+++ b/docs/AGENT-OPS.md
@@ -34,18 +34,19 @@ main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HE
## Comandos
-| Comando | Faz |
-| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
-| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
-| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
-| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
-| `pnpm agent:status` | Overview / fila / mermaid |
-| `pnpm agent:prioritize -- ` | Troca `prio:*` |
-| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
-| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
-| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
-| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks` + `migration-lock`, `strict=false`, 0 reviews |
-| `pnpm db:seed:minimal` | DB mínimo sintético |
+| Comando | Faz |
+| ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| `pnpm agent:claim [-- --dry-run]` | Fila ready+unblocked por prio → `in-progress` + brief |
+| `pnpm worktree next [--issue N] [--stay]` | Claim determinístico (mesma fila/lock do claim) → worktree + provisionamento |
+| `pnpm agent:register -- --id X --title T [--prio P1] [--depends A,B] [--plan docs/plans/x.md] [--model slug]` | Cria Issue (`--plan` ⇒ `blocked` até `agent:ready`; sem plano ⇒ `ready`) |
+| `pnpm agent:ready -- --issue N[,N…]` | Pós-merge do plano: `blocked`→`ready` (só Issues com link `docs/plans/`) |
+| `pnpm agent:status` | Overview / fila / mermaid |
+| `pnpm agent:prioritize -- ` | Troca `prio:*` |
+| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
+| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
+| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
+| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks` + `migration-lock`, `strict=false`, 0 reviews |
+| `pnpm db:seed:minimal` | DB mínimo sintético |
Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`.
diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index d540b5fe..56c42ced 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
+**Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue ` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue ` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md).
+
**Recently resolved (2026-08-10):** **OPS28** (Issue #584) — `worktree next`/`plan` passam a aplicar o **seed mínimo** além das migrations em cada banco provisionado (`migrate` + `db:seed:minimal` por banco, dev e test) — paridade com a CI, que roda o mesmo par antes do e2e: o e2e de worktree deixa de divergir do de CI (specs dependentes dos pins `priority: 'alta'` de `salvador-ze-1`/`camacari` — FAB suggest e `campaignSavedFilters` — falhavam só localmente com banco migrado cru 435×normal, o padrão que o ledger TECH-DEBT row 19 ficha como "masks real failures"). `--no-migrate` pula migrate **e** seed (o seed depende do catálogo migrado); re-provisionamento continua idempotente (upserts). Fallback sem Docker (Cursor Cloud) também roda migrate+seed nos `teqo`/`teqo_test` compartilhados (decisão de produto do gate: paridade total). **Guard de regressão** `tests/e2e/seedParity.setup.e2e.spec.ts` no projeto `setup` do Playwright: falha cedo com mensagem clara ("rode `pnpm migrate && pnpm db:seed:minimal`") se o banco de teste não estiver seedado — dependentes do setup pulam, nada de spec vermelho confuso; a evidência checada são os **users do seed** (`MINIMAL_CAMPAIGN_USERS`), deliberadamente não os pins `priority: 'alta'` — o cleanup dos fixtures e2e reseta `priority` dos municípios que toca (round-robin por sequência persistente), então pins morrem legitimamente após runs repetidas no mesmo banco de worktree e só o `pnpm worktree next` re-aplica (guard nunca checaria pins); `scripts/worktree.mjs` entrou em `HIGH_RISK_EXACT` (mudança no provisionador roda a suíte cheia na CI). Sem migration/access/Consent/UI. Intenção [`worktree-seed-minimal-paridade-e2e-ci.md`](plans/worktree-seed-minimal-paridade-e2e-ci.md); impl [`worktree-seed-minimal-paridade-e2e-ci-impl.md`](plans/worktree-seed-minimal-paridade-e2e-ci-impl.md).
**Recently resolved (2026-08-10):** **OPS34** (Issue #599) — o job `e2e` do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~67 s fixos + build 172 s + testes 196 s) roda agora **em 2 shards paralelos** (`--shard=N/2`, matrix `fail-fast: false`, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). **Cadeia de projetos do Playwright (`setup → campaign → frontend → admin`) + prewarm do `setup` viraram dev-only:** `playwright.config.ts` ganhou `isProdMode = CI || E2E_PROD=1` (mesma condição do `webServer.command`, agora reusada) — em prod o projeto `setup` sai do array (arquivo não casado = não coletado) e as `dependencies` zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: `--list` mostra `[setup]` só sem CI; smoke e2e dev rodou setup→campaignAuth). `ci-pr.yml`: o job `scope` emite `e2e_matrix`/`e2e_shards` via novo `e2eShardConfig(mode)` em `scripts/lib/test-affected-core.mjs` (fonte única, unit-pinada: full → `[1,2]`/2, selected/none → `[1]`/1 — pagar 2 builds por 3 specs é desperdício); `ci.yml` hardcodeia `[1,2]`/2 com comentário apontando o single-source. `PLAYWRIGHT_WORKERS: 2` explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão **sem** o separador `--` — pnpm repassa o `--` literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o `setup` não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. **Medição pós-merge (main, run 31418206512):** job e2e 435 s → **326 s (shard 1) / 369 s (shard 2)** — shard 2 = fixo 68 s + build 176 s + testes 124 s (a fase de testes caiu de 196 s seriais para 124 s — metade da suite por runner + cadeia removida; o shard 2 ainda pagou retry da flake conhecida de `campaignMunicipalities`; shard 1: testes ~82 s); gate completo do pipeline (checks) ~7,5 → ~6,4 min. Build passou a dominar o shard — gatilho da intenção (opção B, artefato compartilhado) reavaliável quando os testes encolherem mais. Intenção [`ci-e2e-paralelizar-job.md`](plans/ci-e2e-paralelizar-job.md); impl [`ci-e2e-paralelizar-job-impl.md`](plans/ci-e2e-paralelizar-job-impl.md).
diff --git a/docs/plans/worktree-next-claima-issue-deterministicamente-impl.md b/docs/plans/worktree-next-claima-issue-deterministicamente-impl.md
new file mode 100644
index 00000000..f2a23c70
--- /dev/null
+++ b/docs/plans/worktree-next-claima-issue-deterministicamente-impl.md
@@ -0,0 +1,101 @@
+# Impl: Worktree next claima deterministicamente a Issue e abre o opencode com /work-issue já informado
+
+Status: aprovado
+Atualizado em: 2026-08-10
+Issue: #595
+Intenção: docs/plans/worktree-next-claima-issue-deterministicamente.md
+Appetite restante: herdado (~1 dia; entrega de scripts/lib/shell/docs — sem schema/UI)
+
+## Leitura da intenção
+
+- **Outcome:** `worktree next` claima a Issue **antes** de criar o worktree (mesma fila/ordem e lock otimista de `pnpm agent:claim`); claim falhou → motivo e saída **sem worktree órfão**; `--issue ` repassa ao claim; reabrir Issue já claimada → segue ao launch **sem re-claim** ("já claimada — reabrindo"); o launch entrega a Issue ao agente de forma determinística (`/work-issue --issue `); `--stay` continua suprimindo cd+launch mas o claim acontece; o comando `/worktree` do opencode roda o mesmo script (claim incluso, sem launch).
+- **O que NÃO negociar:** nunca desfazer claim (falha pós-claim → reportar, humano decide); claim coordenado do pool (`claimIssueForPool`/supervisor) intacto; mesmo lock otimista do `agent:claim` (race → para com motivo); fonte única de verdade é a Issue — o launch só passa o **número**, o resto a skill lê do GitHub.
+- **O que reavaliar:** a hipótese "reutilizar/**invocar** a lógica de `scripts/agent-claim.mjs`" — invocar como subprocesso e parsear o stdout para descobrir o número claimado seria acoplamento frágil de formato de saída (exatamente a classe de bug que #582 puniu). Reavaliado para: **extrair** claim+brief para `scripts/lib/agent-github.mjs` (o worktree.mjs já consome `nextClaimableIssue` de lá — precedente do dono do conhecimento).
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ N["worktree next"] --> P{"--issue N?"}
+ P -->|não| Q["nextClaimableIssue()
(fila pronta+unblocked)"]
+ Q -->|vazia| D1["die: fila vazia — sem worktree"]
+ Q -->|entry| C["claimIssue(entry, die)
(lock otimista + flip + comentário)"]
+ P -->|sim| V["gh issue view N"]
+ V -->|ready| CD["claimIssue direcionado
(não-claimável → die)"]
+ V -->|in-progress| R["reopen — SEM re-claim
(entry via claimQueueEntry)"]
+ C --> W["branch → worktree create/reuse
→ provision → brief"]
+ CD --> W
+ R --> W
+ W --> L["launch opencode … --prompt
\"/work-issue --issue N\""]
+ L --> X["cd <dir> (última linha)"]
+```
+
+**Opções consideradas:** A) extrair `claimIssue` + `claimBriefLines` + `claimQueueEntry` para `scripts/lib/agent-github.mjs` e o worktree.mjs consome direto; B) `worktree next` invoca `pnpm agent:claim` como subprocesso e parseia o stdout; C) duplicar o claim (lock + labels + comentário) dentro do worktree.mjs.
+**Recomendação:** A — o claim é conhecimento de domínio com **um dono**: `agent-github.mjs` já hospeda fila/gh/labels e é o precedente de `nextClaimableIssue`; agent-claim.mjs vira um CLI fino (mesma saída), o worktree chama a mesma função. B acopla o worktree ao formato de saída do claim (brief, log lines mudam sem aviso → parse quebra) e roda gh duas vezes; C faz o lock otimista divergir — o pior caso é claim duplo.
+**Rejeitadas:** B porque parse de stdout para descobrir qual Issue foi claimada é a fragilidade que #582 puniu (claim/branch divergentes); C porque duplica o lock e o brief em dois arquivos que evoluem separados.
+
+### Componentes / mudanças
+
+- **`claimIssue(entry, die)`** (`scripts/lib/agent-github.mjs`): lock otimista (re-read + recusa race) + flip `ready→in-progress` + comentário de claim — extraído **verbatim** de `scripts/agent-claim.mjs`; `die` é injetado (label `[agent:claim]` vs `[worktree]`). O pool segue com `claimIssueForPool` (`agent-pool-github.mjs`) — claim coordenado, intacto.
+- **`claimQueueEntry(issue, byId)`** (`scripts/lib/agent-github.mjs`): shape de entry para UMA Issue (reopen) — o derivador interno do `buildClaimQueue` (`entryForIssue` + `doneIdsOf`) vira função privada e é reusado; comportamento de `buildClaimQueue` preservado (spec de parity do pool pina).
+- **`claimBriefLines(entry)`** (`scripts/lib/agent-github.mjs`): linhas do brief (id/priority/rename_chat/model/deps/url/`--- spec ---`/body) — extraídas verbatim do agent-claim.mjs; os dois CLIs imprimem as **mesmas linhas** (fonte única).
+- **`scripts/agent-claim.mjs`**: passa a usar `claimIssue(pick, die)` + `claimBriefLines(pick)` — CLI, mensagens e saída intactos (OPS32 já ajustou as linhas de model; não regride).
+- **`scripts/worktree.mjs`** (`cmdNext`): claim **antes** de branch/fetch/worktree; flag nova `--issue ` (parseArgs ganha `issue` com valor): `ready` → claim direcionado via `buildClaimQueue([target], issuesById())` (bloqueada/dep → die "não claimável"); `in-progress` → **reopen** (`claimQueueEntry`, sem claim); outros estados/não-existente → die com motivo. Saída: linha de status ("Claimado da fila" / "Reabrindo sessão já claimada") + brief + "Issue já claimada — NÃO rodar `pnpm agent:claim`" (substitui a linha "Issue NÃO claimada — claim continua sendo…" e sai do branch de criação, vira sempre-impresso). `printLaunchDirective` ganha `issueNumber`.
+- **`opencodeLaunchDirective`** (`scripts/lib/worktree.mjs`): assinatura `{ dir, purpose, terminal, issueNumber = null }`; `next` com número → prompt `"/work-issue --issue "` (valor **citado** na linha — a diretiva agora carrega um espaço); sem número → `/work-issue` (fallback atual). `plan`/`new` intactos (sem prompt).
+- **`.agents/shell/worktree.sh`**: a execução da diretiva deixa de ser split cego por `IFS=' '` (que não honra aspas) e passa a **tokenizar como shell** (`printf '%s\n' "$launch" | xargs -n 1 printf '%s\n'` → array; xargs processa aspas duplas, não é eval; conteúdo 100% gerado por constantes + número). Comentário do "nunca eval" atualizado.
+- **Textos:** header/uso do script (`scripts/worktree.mjs`), `.opencode/commands/worktree.md` (description: "next claima; --issue N; reabrir"), skill `worktree-next-issue` (sai "read-only, NÃO claima"), `AGENTS.md` (bullet "Per-worktree environments": claim-first, `--issue N`/reopen, launch com `/work-issue --issue `), `docs/AGENT-OPS.md` (linha 39 da tabela de scripts, se necessário), `docs/CHANGELOG-AGENTS.md` (uma entrada no topo).
+- **Migration:** sem migration. **Access/Consent:** N/A (CLI/shell; gh autenticado do operador). **UI:** Impeccable A — sem superfície de produto.
+
+### Dados → forma
+
+- N/A — sem superfície de dados.
+
+## Fases verificáveis
+
+1. **Lib + scripts** — extração do claim/brief/entry para `agent-github.mjs`; `agent-claim.mjs` refatorado (mesma saída); `cmdNext` com claim + `--issue` + reopen. Verificação: `pnpm gate:fast` (unit de parity do pool e worktree verdes sem edição de comportamento).
+2. **Launch com Issue** — `opencodeLaunchDirective` com `issueNumber` + tokenização do worktree.sh. Verificação: unit novos (directive com/sem número, plan/new intactos, não-terminal → null); smoke: `TEQO_WORKTREE_TERMINAL=1 node scripts/worktree.mjs next --issue --stay` imprime a diretiva com o prompt citado.
+3. **Docs + textos** — AGENTS.md, worktree.md, skill worktree-next-issue, CHANGELOG. Verificação: `pnpm gate:fast`; grep de "NÃO claima"/"read-only" em código vivo → só docs/plans congelados.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Reverter claim / devolver Issue à fila** — outra entrega (intenção).
+- **Claim do pool** (`claimIssueForPool`, supervisor, worker UUID) — intacto.
+- **`plan`/`new`** não ganham `--issue` nem prompt — só `next` claima.
+- **Validação extra de worktree/env/modelo** — o contexto já garante; overhead é o anti-goal da intenção.
+- **`rename_chat` no brief compartilhado** — ruído no worktree, mas fonte única > dois briefs que divergem.
+- **Editar a intenção** (`worktree-next-claima-issue-deterministicamente.md`) — imutável (Issue in-progress desde o claim).
+- **Editar `.opencode/commands/work-issue.md` / skill work-issue** — receptor OPS32, já aceita `--issue ` (contrato fechado).
+
+## Já resolvido no /simplify (não reabrir)
+
+- **Claim antes da validação do branch** (P1/R1 + P3/R2): derivação/validação do branch moveu para **antes** do `claimIssue` (pick é read-only; uma Issue sem frontmatter id morre antes do flip, sem claim órfão) + hint "reabra com `next --issue N`" em falha pós-claim (try/catch no `cmdNext`).
+- **`--issue` sem valor e posicional `next 595`** (P2/R1#2 + R2#3 + F8): die com motivo; `parseArgs` não consome `--flag` seguinte como valor (`--issue --stay` → "requer um número").
+- **Claim direcionado via `buildClaimQueue([target])`** (P2/R1#3): trocado por `claimQueueEntry` + checagem explícita de `blockedBy` com os ids na mensagem.
+- **Decisão claim/reopen sem teste** (F3/F4): `claimTargetVerdict` puro extraído (`agent-github.mjs`) + 5 unit (reopen/claim/closed/neither/double-label).
+- **Reset `-B` de branch com commits de sessão no reopen** (F1): guard `git log origin/main..` → die pedindo decisão.
+- **Headline "Claimado da fila" no claim direcionado** (F5/R2#1): três variantes (fila / direcionado / reaberto) + aviso "já claimada" diferenciado.
+- **`new` omitido nos docs do launch** (R2#5) e **cap ≤999 do slot** no AGENTS.md (R2#6): texto alinhado.
+- **Mensagem de race sugerindo re-claim** (F7): agora aponta reopen via `--issue N`.
+
+## Explicitamente fora (descartes + defers com gatilho — triage confirmada 2026-08-10)
+
+- **S1 (defer)** — extração da diretiva `launch`/`cd` por `sed+tail` sobre output que intercala o corpo livre da Issue: o contrato é a ordem das linhas (launch e cd são as últimas 2). **Gatilho:** se `next` (ou qualquer subcomando) passar a imprimir linhas depois da diretiva → trocar por marcadores máquina-leitura (`__WORKTREE_LAUNCH__=`/`__WORKTREE_CD__=`) no output + parse no `worktree.sh`.
+- **S2 (defer)** — reopen concorrente (2 terminais, mesmo `--issue N`) sem lock local: double `CREATE DATABASE` (um morre) e `runMigrate` concorrente. Raro (gatilho humano), sem dano permanente. **Gatilho:** relato de double-CREATE ou de migrate concorrente → advisory lock local (ex. `mkdir` exclusivo em `/tmp`) no `cmdNext`.
+- **S3 (defer)** — `claimIssue` (mutation do lock otimista) sem unit: a decisão pura foi extraída e testada (`claimTargetVerdict`); mockar `gh` via vi.mock é caro (ESM, execFileSync). **Gatilho:** regressão do lock no `agent:claim`/`next` (race não detectada) → injetar `ghJson`/`setLabels`/`gh` no módulo para unit.
+- **S4 (descartar)** — reopen não limpa `ready` stale no double-label `ready`+`in-progress`: o fluxo normal não produz o estado (claim remove `ready`); precedência `in-progress` pinada no teste do `claimTargetVerdict`.
+
+## Riscos e mitigação
+
+- **Race no lock do claim** (outro agente claima entre o pick e o flip) → `claimIssue` die com o motivo (mesma mensagem do agent:claim); o humano re-roda — e **nenhum worktree órfão** porque o claim falha antes do `worktree add`.
+- **`--issue N` inexistente/estranho** → `ghJson` lança; o catch do worktree.mjs imprime o stderr do gh com o label `[worktree]` — motivo no terminal, sem worktree.
+- **Falha pós-claim** (`worktree add`/provision quebra) → claim **não** desfeito (intenção); recuperação: `worktree next --issue N` vê `in-progress` e reabre sem re-claim — caminho explícito, sem cerimônia.
+- **Tokenização por xargs no worktree.sh** → a diretiva é 100% constantes + número (sem input livre, dir slugificado sem espaços); xargs não é eval nem expande globs; unit pina o formato exato da linha.
+- **Refactor do `buildClaimQueue`** → extração preserva saídas; spec `agentPoolEligibility` (parity com agent:claim) permanece verde sem edição.
+- **Reopen de Issue `in-progress` com deps abertas** → `claimQueueEntry` deriva o shape sem o filtro de bloqueio (reopen é sobre a sessão, não sobre a fila) — caso coberto por design.
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto (claim-first com falha→para; `--issue N`; reopen sem re-claim; launch `/work-issue --issue `; `--stay` claima; `/worktree` com claim)
+- [x] Invariantes AGENTS/engineering-standards (sem DB/access/Consent/migration; pool intacto; lock otimista compartilhado)
+- [x] Testes de domínio previstos: unit novos para `opencodeLaunchDirective` com `issueNumber`, `claimQueueEntry` (incl. deps satisfeitas) e `claimTargetVerdict` (5 casos); parity `buildClaimQueue` verde sem edição; smoke E2E do reopen (#595, sem re-claim) e dos caminhos de erro
+- [x] Self-score decision-quality: 5/5 (decisões com rejeitadas; cabe no appetite; rabbit holes nomeados; reusa dono do conhecimento `agent-github.mjs`; intenção preservada)
diff --git a/scripts/agent-claim.mjs b/scripts/agent-claim.mjs
index 7bc78eef..e3267136 100644
--- a/scripts/agent-claim.mjs
+++ b/scripts/agent-claim.mjs
@@ -16,14 +16,12 @@
import {
buildClaimQueue,
+ claimBriefLines,
+ claimIssue,
dieAgent,
- gh,
ghJson,
issuesById,
- labelNames,
parseArgs,
- parseFrontmatter,
- setLabels,
} from './lib/agent-github.mjs'
const die = dieAgent('claim')
@@ -68,57 +66,11 @@ if (!pick) {
)
}
-// Optimistic lock: re-read right before flipping.
-const fresh = ghJson(['issue', 'view', String(pick.issue.number), '--json', 'number,labels,state'])
-const freshLabels = labelNames(fresh)
-if (
- fresh.state !== 'OPEN' ||
- !freshLabels.includes('ready') ||
- freshLabels.includes('in-progress')
-) {
- die(`Issue #${pick.issue.number} was just claimed or closed by someone else. Re-run claim.`)
-}
-
-setLabels(pick.issue.number, { add: ['in-progress'], remove: ['ready'] })
-gh([
- 'issue',
- 'comment',
- String(pick.issue.number),
- '--body',
- `Claimed by agent run at ${new Date().toISOString()}. Lock otimista: outro claim deve falhar e re-rodar \`pnpm agent:claim\`.`,
-])
-
-const { rest } = parseFrontmatter(pick.issue.body)
-const issueId = pick.meta.id ?? null
-let subject = pick.issue.title
-if (issueId) {
- const idPrefix = `${issueId} — `
- if (subject.startsWith(idPrefix)) subject = subject.slice(idPrefix.length)
-}
-const sessionTitle = issueId
- ? `#${pick.issue.number} ${issueId} — ${subject}`
- : `#${pick.issue.number} — ${subject}`
+// Optimistic lock: re-read right before flipping (shared with `worktree next`).
+claimIssue(pick, die)
console.log(`\n[agent:claim] Claimed #${pick.issue.number} — ${pick.issue.title}`)
-console.log(` id: ${issueId ?? '(none)'} priority: ${pick.priority}`)
-console.log(` rename_chat: ${sessionTitle.slice(0, 200)}`)
-if (pick.meta.model) {
- console.log(
- ` model: ${pick.meta.model} (metadata consultiva — o work-issue não verifica modelo; o pool spawna nele; ver skill model-selection)`,
- )
-} else {
- console.log(
- ' model: ausente — registrar slug único na Issue (gh issue edit; ver skill model-selection)',
- )
-}
-if (pick.satisfiedWithoutIssue.length > 0) {
- console.log(
- ` deps sem issue (roadmap entregue, satisfeitas): ${pick.satisfiedWithoutIssue.join(', ')}`,
- )
-}
-console.log(` url: https://github.com/fsolla/teqo/issues/${pick.issue.number}`)
-console.log('\n--- spec ---\n')
-console.log(rest.trim() || '(empty body — see linked plan)')
+for (const line of claimBriefLines(pick)) console.log(line)
console.log(
'\n[agent:claim] Fluxo: claim feito fora da sessão → abra a sessão de trabalho no worktree (skill `work-issue`, humano; no pool, o prompt do worker aponta `agent-work-issue`) → gates → PR Ready --base main (Closes #' +
`${pick.issue.number}) → auto-merge.`,
diff --git a/scripts/lib/agent-github.mjs b/scripts/lib/agent-github.mjs
index 1e563610..4bc0103c 100644
--- a/scripts/lib/agent-github.mjs
+++ b/scripts/lib/agent-github.mjs
@@ -82,6 +82,39 @@ export const issuesById = () => {
export const labelNames = (issue) => issue.labels.map((label) => label.name)
+/** Issue ids that count as a satisfied dependency (closed or done/in-prod). */
+const doneIdsOf = (byId) =>
+ new Set(
+ [...byId.entries()]
+ .filter(([, issue]) => {
+ const labels = labelNames(issue)
+ return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod')
+ })
+ .map(([id]) => id),
+ )
+
+/**
+ * Claim-queue entry shape derived from ONE issue: meta, priority and the
+ * dependency verdicts. Shared by `buildClaimQueue` (the queue) and
+ * `claimQueueEntry` (single-issue contexts like reopening an in-progress
+ * session) — one derivation, no drift.
+ */
+const entryForIssue = (issue, byId, doneIds) => {
+ const { meta } = parseFrontmatter(issue.body)
+ const depends = Array.isArray(meta.depends) ? meta.depends : []
+ // A dep without an issue is a delivered roadmap item (B43/B47/B59…): they
+ // predate the Issues era and are never reopened, so they are satisfied.
+ const satisfiedWithoutIssue = depends.filter((id) => !byId.has(id))
+ const blockedBy = depends.filter((id) => byId.has(id) && !doneIds.has(id))
+ return {
+ issue,
+ meta,
+ priority: labelNames(issue).find((label) => /^prio:P[0-3]$/.test(label)) ?? 'prio:P2',
+ satisfiedWithoutIssue,
+ blockedBy,
+ }
+}
+
/**
* Claim queue shared by `agent:claim` and the agent pool: open `ready` issues
* filtered to UNBLOCKED (every frontmatter `depends` id is closed or labeled
@@ -95,31 +128,10 @@ export const labelNames = (issue) => issue.labels.map((label) => label.name)
* @returns {Array<{ issue: any, meta: Record, priority: string, satisfiedWithoutIssue: string[], blockedBy: string[] }>}
*/
export const buildClaimQueue = (openReady, byId) => {
- const doneIds = new Set(
- [...byId.entries()]
- .filter(([, issue]) => {
- const labels = labelNames(issue)
- return issue.state === 'CLOSED' || labels.includes('done') || labels.includes('in-prod')
- })
- .map(([id]) => id),
- )
+ const doneIds = doneIdsOf(byId)
return openReady
- .map((issue) => {
- const { meta } = parseFrontmatter(issue.body)
- const depends = Array.isArray(meta.depends) ? meta.depends : []
- // A dep without an issue is a delivered roadmap item (B43/B47/B59…): they
- // predate the Issues era and are never reopened, so they are satisfied.
- const satisfiedWithoutIssue = depends.filter((id) => !byId.has(id))
- const blockedBy = depends.filter((id) => byId.has(id) && !doneIds.has(id))
- return {
- issue,
- meta,
- priority: labelNames(issue).find((label) => /^prio:P[0-3]$/.test(label)) ?? 'prio:P2',
- satisfiedWithoutIssue,
- blockedBy,
- }
- })
+ .map((issue) => entryForIssue(issue, byId, doneIds))
.filter((entry) => entry.blockedBy.length === 0)
.sort((a, b) => {
const rank =
@@ -129,6 +141,108 @@ export const buildClaimQueue = (openReady, byId) => {
})
}
+/**
+ * Entry shape for ONE issue regardless of its labels — the `worktree next
+ * --issue ` reopen path (an already-claimed `in-progress` issue must not be
+ * re-filtered by its deps: reopening is about the session, not the queue).
+ */
+export const claimQueueEntry = (issue, byId) => entryForIssue(issue, byId, doneIdsOf(byId))
+
+/**
+ * Pure decision for `worktree next --issue `: how to treat the target issue
+ * from its state/labels, WITHOUT touching GitHub. `reopen` = already claimed
+ * (`in-progress` — no re-claim, and it wins over a stale `ready` double label);
+ * `claim` = `ready`; `error` = anything else (closed, or neither label).
+ */
+export const claimTargetVerdict = (issue) => {
+ if (issue.state !== 'OPEN') {
+ return { kind: 'error', message: `Issue não está aberta (${issue.state}).` }
+ }
+ const names = labelNames(issue)
+ if (names.includes('in-progress')) return { kind: 'reopen' }
+ if (names.includes('ready')) return { kind: 'claim' }
+ return {
+ kind: 'error',
+ message: `Issue não é claimável (labels: ${names.join(', ') || 'nenhum'}).`,
+ }
+}
+
+/**
+ * Optimistic claim shared by `agent:claim` and `worktree next`: re-reads the
+ * issue right before flipping labels, refuses if someone else already took it
+ * (`in-progress` present / `ready` gone), then swaps ready→in-progress and
+ * leaves a claim comment with the run timestamp. `die` is injected by the
+ * caller so the failure carries the right script label. The pool keeps its own
+ * coordinated `claimIssueForPool` — this is the human/CLI lock.
+ */
+export const claimIssue = (entry, die) => {
+ const fresh = ghJson([
+ 'issue',
+ 'view',
+ String(entry.issue.number),
+ '--json',
+ 'number,labels,state',
+ ])
+ const freshLabels = labelNames(fresh)
+ if (
+ fresh.state !== 'OPEN' ||
+ !freshLabels.includes('ready') ||
+ freshLabels.includes('in-progress')
+ ) {
+ die(
+ `Issue #${entry.issue.number} was just claimed or closed by someone else. ` +
+ 'Re-run `pnpm agent:claim` or reopen the session with `pnpm worktree next --issue ' +
+ `${entry.issue.number}` +
+ '`.',
+ )
+ }
+
+ setLabels(entry.issue.number, { add: ['in-progress'], remove: ['ready'] })
+ gh([
+ 'issue',
+ 'comment',
+ String(entry.issue.number),
+ '--body',
+ `Claimed by agent run at ${new Date().toISOString()}. Lock otimista: outro claim deve falhar e re-rodar \`pnpm agent:claim\`.`,
+ ])
+}
+
+/**
+ * Lines of the claim brief (id, priority, rename_chat, model, deps, url, spec
+ * body) printed by both claim surfaces (`agent:claim` and `worktree next`) —
+ * one source of truth for the claim presentation.
+ */
+export const claimBriefLines = (entry) => {
+ const { rest } = parseFrontmatter(entry.issue.body)
+ const issueId = entry.meta.id ?? null
+ let subject = entry.issue.title
+ if (issueId) {
+ const idPrefix = `${issueId} — `
+ if (subject.startsWith(idPrefix)) subject = subject.slice(idPrefix.length)
+ }
+ const sessionTitle = issueId
+ ? `#${entry.issue.number} ${issueId} — ${subject}`
+ : `#${entry.issue.number} — ${subject}`
+
+ return [
+ ` id: ${issueId ?? '(none)'} priority: ${entry.priority}`,
+ ` rename_chat: ${sessionTitle.slice(0, 200)}`,
+ entry.meta.model
+ ? ` model: ${entry.meta.model} (metadata consultiva — o work-issue não verifica modelo; o pool spawna nele; ver skill model-selection)`
+ : ' model: ausente — registrar slug único na Issue (gh issue edit; ver skill model-selection)',
+ ...(entry.satisfiedWithoutIssue.length > 0
+ ? [
+ ` deps sem issue (roadmap entregue, satisfeitas): ${entry.satisfiedWithoutIssue.join(', ')}`,
+ ]
+ : []),
+ ` url: https://github.com/fsolla/teqo/issues/${entry.issue.number}`,
+ '',
+ '--- spec ---',
+ '',
+ rest.trim() || '(empty body — see linked plan)',
+ ]
+}
+
export const setLabels = (number, { add = [], remove = [] }) => {
const args = ['issue', 'edit', String(number)]
for (const label of add) args.push('--add-label', label)
@@ -160,7 +274,10 @@ export const parseArgs = (argv, flagsWithValue) => {
if (arg.startsWith('--')) {
const name = arg.slice(2)
if (flagsWithValue.has(name)) {
- flags[name] = argv[index + 1]
+ const next = argv[index + 1]
+ // A following `--flag` is not a value: `--issue --stay` leaves the
+ // flag unset instead of silently consuming another flag as its value.
+ flags[name] = typeof next === 'string' && next.startsWith('--') ? undefined : next
index += 1
} else {
flags[name] = true
diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs
index f74e8103..385ccae3 100644
--- a/scripts/lib/worktree.mjs
+++ b/scripts/lib/worktree.mjs
@@ -24,12 +24,15 @@ 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 `/plan-issue` (OPS31 — auto-submit is the real need of the flow) and
- * `new` sends nothing ("apenas conversar", no skill at all). The opencode
- * CLI's `--prompt` always auto-submits, there is no prefill-without-submit
- * flag; prefill stays an upstream-only feature request (proposition
- * annotated), not a Teqo fallback.
+/**
+ * Skill command sent as the launch's initial message per purpose. `next`
+ * sends `/work-issue` (the OPS25 command executes the full cycle; the OPS33
+ * launch appends `--issue ` — the claimed issue — via
+ * `opencodeLaunchDirective`); `plan` sends `/plan-issue` (OPS31 — auto-submit
+ * is the real need of the flow) and `new` sends nothing ("apenas conversar",
+ * no skill at all). The opencode CLI's `--prompt` always auto-submits, there
+ * is no prefill-without-submit flag; prefill stays an upstream-only feature
+ * request (proposition annotated), not a Teqo fallback.
*/
export const OPENCODE_SKILL_COMMAND_BY_PURPOSE = {
next: '/work-issue',
@@ -123,17 +126,28 @@ const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback })
* Launch directive for the opencode TUI, printed by `worktree next`/`plan`
* right before the `cd ` line when called from the interactive terminal
* (`WORKTREE_TERMINAL=1`): `launch opencode --model --auto
- * [--prompt ]`. The shell function (`.agents/shell/worktree.sh`)
- * applies the `cd` first, then word-splits and executes this line — the dir
- * is always `/`, so the line never
- * needs quoting. Returns `null` outside the terminal so the `/worktree`
- * opencode command never launches a nested TUI.
+ * [--prompt ""]`. The shell function (`.agents/shell/worktree.sh`)
+ * applies the `cd` first, then tokenizes and executes this line (xargs —
+ * quote-aware, never eval) — the dir is always `/`, so the line never needs quoting for the path. `next` with an
+ * `issueNumber` sends `/work-issue --issue ` (OPS33: the launch delivers the
+ * claimed issue to the agent; the skill reads the rest from GitHub), the prompt
+ * value is quoted in the line because it now carries a space. Returns `null`
+ * outside the terminal so the `/worktree` opencode command never launches a
+ * nested TUI.
+ * @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null }} options
*/
-export const opencodeLaunchDirective = ({ dir, purpose, terminal = false }) => {
+export const opencodeLaunchDirective = ({ dir, purpose, terminal = false, issueNumber = null }) => {
if (!terminal) return null
const prompt = OPENCODE_SKILL_COMMAND_BY_PURPOSE[purpose]
const args = [dir, '--model', OPENCODE_PRESET_MODEL, '--auto']
- if (prompt) args.push('--prompt', prompt)
+ if (prompt) {
+ // The issue suffix belongs to `next` alone — `plan`/`new` never carry a
+ // claimed issue (fail-safe: a stray issueNumber must not break /plan-issue).
+ const value = purpose === 'next' && issueNumber ? `${prompt} --issue ${issueNumber}` : prompt
+ // JSON.stringify quotes the value — the directive carries spaces now.
+ args.push('--prompt', JSON.stringify(value))
+ }
return `launch opencode ${args.join(' ')}`
}
diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs
index ddc3ade9..59719960 100644
--- a/scripts/worktree.mjs
+++ b/scripts/worktree.mjs
@@ -2,27 +2,38 @@
* `pnpm worktree` — worktree management determinístico em torno da fila de
* claim do projeto (mesma fila de `agent:claim` / agent pool).
*
- * pnpm worktree next [--stay] [--no-migrate]
- * cria worktree a partir de origin/main para a
- * próxima Issue claimável; branch `-`.
+ * pnpm worktree next [--issue N] [--stay] [--no-migrate]
+ * claima a próxima Issue claimável ANTES de criar
+ * o worktree a partir de origin/main (mesma fila
+ * e lock otimista de `pnpm agent:claim`; claim
+ * falhou → motivo e saída SEM worktree órfão);
+ * branch `-`.
+ * `--issue N` claima a Issue direcionada (`ready`)
+ * ou REABRE uma já claimada (`in-progress` — sem
+ * re-claim; worktree reutilizado/criado e launch
+ * na hora). O texto de saída avisa "já claimada —
+ * não rodar `pnpm agent:claim`".
* Por padrão imprime `cd ` no fim — node
* não muda o cwd do shell pai; o shell chamador
* aplica (opencode/CDP usa o opencode command;
* terminal interativo: função `worktree()` em
* `.agents/shell/worktree.sh`, sourced no profile).
- * `--stay` suprime a linha `cd`; `--go` explícito
- * continua aceito como no-op (era o antigo padrão).
+ * `--stay` suprime a linha `cd` (o claim ainda
+ * acontece); `--go` explícito continua aceito como
+ * no-op (era o antigo padrão).
* Chamado do terminal interativo (com
* `TEQO_WORKTREE_TERMINAL=1`, que só a função shell
* seta), imprime também a diretiva `launch
* opencode --model deepseek/deepseek-v4-flash
- * --auto --prompt /work-issue` ANTES do `cd` — a
- * função shell executa o cd e então a linha, e o
- * TUI do opencode abre no worktree com `/work-issue`
- * já enviado (OPS26). Presets em
- * scripts/lib/worktree.mjs. Sem o marcador (comando
- * `/worktree` do opencode), a diretiva não é
- * impressa — nunca abre TUI aninhado.
+ * --auto --prompt "/work-issue --issue "` ANTES
+ * do `cd` — a função shell executa o cd e então a
+ * linha, e o TUI do opencode abre no worktree com
+ * `/work-issue --issue ` já enviado (OPS26 +
+ * OPS33: o launch entrega a Issue claimada ao
+ * agente; a skill lê o resto do GitHub). Presets
+ * em scripts/lib/worktree.mjs. Sem o marcador
+ * (comando `/worktree` do opencode), a diretiva
+ * não é impressa — nunca abre TUI aninhado.
* Também PROVISIONA o ambiente isolado do worktree:
* porta do dev server + bancos próprios derivados
* deterministicamente do branch (ver
@@ -73,7 +84,10 @@
* fica num diretório destruído (não aceita
* `--stay`; `--go` é no-op)
*
- * Read-only no GitHub: `next` NUNCA claima (claim = `pnpm agent:claim`).
+ * Read-only no GitHub? NÃO — desde o OPS33 `next` CLAIMA: claim determinístico
+ * antes do worktree (mesma fila/ordem e lock otimista do `pnpm agent:claim`;
+ * `--issue N` claim direcionado ou reabre sessão já claimada). `plan`/`new`/
+ * `kill` não tocam Issues. O claim do pool (supervisor coordenado) é intacto.
* Dir raiz: `~/.cursor/worktrees/teqo/` (mesma casa dos worktrees do Cursor).
*/
@@ -86,7 +100,17 @@ import { join, resolve } from 'node:path'
import { parse as parseEnv } from 'dotenv'
import pg from 'pg'
-import { dieAgent, nextClaimableIssue, parseArgs } from './lib/agent-github.mjs'
+import {
+ claimBriefLines,
+ claimIssue,
+ claimQueueEntry,
+ claimTargetVerdict,
+ dieAgent,
+ ghJson,
+ issuesById,
+ nextClaimableIssue,
+ parseArgs,
+} from './lib/agent-github.mjs'
import {
DEV_PORT_BASE,
GENERATED_ENV_MARKER,
@@ -114,8 +138,8 @@ const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo')
const terminalShell = process.env[WORKTREE_TERMINAL_ENV] === '1'
/** Print the `launch` directive (only exists from the terminal shell); the `cd` line stays last. */
-const printLaunchDirective = ({ dir, purpose }) => {
- const line = opencodeLaunchDirective({ dir, purpose, terminal: terminalShell })
+const printLaunchDirective = ({ dir, purpose, issueNumber }) => {
+ const line = opencodeLaunchDirective({ dir, purpose, terminal: terminalShell, issueNumber })
if (line) console.log(line)
}
@@ -379,54 +403,130 @@ const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot, purpo
runSeedMinimal(dir, testUrl, payloadSecret)
}
-const cmdNext = async (stay, skipMigrate) => {
+/**
+ * Pick determinístico do `next` — READ-ONLY no GitHub: `--issue ` escolhe a
+ * Issue direcionada (via `claimTargetVerdict`: `ready` → claim, `in-progress` →
+ * reopen sem re-claim), sem a flag escolhe a próxima da fila (mesmo pick/ordem
+ * do `pnpm agent:claim`). O claim em si acontece só em `cmdNext`, DEPOIS da
+ * derivação/validação do branch — uma Issue sem frontmatter id (ou branch
+ * inválido) morre antes do flip de labels, nunca deixando claim órfão.
+ */
+const pickNextIssue = ({ requestedIssueNumber, die }) => {
+ if (requestedIssueNumber !== null) {
+ const raw = String(requestedIssueNumber)
+ const number = Number(raw)
+ if (!Number.isInteger(number) || number <= 0) {
+ die(`--issue inválido: ${raw}`)
+ }
+ const target = ghJson([
+ 'issue',
+ 'view',
+ raw,
+ '--json',
+ 'number,title,body,labels,createdAt,state',
+ ])
+ const verdict = claimTargetVerdict(target)
+ if (verdict.kind === 'error') die(verdict.message)
+ const entry = claimQueueEntry(target, issuesById())
+ if (verdict.kind === 'reopen') {
+ // Sessão já claimada — reabrir não re-claima (reopen é sobre a sessão,
+ // nunca sobre a fila: deps atuais não importam).
+ return { entry, reopened: true, directed: true }
+ }
+ if (entry.blockedBy.length > 0) {
+ die(`Issue #${number} não é claimável (bloqueada por ${entry.blockedBy.join(', ')}).`)
+ }
+ return { entry, reopened: false, directed: true }
+ }
+
const pick = nextClaimableIssue()
if (!pick) {
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.')
}
+ return { entry: pick, reopened: false, directed: false }
+}
- const issue = pick.issue
- const branch = branchNameForIssue({ ...issue, meta: pick.meta })
+const cmdNext = async (stay, skipMigrate, requestedIssueNumber) => {
+ const { entry, reopened, directed } = pickNextIssue({ requestedIssueNumber, die })
+
+ const issue = entry.issue
+ const branch = branchNameForIssue({ ...issue, meta: entry.meta })
if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) {
die(`Branch derivado inválido para refname: ${branch}`)
}
const dir = join(WORKTREES_ROOT, branch)
- console.log(`Próxima da fila: #${issue.number} ${issue.title}`)
+ // O claim flips labels — só depois da derivação do branch provar que a Issue
+ // tem tudo para virar worktree.
+ if (!reopened) claimIssue(entry, die)
+ const claimedIssueNumber = reopened ? null : issue.number
- git(['fetch', 'origin'])
+ const headline = reopened
+ ? 'Sessão já claimada — reabrindo (sem re-claim)'
+ : directed
+ ? 'Claimado (direcionado — `--issue`)'
+ : 'Claimado da fila'
+ console.log(`\n${headline}: #${issue.number} ${issue.title}`)
+ for (const line of claimBriefLines(entry)) console.log(line)
- const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
- if (entries.some((entry) => resolve(entry.path) === resolve(dir))) {
- console.log(`Worktree 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'])
+ try {
+ git(['fetch', 'origin'])
- console.log('Worktree criado:')
- console.log(` branch: ${branch}`)
- console.log(` path: ${dir}`)
- console.log(' origem: origin/main')
- console.log('Issue NÃO claimada — claim continua sendo `pnpm agent:claim`.')
+ const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
+ if (entries.some((entry) => resolve(entry.path) === resolve(dir))) {
+ console.log(`Worktree já existe em ${dir} — reutilizando, sem duplicar.`)
+ } else {
+ const branchExists =
+ git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
+ okIfFails: true,
+ }) !== null
+ if (branchExists) {
+ // Reopen com worktree removido à mão: `-B` resetaria commits de sessão.
+ const ahead = git(['log', '--oneline', `origin/main..${branch}`], { okIfFails: true })
+ if (ahead) {
+ die(
+ `Branch ${branch} tem commits fora de origin/main e o worktree não existe — reabrir com -B descartaria esses commits. Rode \`git branch -D ${branch}\` (sessão encerrada) ou resolva antes.`,
+ )
+ }
+ }
+ const flag = branchExists ? '-B' : '-b'
+ git(['worktree', 'add', flag, branch, dir, 'origin/main'])
+
+ console.log('Worktree criado:')
+ console.log(` branch: ${branch}`)
+ console.log(` path: ${dir}`)
+ console.log(' origem: origin/main')
+ }
+ console.log(
+ reopened
+ ? 'Issue já claimada (sessão reaberta) — NÃO rodar `pnpm agent:claim` (o claim é parte do `worktree next`).'
+ : 'Issue claimada por este comando — NÃO rodar `pnpm agent:claim` (o claim é parte do `worktree next`).',
+ )
+
+ const mainRoot = entries[0]?.path
+ const env = worktreeEnvironment({
+ branch,
+ code: entry.meta.id,
+ takenSlots: readLiveSlots(entries, dir),
+ })
+ await provision({ dir, branch, issue, env, skipMigrate, mainRoot })
+
+ console.log(`\nAmbiente isolado do worktree (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}`)
+ } catch (error) {
+ if (claimedIssueNumber !== null) {
+ console.error(
+ `\n[worktree] Issue #${claimedIssueNumber} ficou claimada (o worktree não foi concluído) — ` +
+ `reabra quando quiser: \`pnpm worktree next --issue ${claimedIssueNumber}\`.\n`,
+ )
+ }
+ throw error
}
- const mainRoot = entries[0]?.path
- const env = worktreeEnvironment({
- branch,
- code: pick.meta.id,
- takenSlots: readLiveSlots(entries, dir),
- })
- await provision({ dir, branch, issue, env, skipMigrate, mainRoot })
-
- console.log(`\nAmbiente isolado do worktree (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: 'next' })
+ printLaunchDirective({ dir, purpose: 'next', issueNumber: issue.number })
console.log(`cd ${dir}`)
}
}
@@ -632,25 +732,26 @@ const cmdKill = async (force) => {
console.log(`cd ${mainRoot}`)
}
-const { flags, positional } = parseArgs(process.argv.slice(2), new Set())
+const { flags, positional } = parseArgs(process.argv.slice(2), new Set(['issue']))
const subcommand = positional[0]
if (!subcommand) {
console.log(
- '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')
- console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);')
- console.log(' por padrão imprime `cd ` no fim (quem aplica o cd: opencode command, ou a')
- console.log(
- ' função `worktree()` de .agents/shell/worktree.sh); no terminal (TEQO_WORKTREE_TERMINAL=1)',
- )
- console.log(' imprime também a diretiva `launch opencode …` (OPS26: abre o TUI com')
- console.log(' deepseek/deepseek-v4-flash + auto + /work-issue enviado); --stay suprime cd e')
- console.log(
- ' launch; --go explícito continua aceito como no-op; --no-migrate pula migrations e o',
+ 'Uso: pnpm worktree next [--issue N] [--stay] [--no-migrate] | plan [bag] [--stay] [--no-migrate] | new [bag] [--stay] [--no-migrate] | kill [--force]',
)
+ console.log(' next [--issue N] [--stay] [--no-migrate]')
+ console.log(' CLAIMA a próxima Issue claimável (mesma fila/ordem e lock otimista do')
+ console.log(' `pnpm agent:claim`) e cria o worktree dela (branch -),')
+ console.log(' provisionando o ambiente isolado: porta de dev + bancos próprios;')
+ console.log(' claim falhou → motivo e saída sem worktree. `--issue N` claima a Issue')
+ console.log(' direcionada (`ready`) ou REABRE uma já claimada (`in-progress`, sem')
+ console.log(' re-claim). Por padrão imprime `cd ` no fim (quem aplica o cd:')
+ console.log(' opencode command, ou a função `worktree()` de .agents/shell/worktree.sh);')
+ console.log(' no terminal (TEQO_WORKTREE_TERMINAL=1) imprime também a diretiva')
+ console.log(' `launch opencode … --prompt "/work-issue --issue "` (OPS26+OPS33:')
+ console.log(' abre o TUI com deepseek/deepseek-v4-flash + auto + a Issue claimada já')
+ console.log(' informada); --stay suprime cd e launch (o claim ainda acontece); --go')
+ console.log(' explícito continua aceito como no-op; --no-migrate pula migrations e o')
console.log(' seed mínimo (db:seed:minimal) nos bancos novos (OPS28: paridade com a CI)')
console.log(`\n plan [bag] [--stay] [--no-migrate]`)
console.log(
@@ -677,8 +778,15 @@ if (!subcommand) {
}
try {
- if (subcommand === 'next') await cmdNext(Boolean(flags.stay), Boolean(flags['no-migrate']))
- else if (subcommand === 'plan')
+ if (subcommand === 'next') {
+ if ('issue' in flags && flags.issue === undefined) {
+ die('`--issue` requer um número (ex.: `--issue 595`).')
+ }
+ if (positional.length > 1) {
+ die('`next` não aceita argumento posicional — use `--issue ` para direcionar a Issue.')
+ }
+ await cmdNext(Boolean(flags.stay), Boolean(flags['no-migrate']), flags.issue ?? null)
+ } 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])
diff --git a/tests/unit/agentPoolEligibility.unit.spec.ts b/tests/unit/agentPoolEligibility.unit.spec.ts
index 7909af53..ba16dd48 100644
--- a/tests/unit/agentPoolEligibility.unit.spec.ts
+++ b/tests/unit/agentPoolEligibility.unit.spec.ts
@@ -2,7 +2,11 @@
import { describe, expect, it } from 'vitest'
-import { buildClaimQueue } from '../../scripts/lib/agent-github.mjs'
+import {
+ buildClaimQueue,
+ claimQueueEntry,
+ claimTargetVerdict,
+} from '../../scripts/lib/agent-github.mjs'
import {
buildPoolQueue,
isAutonomousClaimable,
@@ -177,3 +181,68 @@ describe('buildClaimQueue parity (shared with agent:claim)', () => {
expect(queue.map((item) => item.issue.number)).toEqual([2])
})
})
+
+describe('claimQueueEntry (single-issue shape for `worktree next --issue` reopen)', () => {
+ const byId = new Map([
+ ['B1', issue({ number: 101, state: 'CLOSED', labels: [{ name: 'done' }] })],
+ ['B2', issue({ number: 102, labels: [{ name: 'in-progress' }] })],
+ ])
+ const fm = (depends: string[]) => `---\nid: BX\ndepends: [${depends.join(', ')}]\n---\nspec`
+
+ it('derives the same entry shape as buildClaimQueue for one issue', () => {
+ const target = issue({ number: 7, body: fm(['B1']), labels: [{ name: 'in-progress' }] })
+ const entry = claimQueueEntry(target, byId)
+ expect(entry.issue.number).toBe(7)
+ expect(entry.meta).toEqual({ id: 'BX', depends: ['B1'] })
+ expect(entry.priority).toBe('prio:P2')
+ expect(entry.satisfiedWithoutIssue).toEqual([])
+ expect(entry.blockedBy).toEqual([])
+ })
+
+ it('does NOT re-filter by deps — reopening is about the session, not the queue', () => {
+ const target = issue({ number: 7, body: fm(['B2']) })
+ const entry = claimQueueEntry(target, byId)
+ expect(entry.blockedBy).toEqual(['B2'])
+ })
+
+ it('defaults the priority from the prio label, like the queue does', () => {
+ const target = issue({
+ number: 7,
+ labels: [{ name: 'in-progress' }, { name: 'prio:P0' }],
+ })
+ expect(claimQueueEntry(target, byId).priority).toBe('prio:P0')
+ })
+
+ it('surfaces satisfied deps without an issue (brief deps line)', () => {
+ const target = issue({ number: 8, body: fm(['B9']) })
+ expect(claimQueueEntry(target, byId).satisfiedWithoutIssue).toEqual(['B9'])
+ })
+})
+
+describe('claimTargetVerdict (`worktree next --issue` target decision)', () => {
+ it('reopens an in-progress issue — no claim', () => {
+ expect(claimTargetVerdict(issue({ labels: [{ name: 'in-progress' }] }))).toEqual({
+ kind: 'reopen',
+ })
+ })
+
+ it('claims a ready issue', () => {
+ expect(claimTargetVerdict(issue({ labels: [{ name: 'ready' }] }))).toEqual({ kind: 'claim' })
+ })
+
+ it('rejects a closed issue', () => {
+ const verdict = claimTargetVerdict(issue({ state: 'CLOSED', labels: [{ name: 'ready' }] }))
+ expect(verdict).toEqual({ kind: 'error', message: expect.stringContaining('não está aberta') })
+ })
+
+ it('rejects an issue with neither ready nor in-progress', () => {
+ const verdict = claimTargetVerdict(issue({ labels: [{ name: 'blocked' }] }))
+ expect(verdict).toEqual({ kind: 'error', message: expect.stringContaining('não é claimável') })
+ })
+
+ it('in-progress wins over a stale ready double label (reopen, not re-claim)', () => {
+ expect(
+ claimTargetVerdict(issue({ labels: [{ name: 'ready' }, { name: 'in-progress' }] })),
+ ).toEqual({ kind: 'reopen' })
+ })
+})
diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts
index e104483a..8d743596 100644
--- a/tests/unit/worktree.unit.spec.ts
+++ b/tests/unit/worktree.unit.spec.ts
@@ -134,23 +134,34 @@ describe('planBranchName (per-invocation planning worktrees)', () => {
})
})
-describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26)', () => {
+describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26 + OPS33)', () => {
const dir = '/home/fsolla/.cursor/worktrees/teqo/OPS26-foo'
it('returns null outside the terminal — the /worktree command never launches a TUI', () => {
expect(opencodeLaunchDirective({ dir, purpose: 'next' })).toBeNull()
expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: false })).toBeNull()
+ expect(
+ opencodeLaunchDirective({ dir, purpose: 'next', terminal: false, issueNumber: 595 }),
+ ).toBeNull()
})
- it('next launches with the preset model, --auto and the /work-issue command sent', () => {
+ it('next launches with the preset model, --auto and the /work-issue command sent (value always quoted)', () => {
expect(opencodeLaunchDirective({ dir, purpose: 'next', terminal: true })).toBe(
- `launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt /work-issue`,
+ `launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/work-issue"`,
)
})
- it('plan launches with the same presets and /plan-issue sent (OPS31)', () => {
+ it('next with an issueNumber sends /work-issue --issue with the value quoted (spaces)', () => {
+ expect(
+ opencodeLaunchDirective({ dir, purpose: 'next', terminal: true, issueNumber: 595 }),
+ ).toBe(
+ `launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/work-issue --issue 595"`,
+ )
+ })
+
+ it('plan launches with the same presets and /plan-issue sent (OPS31, value quoted)', () => {
expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true })).toBe(
- `launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt /plan-issue`,
+ `launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/plan-issue"`,
)
})
@@ -160,6 +171,15 @@ describe('opencodeLaunchDirective (terminal-only opencode launch, OPS26)', () =>
)
})
+ it('plan/new ignore the issueNumber — only next carries the claimed issue', () => {
+ expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, issueNumber: 7 })).toBe(
+ `launch opencode ${dir} --model deepseek/deepseek-v4-flash --auto --prompt "/plan-issue"`,
+ )
+ expect(opencodeLaunchDirective({ dir, purpose: 'new', terminal: true, issueNumber: 7 })).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')