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