OPS33: worktree next claima deterministicamente a Issue (--issue N, reopen) e launch com /work-issue --issue N

Claim extraído para scripts/lib/agent-github.mjs (claimIssue/claimBriefLines/
claimQueueEntry/claimTargetVerdict), agent-claim.mjs vira CLI fino com mesma
saída; branch validada antes do claim (sem claim órfão); hint de falha
pós-claim; guard -B contra reset de commits de sessão; diretiva launch com
prompt citado + tokenização xargs no worktree.sh; --issue sem valor/posicional
morrem com motivo (sem misclaim); unit novos (directive com issueNumber,
claimQueueEntry, claimTargetVerdict); docs/textos (AGENTS.md, /worktree, skill
worktree-next-issue, AGENT-OPS, CHANGELOG).
This commit is contained in:
Francisco Solla 2026-08-10 19:28:22 -03:00
parent b4278f1632
commit f0f5b73f3f
13 changed files with 600 additions and 201 deletions

View file

@ -7,10 +7,12 @@
# shell que o chamou.
#
# No terminal (esta função), o script também imprime a diretiva `launch
# opencode <dir> --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 <dir> --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 <N>` 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 <repo>/.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-<bag> (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 <N>"`) — 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
}

View file

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

View file

@ -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 <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) 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-<bag>`, ou `plans/plan-issue-<n>` 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 <dir>` 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/<bag>`, ou `work/<n>` 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 <dir>` 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 <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) 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-<bag>`, ou `plans/plan-issue-<n>` 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 <dir>` 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/<bag>`, ou `work/<n>` 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 <dir>` por padrão; `--stay` suprime.
- `kill [--force]`: destrói o worktree em que você está e remove seus bancos. O script imprime `cd <main>` na última linha — aplique-o (`cd <main>`) 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`

File diff suppressed because one or more lines are too long

View file

@ -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 -- <issue> <P0..P3>` | Troca `prio:*` |
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks` + `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 -- <issue> <P0..P3>` | Troca `prio:*` |
| `pnpm agent:file-miss -- --title ...` | Issue `kind:agent-miss` |
| `pnpm agent:pool -- status\|tick --dry-run\|doctor` | Pool Cloud; start/stop via `gh workflow run agent-pool.yml` |
| `pnpm push [-- -u origin HEAD …]` | Canônico: ensure-deps + gate:push + push |
| `pnpm configure:branch-protection [-- --dry-run]` | Proteção de `main`: `checks` + `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:*`.

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-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 <N>` 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 <N>` (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).

View file

@ -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 <N>` 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 <N>`); `--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()<br/>(fila pronta+unblocked)"]
Q -->|vazia| D1["die: fila vazia — sem worktree"]
Q -->|entry| C["claimIssue(entry, die)<br/>(lock otimista + flip + comentário)"]
P -->|sim| V["gh issue view N"]
V -->|ready| CD["claimIssue direcionado<br/>(não-claimável → die)"]
V -->|in-progress| R["reopen — SEM re-claim<br/>(entry via claimQueueEntry)"]
C --> W["branch → worktree create/reuse<br/>→ provision → brief"]
CD --> W
R --> W
W --> L["launch opencode … --prompt<br/>\"/work-issue --issue N\""]
L --> X["cd &lt;dir&gt; (ú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 <N>` (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 <N>"` (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 <N>`), `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 <N> --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 <N>` (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..<branch>` → 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 <N>`; `--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)

View file

@ -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.`,

View file

@ -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<string, any>, 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 <N>` 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 <N>`: 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

View file

@ -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 <N>` — 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 <dir>` line when called from the interactive terminal
* (`WORKTREE_TERMINAL=1`): `launch opencode <dir> --model <preset> --auto
* [--prompt <command>]`. The shell function (`.agents/shell/worktree.sh`)
* applies the `cd` first, then word-splits and executes this line — the dir
* is always `<root without spaces>/<slugified branch>`, so the line never
* needs quoting. Returns `null` outside the terminal so the `/worktree`
* opencode command never launches a nested TUI.
* [--prompt "<command>"]`. 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 `<root without spaces>/<slugified
* branch>`, so the line never needs quoting for the path. `next` with an
* `issueNumber` sends `/work-issue --issue <N>` (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(' ')}`
}

View file

@ -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 `<code>-<slug>`.
* 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 `<code>-<slug>`.
* `--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 <dir>` 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 <dir> --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 <N>"` 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 <N>` 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 <N>` 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 <code>-<slug>) e provisiona o')
console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);')
console.log(' por padrão imprime `cd <dir>` 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 <code>-<slug>),')
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 <dir>` 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 <N>"` (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 <N>` 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])

View file

@ -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<string, TestIssue>([
['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' })
})
})

View file

@ -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 <N> 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')