mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
b4278f1632
commit
f0f5b73f3f
13 changed files with 600 additions and 201 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:*`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 <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 <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)
|
||||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(' ')}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue