fix(OPS122): launch do worktree honra o modelo na sessão e o --auto (Closes #1088)

This commit is contained in:
Francisco Solla 2026-09-16 18:48:17 -03:00
parent f6a6d8e42b
commit 78b24ed51d
12 changed files with 653 additions and 34 deletions

View file

@ -8,7 +8,7 @@
#
# No terminal (esta função), o script também imprime a diretiva `launch node
# scripts/agent-session.mjs start --purpose=<next|plan|new|fix> --dir=<dir>
# --model=<preset|map> [--issue=<N>] [--argument="<bag>"]` (OPS26+OPS33+OPS93+OPS95+OPS110):
# --model=<preset|map> [--skill-auto] [--issue=<N>] [--argument="<bag>"]` (OPS26+OPS33+OPS93+OPS95+OPS110+OPS122):
# a função executa o cd e então a linha (tokenizada por xargs — honra as aspas
# do argumento, nunca eval). Desde a OPS110 o launch não abre um TUI local dono
# da sessão: o `agent-session` sobe/reaproveita o `opencode serve` compartilhado,
@ -31,6 +31,14 @@
# 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.
#
# OPS122: `--auto` (o opt-out do GATE da skill) transposta para a invocation do
# driver — `/work-issue --issue <N> --auto`, `/plan-issue --auto <bag>`,
# `/bug-fix --auto <bag>`. Vale em `next`/`fix` e em `plan` COM bag; flag
# desconhecida, `--auto` sem destino (`new`, `plan` sem bag), `--stay --auto`,
# `--headless --auto` ou `--auto` fora do terminal falham alto (nunca descarte
# silencioso). O modelo pedido passa a ser gravado na própria SESSÃO do servidor
# (o TUI anexado herda), inclusive driverless — não só no argv do driver.
#
# Instalação (uma linha no profile; requer bash ou zsh — usa BASH_SOURCE, arrays e here-strings):
# source <repo>/.agents/shell/worktree.sh
#

View file

@ -51,7 +51,7 @@ Fases pesadas são delegadas a sub-agentes com contexto mínimo — o agente pri
- [ ] 3. Dispatch sub-agente investigador → causa-raiz
- [ ] 4. Fix da causa-raiz + teste de regressão (main agent iterativo)
- [ ] 5. Dispatch sub-agente verificador → prova verde
- [ ] 6. Gates + PR → merge; bug de prod: deploy (staging automático, produção após approve) → confirmação do humano
- [ ] 6. Gates + PR → merge; bug de prod: deploy (staging automático, produção após approve humano — `--auto` NÃO aprova produção) → confirmação do humano
- [ ] 7. Dispatch sub-agente estrategista → prevenção classificada
- [ ] 8. Implementar prevenção barata agora (a cara fica no post-mortem)
- [ ] 9. Dispatch sub-agente escritor → post-mortem + entrada de changelog
@ -77,6 +77,17 @@ O relatório chega no `$ARGUMENTS` (a descrição passada ao `worktree fix`), do
Ausente → **uma** pergunta ao humano ("Qual o bug?"). Se houver Issue no GitHub (`github.com/fsolla/teqo/issues`), leia-a via `pnpm issue` / `scripts/issue.mjs` — mas bug-fix não exige Issue (o post-mortem é o registro).
## Modo autônomo (`--auto`)
Opt-out da pausa por invocação. Detecta-se em `$ARGUMENTS`: `/bug-fix --auto <bug>` (a flag vive dentro do prompt, junto da descrição; sem parser dedicado). O transporte padrão é o `pnpm worktree fix --auto <bag>`, que auto-submete exatamente `/bug-fix --auto <bag>`.
- **Sem a flag (ou flag desconhecida):** o fluxo supervisionado atual vale idêntico — inclusive a pergunta única do Passo 1 quando o relatório não chegou.
- **Com a flag:** a descrição chega no `$ARGUMENTS` e o fluxo segue direto do Passo 2 ao 9 sem pedir confirmação. Relatório ausente mesmo com a flag → **pare** (sem sintoma não há o que investigar — mesmo fail-closed do Passo 1).
- **Pode auto-aprovar:** o diagnóstico de causa-raiz, a escolha e a implementação do fix, a classificação da prevenção, a prevenção barata, o post-mortem e o push/PR.
- **Continua parando (vale mesmo com a flag):** DB de prod/`ALLOW_REMOTE_DB`; escrita em prod (repro de bug de prod é read-only); Consent/LGPD fail-closed; migração de schema; contrato de URL público; shapes públicos; **aprovação humana da produção** (o deploy de prod nunca é automático — `--auto` não aprova o environment `production`); merge sem CI green; divergência material de produto → pare, registre o pendente no post-mortem e reporte no resumo final. Nunca declare bug de prod corrigido sem a confirmação do humano.
- **Não confundir:** `--auto` aqui é flag da skill dentro do prompt; o `--auto` do CLI opencode (auto-aprovar permissões de ferramenta) é outro conceito.
- **Convivência:** `agent-work-issue` continua sendo o contrato do pool dormente; este modo é opt-in da sessão humana.
## Passo 2 — Reproduzir
Antes de mexer em código: fazer o bug acontecer de forma confiável.

View file

@ -4,4 +4,6 @@ description: Corrige um bug de ponta a ponta: diagnóstico → causa-raiz → fi
Carregue a skill `bug-fix` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta. A fonte canônica é `.agents/skills/bug-fix/SKILL.md` — não a transcreva nem recrie o fluxo.
Uso: /bug-fix [--auto] <descrição do bug>. Com `--auto`, a skill dispensa a pausa do Passo 1 e segue até o PR sem confirmação (o bug de prod ainda exige approve humano na produção); sem ela, o fluxo supervisionado atual vale.
$ARGUMENTS

View file

@ -1,15 +1,17 @@
---
description: Claima a próxima Issue (next [--issue N] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]), cria o worktree dela e provisiona o ambiente; também cria worktree de planejamento (plan [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]), worktree neutro (new [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]), worktree de correção de bug (fix [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]) ou destrói o atual (kill [--force])
description: Claima a próxima Issue (next [--issue N] [--stay] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]), cria o worktree dela e provisiona o ambiente; também cria worktree de planejamento (plan [bag] [--stay] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]), worktree neutro (new [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]), worktree de correção de bug (fix [bag] [--stay] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]) 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 [--issue N] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: **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. `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` escolhe o modelo por invocação no mapa fixo `WORKTREE_MODEL_MAP` (cheap=cheapestinference/deepseek-v4-flash, pro=deepseek/deepseek-v4-pro, zen=opencode/muse-spark-1.3-contributor-free, go=opencode-go/deepseek-v4.1-flash, alibaba=alibaba-token-plan/deepseek-v4-flash, glm=opencode-go/glm-5.3-flash, free=openrouter/openrouter/free); at-most-one (múltiplas → erro).
- `plan [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: 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. No terminal, o bag é também a mensagem de abertura: o launch carrega `--argument="<bag>"` e o driver auto-submete `/plan-issue <bag>`; **sem bag a sessão abre sem driver** (você digita o `/plan-issue` com contexto). Mesmo contrato de `cd` do `next`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
- `next [--issue N] [--stay] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: **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. `--cheap/--pro/--zen/--go/--alibaba/--glm/--free` escolhe o modelo por invocação no mapa fixo `WORKTREE_MODEL_MAP` (cheap=cheapestinference/deepseek-v4-flash, pro=deepseek/deepseek-v4-pro, zen=opencode/muse-spark-1.3-contributor-free, go=opencode-go/deepseek-v4.1-flash, alibaba=alibaba-token-plan/deepseek-v4-flash, glm=opencode-go/glm-5.3-flash, free=openrouter/openrouter/free); at-most-one (múltiplas → erro).
- `plan [bag] [--stay] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: 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. No terminal, o bag é também a mensagem de abertura: o launch carrega `--argument="<bag>"` e o driver auto-submete `/plan-issue <bag>`; **sem bag a sessão abre sem driver** (você digita o `/plan-issue` com contexto). Mesmo contrato de `cd` do `next`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
- `new [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: 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; flag de modelo como no `next`.
- `fix [bag] [--stay] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de **correção de bug** (skill `/bug-fix`) **diferente a cada invocação** (branch `fix/<bag>`, ou `fix/<n>` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `fix/…` nunca conflita com `next`, `plan` nem `new`. **Não claima nem cria Issues** — o registro do bug é o post-mortem da skill. O bag é a descrição do bug: no terminal, o launch carrega `--argument="<bag>"` e o driver destacado auto-submete `/bug-fix <bag>` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
- `fix [bag] [--stay] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`: cria um worktree de **correção de bug** (skill `/bug-fix`) **diferente a cada invocação** (branch `fix/<bag>`, ou `fix/<n>` sequencial sem bag; sufixo `-2`/`-3` em colisão); o prefixo minúsculo `fix/…` nunca conflita com `next`, `plan` nem `new`. **Não claima nem cria Issues** — o registro do bug é o post-mortem da skill. O bag é a descrição do bug: no terminal, o launch carrega `--argument="<bag>"` e o driver destacado auto-submete `/bug-fix <bag>` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd <dir>` por padrão; `--stay` suprime; flag de modelo como no `next`.
- `kill [--force]`: destrói o worktree em que você está, encerra a sessão persistente do run (OPS110) 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.
> **OPS122 — flags honradas ou falha alto.** `--auto` (opt-out do GATE da skill) transposta para a invocation do driver: `/work-issue --issue <N> --auto` (next), `/plan-issue --auto <bag>` (plan com bag), `/bug-fix --auto <bag>` (fix). Vale em `next`/`fix` e em `plan` COM bag; falha alto em `new`, `plan` sem bag, `--stay --auto`, `--headless --auto` e `--auto` **neste comando** (sem `TEQO_WORKTREE_TERMINAL` não há launch — rode `pnpm worktree` no terminal). Flag desconhecida falha alto com sugestão, nunca é descartada. O modelo pedido (`--zen/--go/…`) agora é gravado na própria SESSÃO do servidor, então o TUI anexado o herda inclusive nos driverless (`new`, `plan` sem bag).
> **Este comando roda SEM o marcador `TEQO_WORKTREE_TERMINAL`, então nunca recebe a diretiva `launch`** — só o `cd`. O launch (OPS26/OPS33+OPS93+OPS95+**OPS110**: delega ao `scripts/agent-session.mjs`, que sobe o servidor compartilhado, dispara o driver destacado e anexa o TUI — fechar o terminal não encerra o run) 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`

View file

@ -0,0 +1 @@
**OPS122 — Launch do worktree honra as flags do humano: modelo na sessão + `--auto` (2026-09-16):** fecha os dois sintomas do mesmo defeito — o que a pessoa digita na linha do `pnpm worktree` (`--zen`/`--go`/…, `--auto`) é o que a sessão usa, ou a flag falha alto. **(1) Modelo na SESSÃO:** `createSession` (`scripts/agent-session.mjs`) passa `model: { providerID, id }` no `POST /session` — construção pura em `parseModelRef`/`buildCreateSessionBody` (`scripts/lib/agent-session.mjs`, split no PRIMEIRO `/`, sem `variant` — OPS95) — então o TUI anexado herda o modelo pedido **inclusive nos driverless** (`new`, `plan` sem bag), onde antes o `--model` morria no argv do driver e o anexo caía no pin global. Verificado ao vivo no `opencode serve` 1.18.31: `GET /session/<id>` reporta o model criado. Reuso (`resolveStartDecision` só devolve `reuse` com driver vivo/sessão busy) com modelo divergente → `die` claro ("modelo não registrado" inclusive em estado antigo) — nunca descarte silencioso; a rota `/api/session/:id/model` fica como gatilho de revisão, não código. **(2) `--auto` do humano chega à invocation:** anexado em `purposeInvocation` (dono único) nas formas exatas `/work-issue --issue <N> --auto` (next), `/plan-issue --auto <bag>` (plan com bag) e `/bug-fix --auto <bag>` (fix); o transporte interno usa a const `SKILL_AUTO_FLAG='skill-auto'` (nome distinto do `--auto` de PERMISSÕES que o `driverArgs` emite para o `opencode run`). **(3) Flag desconhecida/não-honrável falha alto (antes sumia):** `WORKTREE_FLAG_ALLOWLIST` + `validateWorktreeFlags` (`scripts/lib/worktree.mjs`) por subcomando, com sugestão de vizinho (Damerau ≤1) — a mensagem/sugestão ganha dono único em `assertKnownFlags` (extraído de `validateSessionFlags`, OPS110-F1) — e `assertSkillAutoSupported` (matriz determinística: `next`/`fix` sempre; `plan` só com bag; nunca `new`, `plan` sem bag, `--stay`, `--headless` ou fora do terminal interativo), checada no dispatch **antes de qualquer git/claim** para não deixar worktree órfão. **Extensão aprovada pelo humano em 2026-09-16:** a revisão provou que a skill `bug-fix` não tinha modo `--auto` (só `work-issue`/`plan-issue` tinham) — transpor `/bug-fix --auto <bag>` seria honra de mentira e sujaria a descrição do bug; a opção escolhida foi implementar a seção **§Modo autônomo (`--auto`)** na skill `bug-fix` espelhando as outras duas (auto-aprova diagnóstico/fix/prevenção/post-mortem/PR; **continua parando** em DB/prod, Consent/LGPD, migração, URL/shapes públicos, **approve humano da produção** e divergência material), com `Uso:` documentado em `.opencode/commands/bug-fix.md` e checklist/Passo 6 ajustados. Unit: `tests/unit/worktree.unit.spec.ts` + `tests/unit/agentSession.unit.spec.ts` (formas exatas da invocation com `auto`, allowlist por verbo, matriz do `assertSkillAutoSupported` incl. `--stay`/`--headless`/fora do terminal, `parseModelRef`/`buildCreateSessionBody` com e sem model, `assertKnownFlags`) — 164 testes nos dois specs; `pnpm gate:fast` verde. Preset `deepseek/deepseek-flash` intocado, OPS119 (`plan`/`new` sem bag driverless) e OPS110 (`purposeInvocation`, ciclo serve/start/attach/stop) preservados; sem mudança de provider/auth, sem migration/schema/UI/access/Consent. Issue #1088; intenção [`worktree-launch-honra-flags.md`](../plans/worktree-launch-honra-flags.md), impl [`worktree-launch-honra-flags-impl.md`](../plans/worktree-launch-honra-flags-impl.md).

View file

@ -0,0 +1,110 @@
# Impl: Launch do worktree honra as flags do humano: o modelo pedido e o `--auto` chegam onde deveriam
Status: em execução
Atualizado em: 2026-09-16
Issue: #1088
Intenção: docs/plans/worktree-launch-honra-flags.md
Appetite restante: herdado ~1 dia (parser + transporte + modelo na sessão; sem UI)
## Leitura da intenção
- **Outcome:** digitar `--zen/--go/…` abre a SESSÃO (a que o TUI anexa) no modelo pedido em **todos** os purposes — inclusive driverless (`new`, `plan` sem bag); `--auto` chega à mensagem inicial nas formas `/work-issue --issue <N> --auto`, `/plan-issue --auto <bag>`, `/bug-fix --auto <bag>`; flag desconhecida ou não-honrável falha alto com sugestão, nunca descarte silencioso.
- **O que NÃO negociar:** preset `deepseek/deepseek-flash` intocado; OPS119 (`plan`/`new` sem bag driverless); ciclo OPS110 (`serve`/`start`/`attach`/`stop`, `TEQO_WORKTREE_TERMINAL`) e contrato `purposeInvocation` preservados; sem mudança de provider/auth; `--auto` do humano ≠ `--auto` de permissões do `opencode run`.
- **O que reavaliar:** (i) a rota `/api/session/:id/model` **não** é necessária no caminho principal — `resolveStartDecision` só devolve `reuse` com driver vivo/sessão busy ([lib/agent-session.mjs:393-417](../../scripts/lib/agent-session.mjs)), então não há reuso ocioso para trocar de modelo; (ii) o item "modelo na sessão" exige tocar `createSession` (HTTP) — manter a decisão testável extraindo a construção do body para helper puro.
### Extensão de escopo (aprovada pelo humano em 2026-09-16)
A revisão de simplify provou que a skill `bug-fix` **não tinha** modo `--auto` (só `work-issue`/`plan-issue` têm): transpor `/bug-fix --auto <bag>` seria honra de mentira e sujaria a descrição do bug com `--auto`. O humano decidiu (opção "Implementar --auto no bug-fix") **estender o item**: a skill `bug-fix` ganha a seção §Modo autônomo (`--auto`), espelhando `work-issue`/`plan-issue` (auto-aprova diagnóstico/fix/prevenção/post-mortem/PR; **continua parando** em DB/prod, Consent/LGPD, migração, URL/shapes públicos, aprovação humana da produção e divergência material — `--auto` nunca aprova o environment `production`), e `.opencode/commands/bug-fix.md` documenta o uso. Isso sobrepõe o "Fora de escopo" da intenção por decisão explícita do dono do produto.
## Abordagem recomendada
```mermaid
flowchart LR
H["worktree plan --zen --auto bag"] --> P[parseArgs]
P --> V["validateWorktreeFlags<br/>allowlist + nearestAcceptedFlag"]
V -->|desconhecida| D1["die + sugestão"]
V --> S["assertSkillAutoSupported(purpose,bag)"]
S -->|não honrável| D2["die + forma válida"]
S --> M["resolveWorktreeModel (at-most-one)"]
M --> L["opencodeLaunchDirective<br/>--model + --skill-auto"]
L --> SH["shell xargs"]
SH --> ST["agent-session start"]
ST --> CS["createSession POST /session {model}"]
ST --> PI["purposeInvocation(auto) → /work-issue --issue N --auto"]
CS --> TUI["opencode attach herda o model"]
PI --> DR["driverArgs run --auto -- …"]
```
**Opções consideradas:** A | B | C (por decisão abaixo)
**Recomendação:** modelo na **sessão** via `POST /session` (B), `--auto` anexado em `purposeInvocation` (A), validação fail-high espelhando OPS110-F1 — ataca a raiz (descarte silencioso / ausência de model na sessão) sem processo fantasma nem dono duplicado.
**Rejeitadas:** driver mínimo só para fixar modelo (A da intenção — reintroduz submissão espúria no driverless, contra OPS119); deixar driverless quebrado (C); `--auto` na diretiva de launch (dois donos do comando).
### Componentes / mudanças
- **`WORKTREE_FLAG_ALLOWLIST` + `validateWorktreeFlags`** (`scripts/lib/worktree.mjs`, novo): allowlist por subcomando; **reusa `nearestAcceptedFlag` importado de `scripts/lib/agent-session.mjs`** (direção `lib→lib`; `agent-session.mjs` só importa `slug`/builtins — sem ciclo; `check:cycles` cobre só `src`). Chamado no dispatch ([scripts/worktree.mjs:1031](../../scripts/worktree.mjs)) após `subcommand`, antes de `cmdNext`/git. Allowlist: `next:[issue,stay,no-migrate,+modelFlags,+auto]`, `plan/new:[stay,no-migrate,+modelFlags,+auto]`, `fix:[stay,no-migrate,headless,directive,+modelFlags,+auto]`, `kill:[force,stay]` (`stay` fica p/ preservar a mensagem específica de `kill`, [:1054](../../scripts/worktree.mjs)).
- **`assertSkillAutoSupported({purpose, bag})`** (`scripts/lib/worktree.mjs`, novo, puro): matriz determinística — `next` sempre; `fix` sempre; `plan` só com bag; `new` nunca; `--stay` + `--auto` nunca (o launch é suprimido). Chamado no dispatch (mesmo ponto), antes de qualquer git. Sem migration, sem access, sem Consent.
- **`opencodeLaunchDirective`** ([scripts/lib/worktree.mjs:202-232](../../scripts/lib/worktree.mjs)): novo parâmetro `skillAuto` → emite `--skill-auto` (nome interno, decisão C) só quando `true`. `--model=` continua **sempre** (driverless inclusive).
- **`printLaunchDirective`** ([scripts/worktree.mjs:225-239](../../scripts/worktree.mjs)) + call sites [:643](../../scripts/worktree.mjs) (`next`) e [:747](../../scripts/worktree.mjs) (`cmdNamespaceBranch`): repassar `skillAuto: Boolean(flags.auto)`.
- **`parseModelRef(model)` + `buildCreateSessionBody({model})`** (`scripts/lib/agent-session.mjs`, novos, puros): split no PRIMEIRO `/` → `{providerID,id}`; throw se não houver `/`; sem `variant` (OPS95); body `{}` sem model. **Colocados aqui** (não em `lib/worktree.mjs`) porque `lib/worktree.mjs` passará a importar de `lib/agent-session.mjs` (decisão A) — evita ciclo.
- **`createSession(url, dir, model)`** ([scripts/agent-session.mjs:269-276](../../scripts/agent-session.mjs)): usa `buildCreateSessionBody`; `POST /session?directory=…` com `{ model: {providerID,id} }` (verificado ao vivo no `serve` 1.18.31: a sessão passa a reportar o model).
- **`cmdStart`** ([scripts/agent-session.mjs:465-559](../../scripts/agent-session.mjs)): passa `model` ao `createSession` ([:515](../../scripts/agent-session.mjs)); `purposeInvocation({..., auto: Boolean(flags['skill-auto'])})` ([:472](../../scripts/agent-session.mjs)); decisão D no ramo `reuse` ([:511](../../scripts/agent-session.mjs)). `driverArgs` ([:291-317](../../scripts/lib/agent-session.mjs)) **intocado** — segue emitindo o `--auto` de permissões; o `--auto` do humano viaja como texto da invocation.
- **`purposeInvocation`** ([scripts/lib/agent-session.mjs:187-203](../../scripts/lib/agent-session.mjs)): novo `auto=false`; formas exatas `--issue <N>[ --auto]`, `[--auto ]<bag>`, `[--auto ][<bag>]`.
- **`SESSION_FLAG_ALLOWLIST.start`** ([scripts/lib/agent-session.mjs:214](../../scripts/lib/agent-session.mjs)): incluir `skill-auto`.
- **`assertSessionState`** ([scripts/lib/agent-session.mjs:113-125](../../scripts/lib/agent-session.mjs)): **não** exige `model` (decisão E); só doc.
- **Docs/comando:** `.agents/shell/worktree.sh` (comentário; shell não muda) e `.opencode/commands/worktree.md` (documentar `--auto` e o fail-high).
- **Migration:** sem migration. **Access/Consent/UI:** N/A (Impeccable A — sem UI).
### Dados → forma (se aplicável)
N/A — CLI; o efeito é observável no `model` do estado/servidor (`~/.local/state/teqo/agent-sessions/<slug>.json` e `GET /session/<id>`) e no log do driver.
## Decisões de engenharia (caro de reverter)
| # | Decisão | Opções / Rejeitadas |
| --- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A | `validateWorktreeFlags` em `lib/worktree.mjs`, reusando `nearestAcceptedFlag` | **Rec:** reusar (um dono da distância OSA). **Rej:** duplicar OSA (drift); extrair p/ `lib/cli.mjs` (churn, 1 call site). |
| B | `--auto` não-honrável falha alto via `assertSkillAutoSupported({purpose,bag})` puro chamado no dispatch antes do git | **Rec:** helper puro (testável, falha antes de criar worktree). **Rej:** embutir em `purposeInvocation` (tarde, já provisionou); validar na skill (fora do launcher). |
| C | Nome do transporte interno no `start` | **Rec:** `--skill-auto` — inequívoco, separa do `--auto` de permissões que `driverArgs` já emite. **Rej:** reusar `--auto` no `start` (dois significados no mesmo pipeline). |
| D | Sessão reusada com `state.model` ≠ pedido | **Rec:** `die` claro ("run já em andamento no modelo X; encerre com `pnpm agent:session stop` e reabra para trocar") — `reuse` só ocorre com driver vivo/sessão busy, então não há janela para trocar; nunca descarte silencioso. **Rej:** trocar via `/api/session/:id/model` (complexidade extra; o ramo "ocioso" é inalcançável por `resolveStartDecision`); ignorar (quebra "honra as flags"). Gatilho de revisão: se reabrir sessão viva virar caso de uso, implementar a troca pela rota. |
| E | `assertSessionState` exige `model`? | **Rec:** não exigir; só documentar `model`. **Rej:** exigir (estados antigos; `model` nunca foi obrigatório). |
| F | Parse do modelo | **Rec:** `parseModelRef` puro em `lib/agent-session.mjs`, throw sem `/`, sem variant (OPS95). **Rej:** em `lib/worktree.mjs` (ciclo com A); aceitar sem `/` (servidor rejeita remoto). |
## Fases verificáveis
1. **Tracer — modelo na sessão:** `parseModelRef`/`buildCreateSessionBody` + `createSession(model)` + `cmdStart` passando `model`. Prova: unit puro do body e criação real contra o `serve` local. `<0,3 dia>`
2. **Transporte `--auto`:** `purposeInvocation(auto)` nas 3 formas + `SESSION_FLAG_ALLOWLIST.start` + `opencodeLaunchDirective({skillAuto})` + repasse nos call sites. Unit das formas exatas. `<0,3 dia>`
3. **Fail-high no parser:** `WORKTREE_FLAG_ALLOWLIST`/`validateWorktreeFlags`/`assertSkillAutoSupported` + dispatch + decisão D. Unit de flag desconhecida com sugestão, das células falsas da matriz e do reuso com modelo divergente. `<0,3 dia>`
4. **Skill `bug-fix` §Modo autônomo** (extensão aprovada): seção na skill + `Uso:` no comando, espelhando `work-issue`/`plan-issue`. `<0,1 dia>`
5. **Docs + gates:** atualizar comentários/`.opencode/commands/worktree.md`; `pnpm gate:fast`; push via `pnpm push`. `<0,1 dia>`
## Adiado com gatilho (triage de simplify, 2026-09-16)
Nenhum achado atingiu o piso de registro (score ≥3); os dois abaixo ficam adiados com gatilho:
- **`assertKnownFlags`/`nearestAcceptedFlag` como helpers neutros de CLI** hoje moram em `scripts/lib/agent-session.mjs` e são importados por `scripts/lib/worktree.mjs` (acopla launcher→domínio de sessão; sem ciclo, sem risco de runtime). **Gatilho:** um 3º consumidor de validação de flag, ou `scripts/lib/cli.mjs` ganhar o dono do parser — então mover os três para `cli.mjs`.
- **Branch de reuso divergente** (`scripts/agent-session.mjs`, decisão D) sem teste unit — o CLI de sessão não tem harness impuro; a decisão pura está coberta por `resolveStartDecision`. **Gatilho:** o CLI ganhar harness impuro, ou a próxima mudança nesse ramo.
## Rabbit holes / Não escopo (engenharia)
- Seletor de modelo no TUI / mexer em `WORKTREE_MODEL_MAP` ou preset.
- `--auto` de permissões do `opencode run`; semântica das skills; `--variant`.
- Caminho `--headless` (OPS106): usa `opencodeHeadlessArgs`, não a invocation de skill — `--auto` do humano não se aplica; documentar.
- `--model` combinado com `--stay`: mantém comportamento atual (gatilho: aplicar a mesma regra depois se incomodar).
- Validar flags do comando `/worktree` como camada separada (a validação é no script, único dono; shell e comando não ganham allowlist própria).
## Riscos e mitigação
- **TUI não herda o model da sessão criada** → validar cedo no Tracer (fase 1); fallback já desenhado e documentado (`POST /api/session/:id/model`), sem mudar contrato público.
- **Quebra dos specs que pinam a diretiva** ([tests/unit/worktree.unit.spec.ts:153-320](../../tests/unit/worktree.unit.spec.ts)) → `--skill-auto` é aditivo; strings sem `auto` permanecem idênticas.
- **`--auto` duplicado confundir replay de argv** → nome interno distinto (C) e comentário no `driverArgs`.
- **Reuse + modelo divergente** (D) → `die` claro; se estourar o appetite, manter o `die` (mais simples que funciona).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (modelo em todos os purposes; `--auto` nas 3 formas; fail-high)
- [ ] Invariantes AGENTS/engineering-standards (preset/OPS110/OPS119 intactos; sem provider/auth)
- [ ] Testes de domínio previstos (unit puro; int N/A justificado)
## Self-score (decision-quality)
5/5 — (1) decisões caras A–F com rejeitadas; (2) cabe no appetite ~1 dia com fases ≤0,3 dia; (3) rabbit holes nomeados (seletor, headless, `--stay`); (4) depth check: reusa `nearestAcceptedFlag` e o padrão `validateSessionFlags`, sem módulo pass-through; (5) outcome de produto preservado — engenharia não reescreveu o aceite.

View file

@ -44,6 +44,7 @@ import { dirname, join, resolve } from 'node:path'
import {
attachArgs,
buildCreateSessionBody,
codeFromBranch,
deriveSessionStatus,
driverArgs,
@ -63,6 +64,7 @@ import {
sessionDirFromEnv,
sessionListPayload,
sessionStatePath,
SKILL_AUTO_FLAG,
startLockPath,
STATE_VERSION,
validateServerBind,
@ -75,13 +77,14 @@ const die = dieWithLabel('agent:session')
const USAGE = `Uso: pnpm agent:session <serve|start|attach|stop|list> [flags]
serve [--hostname=H] [--port=P] sobe/reaproveita o opencode serve compartilhado
start --purpose=<next|plan|new|fix> --dir=<D> [--model=<M>] [--issue=N] [--argument="<bag>"] [--new] [--hostname=H] [--port=P]
start --purpose=<next|plan|new|fix> --dir=<D> [--model=<M>] [--issue=N] [--argument="<bag>"] [--skill-auto] [--new] [--hostname=H] [--port=P]
cria a sessão, sobe o driver destacado e anexa o TUI
attach [--session=<ses_…>|--branch=<B>|--issue=N] (re)entra na sessão (default: branch do cwd)
stop [--session=<ses_…>|--branch=<B>|--issue=N] encerra de forma explícita (abort + fim do driver)
list [--json] [--hostname=H] [--port=P] status dos runs registrados
--model é obrigatório quando o purpose dispara driver (next/fix, plan com bag); 'new' e 'plan' sem bag não disparam driver.`
--model é obrigatório quando o purpose dispara driver (next/fix, plan com bag); 'new' e 'plan' sem bag não disparam driver.
--skill-auto é o --auto do HUMANO (opt-out do GATE da skill), transposto para o texto da invocation; não confundir com o --auto de permissões que o driver recebe.`
/** Loopback by default; the credential rides the env, never the state/log. */
const serverPassword = () => process.env.OPENCODE_SERVER_PASSWORD ?? ''
@ -266,10 +269,10 @@ const sessionExists = async (url, sessionID) => {
const sessionStatuses = async (url) => (await request(url, '/session/status')) ?? {}
const createSession = async (url, dir) => {
const createSession = async (url, dir, model = null) => {
const result = await request(url, `/session?directory=${encodeURIComponent(dir)}`, {
method: 'POST',
body: {},
body: buildCreateSessionBody({ model }),
})
if (!result?.id) die(`não consegui criar a sessão em ${url} (resposta sem id).`)
return result.id
@ -473,6 +476,7 @@ const cmdStart = async (flags) => {
purpose,
issueNumber,
argument: flags.argument ?? null,
auto: Boolean(flags[SKILL_AUTO_FLAG]),
})
if (invocation && !model) die('--model é obrigatório para iniciar o run (driver).')
@ -509,10 +513,19 @@ const cmdStart = async (flags) => {
)
}
if (decision.action === 'reuse') {
// OPS122: `reuse` só acontece com driver vivo/sessão busy (ver
// `resolveStartDecision`), então não há janela para aplicar outro modelo.
// Falha alto sempre que o modelo pedido diverge do registrado — inclusive
// quando o estado é antigo e não tem `model` (nunca descarta em silêncio).
if (model && decision.state.model !== model) {
die(
`run já em andamento em ${decision.state.model ?? 'modelo não registrado'} — --model ${model} não se aplica a uma sessão ativa. Encerre com \`pnpm agent:session stop --branch=${branch}\` e reabra para trocar.`,
)
}
reused = true
state = decision.state
} else {
const sessionID = await createSession(server.url, dir)
const sessionID = await createSession(server.url, dir, model)
state = {
version: STATE_VERSION,
code: codeFromBranch(branch),

View file

@ -182,26 +182,79 @@ export const SESSION_COMMAND_BY_PURPOSE = {
* `/bug-fix`; `plan` with an empty bag is driverless by design (no context to
* plan with — mirrors `new`). Unknown purposes degrade to no command
* (fail-safe, mirrors the old directive).
* @param {{ purpose?: string, issueNumber?: number | string | null, argument?: string | null }} [options]
*
* `auto` (OPS122) is the HUMAN's skill flag (`--auto` on the launcher line),
* NEVER the `opencode run --auto` permission flag that `driverArgs` emits on
* its own. When true it transposes into the skill text exactly as documented:
* `/work-issue --issue <N> --auto`, `/plan-issue --auto <bag>`,
* `/bug-fix --auto <bag>` (bare `/bug-fix --auto` with an empty bag). It is a
* text argument, not an argv flag, because the skill parses it from its
* invocation.
* @param {{ purpose?: string, issueNumber?: number | string | null, argument?: string | null, auto?: boolean }} [options]
*/
export const purposeInvocation = ({ purpose = null, issueNumber = null, argument = null } = {}) => {
export const purposeInvocation = ({
purpose = null,
issueNumber = null,
argument = null,
auto = false,
} = {}) => {
const command = SESSION_COMMAND_BY_PURPOSE[purpose] ?? null
if (!command) return null
if (purpose === 'next') {
const number = Number(issueNumber)
if (!Number.isInteger(number) || number <= 0) return null
return { command, arguments: `--issue ${number}` }
return { command, arguments: `--issue ${number}${auto ? ' --auto' : ''}` }
}
if (purpose === 'fix' || purpose === 'plan') {
// Mesma sanitização da diretiva de launch (fronteira dupla de propósito:
// o xargs da camada shell e o argv do `opencode run` não honram escapes).
const sanitized = typeof argument === 'string' ? argument.replace(/["\\]/g, '').trim() : ''
if (purpose === 'plan' && !sanitized) return null
if (auto) return { command, arguments: sanitized ? `--auto ${sanitized}` : '--auto' }
return { command, arguments: sanitized }
}
return { command, arguments: '' }
}
/**
* Split a `provider/model` string (the format of `WORKTREE_MODEL_MAP` and the
* opencode `--model` flag) into the `{ providerID, id }` pair the server's
* `POST /session` expects. The split is on the FIRST slash: model ids may
* themselves carry slashes (`openrouter/openrouter/free`). Fail high on a
* string without both halves — never send the server a half ref.
* @param {string} model
*/
export const parseModelRef = (model) => {
if (typeof model !== 'string' || model.length === 0) {
throw new Error('parseModelRef: model ausente')
}
const slash = model.indexOf('/')
if (slash <= 0 || slash === model.length - 1) {
throw new Error(`parseModelRef: modelo inválido "${model}" (esperado provider/model)`)
}
return { providerID: model.slice(0, slash), id: model.slice(slash + 1) }
}
/**
* Body of `POST /session` — `{}` when no model was requested, or
* `{ model: { providerID, id } }` when one was (the session then reports the
* model, so the attached TUI inherits it even on a driverless launch). No
* `variant` (OPS95: variants stay on the machine's global config).
* @param {{ model?: string | null }} [options]
*/
export const buildCreateSessionBody = ({ model = null } = {}) => {
if (model === null || model === undefined || model === '') return {}
return { model: parseModelRef(model) }
}
/**
* Internal `start` argv flag carrying the HUMAN's skill `--auto` (opt-out of the
* skill GATE) — deliberately distinct from the `opencode run --auto` permission
* flag `driverArgs` emits. Single owner of the name: the worktree launch
* directive, the session allowlist and the CLI all use this constant.
*/
export const SKILL_AUTO_FLAG = 'skill-auto'
/**
* Accepted flag NAMES per subcommand (OPS110-F1). Names only — the `=`/boolean
* form stays `parseEqualsFlags`' concern. `list`/`start`/`serve` legitimately
@ -211,7 +264,17 @@ export const purposeInvocation = ({ purpose = null, issueNumber = null, argument
*/
export const SESSION_FLAG_ALLOWLIST = {
serve: ['hostname', 'port'],
start: ['purpose', 'dir', 'model', 'issue', 'argument', 'new', 'hostname', 'port'],
start: [
'purpose',
'dir',
'model',
'issue',
'argument',
'new',
SKILL_AUTO_FLAG,
'hostname',
'port',
],
attach: ['session', 'branch', 'issue'],
stop: ['session', 'branch', 'issue'],
list: ['json', 'hostname', 'port'],
@ -264,11 +327,13 @@ export const nearestAcceptedFlag = (name, accepted) => {
* a suggestion when a near neighbor exists; unknown subcommands return silently
* (the CLI prints the USAGE for those). A bare `--` parses to an empty name and
* is ignored, not reported as a typo.
* @param {{ subcommand?: string | null, flags?: Record<string, unknown> }} [options]
*
* Single owner of the fail-high message: both the session CLI
* (`validateSessionFlags`) and the worktree launcher (`validateWorktreeFlags`)
* delegate here, so the wording and the suggestion rule never drift.
* @param {{ flags?: Record<string, unknown>, accepted?: readonly string[] }} [options]
*/
export const validateSessionFlags = ({ subcommand = null, flags = {} } = {}) => {
const accepted = SESSION_FLAG_ALLOWLIST[subcommand]
if (!accepted) return
export const assertKnownFlags = ({ flags = {}, accepted = [] } = {}) => {
for (const name of Object.keys(flags)) {
if (name === '' || accepted.includes(name)) continue
const suggestion = nearestAcceptedFlag(name, accepted)
@ -280,6 +345,18 @@ export const validateSessionFlags = ({ subcommand = null, flags = {} } = {}) =>
}
}
/**
* Fail high on any flag the subcommand does not accept (names only — the
* `=`/boolean form stays `parseEqualsFlags`' concern). Unknown subcommands
* return silently (the CLI prints the USAGE for those).
* @param {{ subcommand?: string | null, flags?: Record<string, unknown> }} [options]
*/
export const validateSessionFlags = ({ subcommand = null, flags = {} } = {}) => {
const accepted = SESSION_FLAG_ALLOWLIST[subcommand]
if (!accepted) return
assertKnownFlags({ flags, accepted })
}
/**
* argv of the detached run driver (sem o binário — o caller usa
* `spawn('opencode', …)`). The `--` separator is required so a command

View file

@ -7,6 +7,7 @@
*/
import { slugify } from '../../src/lib/slug.ts'
import { assertKnownFlags, SKILL_AUTO_FLAG } from './agent-session.mjs'
/**
* Prefix of every `/plan-issue` planning-worktree branch (`pnpm worktree
@ -79,6 +80,85 @@ export const resolveWorktreeModel = (flags = {}) => {
return OPENCODE_PRESET_MODEL
}
const hasBag = (bag) => typeof bag === 'string' && bag.trim().length > 0
/**
* Accepted flag NAMES per worktree subcommand (OPS122). Names only — the
* `=`/value form stays `parseArgs`' concern. Before OPS122 an unknown flag
* (`--auto`, a typo) parsed to `true` and was silently dropped; now the CLI
* fails high with a neighbor suggestion, mirroring OPS110-F1. `stay` is kept
* for `kill` so its specific "não se aplica" message still fires.
*/
export const WORKTREE_FLAG_ALLOWLIST = {
next: ['issue', 'stay', 'no-migrate', 'auto', ...WORKTREE_MODEL_FLAGS],
plan: ['stay', 'no-migrate', 'auto', ...WORKTREE_MODEL_FLAGS],
new: ['stay', 'no-migrate', 'auto', ...WORKTREE_MODEL_FLAGS],
fix: ['stay', 'no-migrate', 'headless', 'directive', 'auto', ...WORKTREE_MODEL_FLAGS],
kill: ['force', 'stay'],
}
/**
* Fail high on any flag the worktree subcommand does not accept. Delegates the
* message/suggestion rule to `assertKnownFlags` (single owner, shared with the
* session CLI). Unknown subcommands return silently — the CLI prints the usage.
* @param {{ subcommand?: string | null, flags?: Record<string, unknown> }} [options]
*/
export const validateWorktreeFlags = ({ subcommand = null, flags = {} } = {}) => {
const accepted = WORKTREE_FLAG_ALLOWLIST[subcommand]
if (!accepted) return
assertKnownFlags({ flags, accepted })
}
/**
* The human's `--auto` (opt-out of the skill GATE) only makes sense when there
* IS a skill invocation to auto-submit. Deterministic matrix, checked BEFORE
* any git/provisioning so an unhonorable flag never leaves an orphan worktree:
* `next`/`fix` always; `plan` only with a bag (OPS119 keeps it driverless
* otherwise); `new` never; `--stay` never (the launch is suppressed); outside
* the interactive terminal never (the `/worktree` command only applies `cd`, so
* the launch — and thus the auto-submit — does not exist there). The purpose
* matrix is checked before the terminal so `new --auto` reports the actionable
* "neutra" message. Throws a clear message pointing at the valid form.
* @param {{ purpose?: string | null, bag?: string | null, stay?: boolean, headless?: boolean, terminal?: boolean }} [options]
*/
export const assertSkillAutoSupported = ({
purpose = null,
bag = null,
stay = false,
headless = false,
terminal = true,
} = {}) => {
if (headless) {
throw new Error(
'`--headless` (auto-unblock) roda `opencode run --command` sem a invocation de skill — `--auto` não se aplica. Remova a flag.',
)
}
if (stay) {
throw new Error(
'`--stay` suprime o launch — `--auto` ficaria sem efeito. Remova `--auto` ou `--stay`.',
)
}
const supported = purpose === 'next' || purpose === 'fix' || (purpose === 'plan' && hasBag(bag))
if (!supported) {
if (purpose === 'plan') {
throw new Error(
'`--auto` exige um comando para auto-submeter: `plan` sem bag abre a sessão sem driver (OPS119). Use `plan <bag>` ou remova `--auto`.',
)
}
if (purpose === 'new') {
throw new Error(
'`new` é uma sessão neutra, sem skill — `--auto` não se aplica. Remova a flag ou use `next`/`plan`/`fix`.',
)
}
throw new Error(`\`--auto\` não se aplica a \`${purpose}\`.`)
}
if (!terminal) {
throw new Error(
'`--auto` só é honrado no terminal interativo — o comando `/worktree` só aplica o `cd`, sem launch. Rode `pnpm worktree` no terminal ou remova `--auto`.',
)
}
}
/**
* Prefix of every neutral-worktree branch (`pnpm worktree new`). Lowercase-led
* `work/…` — structurally disjoint from `next`'s uppercase-led `<Code>-<slug>`
@ -132,8 +212,6 @@ export const branchNameForIssue = (issue, maxLength = 60) => {
return `${code}-${slug.slice(0, keep)}`
}
const hasBag = (bag) => typeof bag === 'string' && bag.trim().length > 0
/**
* Shared branch naming for namespace worktrees NOT tied to the claim queue
* (`plan`, `new`). Every invocation must land on a DIFFERENT branch so
@ -197,7 +275,12 @@ const namespaceBranchName = ({ prefix, bag = '', taken = new Set(), fallback })
* ABSOLUTO do checkout que emitiu a diretiva (worktrees reabertos/criados
* antes do merge não têm o arquivo novo; o relativo apontaria para o branch
* errado). O default relativo existe para os specs.
* @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null, model?: string | null, argument?: string | null, sessionScript?: string }} options
*
* OPS122: `skillAuto` (o `--auto` do HUMANO) vira `--skill-auto` no argv do
* `start` — nome interno distinto do `--auto` de PERMISSÕES que o `driverArgs`
* emite para o `opencode run` (são coisas diferentes). O CLI de sessão o
* transpõe para o texto da invocation (`purposeInvocation`).
* @param {{ dir: string, purpose: string, terminal?: boolean, issueNumber?: number | null, model?: string | null, argument?: string | null, sessionScript?: string, skillAuto?: boolean }} options
*/
export const opencodeLaunchDirective = ({
dir,
@ -207,6 +290,7 @@ export const opencodeLaunchDirective = ({
model = null,
argument = null,
sessionScript = 'scripts/agent-session.mjs',
skillAuto = false,
}) => {
if (!terminal) return null
const selectedModel = model ?? OPENCODE_PRESET_MODEL
@ -218,6 +302,7 @@ export const opencodeLaunchDirective = ({
`--dir=${dir}`,
`--model=${selectedModel}`,
]
if (skillAuto) args.push(`--${SKILL_AUTO_FLAG}`)
// The issue suffix belongs to `next` alone — `plan`/`new`/`fix` never carry
// a claimed issue (fail-safe: a stray issueNumber must not break them).
if (purpose === 'next' && issueNumber) args.push(`--issue=${issueNumber}`)

View file

@ -2,7 +2,7 @@
* `pnpm worktree` — worktree management determinístico em torno da fila de
* claim do projeto (mesma fila de `agent:claim` / agent pool).
*
* pnpm worktree next [--issue N] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
* pnpm worktree next [--issue N] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
* 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
@ -47,6 +47,9 @@
* máquina (Ctrl+T). Presets e mapa em
* scripts/lib/worktree.mjs; ciclo de vida em
* scripts/lib/agent-session.mjs.
* OPS122: `--auto` (opt-out do GATE da skill)
* transposta para a invocation; flag desconhecida
* ou `--auto` sem destino falha alto.
* Sem o marcador (comando `/worktree` do opencode),
* a diretiva não é impressa — nunca abre TUI aninhado.
* Também PROVISIONA o ambiente isolado do worktree:
@ -65,7 +68,7 @@
* recusa a abrir com `{file:…}` pendurado.
* `--no-migrate` pula migrations E o seed (que
* depende do catálogo migrado).
* pnpm worktree plan [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
* pnpm worktree plan [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
* cria um worktree de PLANEJAMENTO novo para rodar
* a skill /plan-issue sem ocupar o main — cada
* invocação cria UM DIFERENTE, para sessões de
@ -103,7 +106,7 @@
* modelo por invocação (sem flag o preset permanece).
* Migrations E seed mínimo nos dois bancos, como
* o `next`/`plan` (OPS28).
* pnpm worktree fix [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
* pnpm worktree fix [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]
* cria um worktree de CORREÇÃO DE BUG (skill
* /bug-fix) DIFERENTE a cada invocação: com bag,
* branch `fix/<bag>` (e `-2`, `-3`, … se o nome
@ -181,6 +184,7 @@ import {
worktreeEnvironment,
} from './lib/worktree-env.mjs'
import {
assertSkillAutoSupported,
branchNameForIssue,
fixBranchName,
headlessDirective,
@ -189,6 +193,7 @@ import {
opencodeLaunchDirective,
planBranchName,
resolveWorktreeModel,
validateWorktreeFlags,
workBranchName,
WORKTREE_MODEL_FLAGS,
WORKTREE_MODEL_MAP,
@ -231,6 +236,8 @@ const printLaunchDirective = ({ dir, purpose, issueNumber, argument = null, flag
issueNumber,
model,
argument,
// OPS122: o `--auto` do humano viaja como flag interna (`--skill-auto`).
skillAuto: Boolean(flags.auto),
// Absoluto de propósito (OPS110): worktrees reabertos/criados antes do
// merge não têm `scripts/agent-session.mjs`; o relativo quebraria o launch.
sessionScript: join(REPO_ROOT, 'scripts', 'agent-session.mjs'),
@ -238,6 +245,23 @@ const printLaunchDirective = ({ dir, purpose, issueNumber, argument = null, flag
if (line) console.log(line)
}
/**
* OPS122: falha alto antes de qualquer git quando `--auto` não tem para onde
* ir (purpose sem driver/skill ou `--stay`, que suprime o launch). Um worktree
* provisionado com a flag descartada seria órfão. O `die` do dispatch externo
* formata o erro.
*/
const assertAutoFlag = ({ purpose, bag = null, flags }) => {
if (!flags.auto) return
assertSkillAutoSupported({
purpose,
bag,
stay: Boolean(flags.stay),
headless: Boolean(flags.headless),
terminal: terminalShell,
})
}
/** Maintenance database on the shared local container — only ever localhost. */
const MAINTENANCE_DATABASE_URL = 'postgresql://teqo:teqo@localhost:5432/postgres'
@ -950,10 +974,10 @@ const subcommand = positional[0]
if (!subcommand) {
console.log(
'Uso: pnpm worktree next [--issue N] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | plan [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | fix [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | kill [--force]',
'Uso: pnpm worktree next [--issue N] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | plan [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | new [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | fix [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free] | kill [--force]',
)
console.log(
' next [--issue N] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]',
' next [--issue N] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]',
)
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>),')
@ -980,7 +1004,7 @@ if (!subcommand) {
' seed mínimo (db:seed:minimal) nos bancos novos (OPS28: paridade com a CI); at-most-one de --cheap/--pro/--zen/--go/--alibaba/--glm/--free (múltiplas → erro)',
)
console.log(
`\n plan [bag] [--stay] [--no-migrate] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
`\n plan [bag] [--stay] [--no-migrate] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
)
console.log(
' cria um worktree de planejamento DIFERENTE a cada invocação (sessões /plan-issue',
@ -1004,7 +1028,7 @@ if (!subcommand) {
' terminal, mesma diretiva `launch` — sessão sem skill (apenas conversar) e --model <map> quando a flag está presente',
)
console.log(
`\n fix [bag] [--stay] [--no-migrate] [--headless --directive <path>] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
`\n fix [bag] [--stay] [--no-migrate] [--headless --directive <path>] [--auto] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`,
)
console.log(' cria um worktree de CORREÇÃO DE BUG (skill /bug-fix) DIFERENTE a cada')
console.log(' invocação: com bag (a descrição do bug), branch fix/<bag> (sufixo -2/-3 se o')
@ -1025,10 +1049,19 @@ if (!subcommand) {
console.log(' driver; estado/lock removidos) e remove os bancos gerados do')
console.log(' worktree (best-effort); imprime')
console.log(' `cd <main>` no fim — o shell sempre volta ao worktree principal')
console.log(' --auto opt-out do GATE da skill: o driver auto-submete')
console.log(' /work-issue --issue <N> --auto | /plan-issue --auto <bag> |')
console.log(' /bug-fix --auto <bag>. Vale em next/fix e em plan COM bag; falha')
console.log(' alto em new, em plan sem bag, com --stay, com --headless e fora')
console.log(' do terminal interativo.')
console.log(' Flag desconhecida falha alto com sugestão (OPS122) — nunca é descartada.')
process.exit(1)
}
try {
// OPS122: falha alto ANTES de qualquer git/claim — flag desconhecida ou
// `--auto` sem destino não pode deixar worktree órfão (espelha OPS110-F1).
validateWorktreeFlags({ subcommand, flags })
if (subcommand === 'next') {
if ('issue' in flags && flags.issue === undefined) {
die('`--issue` requer um número (ex.: `--issue 595`).')
@ -1036,12 +1069,16 @@ try {
if (positional.length > 1) {
die('`next` não aceita argumento posicional — use `--issue <N>` para direcionar a Issue.')
}
assertAutoFlag({ purpose: 'next', flags })
await cmdNext(Boolean(flags.stay), Boolean(flags['no-migrate']), flags.issue ?? null, flags)
} else if (subcommand === 'plan')
} else if (subcommand === 'plan') {
assertAutoFlag({ purpose: 'plan', bag: positional[1], flags })
await cmdPlan(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1], flags)
else if (subcommand === 'new')
} else if (subcommand === 'new') {
assertAutoFlag({ purpose: 'new', flags })
await cmdNew(Boolean(flags.stay), Boolean(flags['no-migrate']), positional[1], flags)
else if (subcommand === 'fix')
} else if (subcommand === 'fix') {
assertAutoFlag({ purpose: 'fix', bag: positional[1], flags })
await cmdFix(
Boolean(flags.stay),
Boolean(flags['no-migrate']),
@ -1050,7 +1087,7 @@ try {
Boolean(flags.headless),
flags.directive ?? null,
)
else if (subcommand === 'kill') {
} else if (subcommand === 'kill') {
if (flags.stay) die('`--stay` não se aplica a `kill` — ele sempre volta ao main.')
await cmdKill(Boolean(flags.force))
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | plan | new | fix | kill)`)

View file

@ -3,7 +3,9 @@
import { describe, expect, it } from 'vitest'
import {
assertKnownFlags,
attachArgs,
buildCreateSessionBody,
codeFromBranch,
DEFAULT_AGENT_SERVER_HOST,
DEFAULT_AGENT_SERVER_PORT,
@ -14,6 +16,7 @@ import {
isLoopbackHost,
isStaleLock,
nearestAcceptedFlag,
parseModelRef,
parseServerState,
parseSessionState,
purposeInvocation,
@ -251,13 +254,126 @@ describe('purposeInvocation (mapa purpose→comando movido do worktree)', () =>
expect(purposeInvocation({ purpose: 'bogus' })).toBeNull()
expect(purposeInvocation({})).toBeNull()
})
it('auto=true transposes to the exact skill flags — /work-issue --issue N --auto', () => {
expect(purposeInvocation({ purpose: 'next', issueNumber: 1088, auto: true })).toEqual({
command: 'work-issue',
arguments: '--issue 1088 --auto',
})
})
it('auto=true prefixes plan/fix bags — /plan-issue --auto <bag> and /bug-fix --auto <bag>', () => {
expect(purposeInvocation({ purpose: 'plan', argument: 'revisa o gate', auto: true })).toEqual({
command: 'plan-issue',
arguments: '--auto revisa o gate',
})
expect(purposeInvocation({ purpose: 'fix', argument: '500 no autosave', auto: true })).toEqual({
command: 'bug-fix',
arguments: '--auto 500 no autosave',
})
})
it('auto=true with an empty fix bag still submits the bare flag — /bug-fix --auto', () => {
expect(purposeInvocation({ purpose: 'fix', auto: true })).toEqual({
command: 'bug-fix',
arguments: '--auto',
})
})
it('auto=false (default) keeps the supervised forms byte-identical', () => {
expect(purposeInvocation({ purpose: 'next', issueNumber: 1088 })).toEqual({
command: 'work-issue',
arguments: '--issue 1088',
})
expect(purposeInvocation({ purpose: 'fix', argument: 'b' })).toMatchObject({
arguments: 'b',
})
expect(purposeInvocation({ purpose: 'fix' })).toMatchObject({ arguments: '' })
expect(purposeInvocation({ purpose: 'plan', argument: 'b' })).toMatchObject({
arguments: 'b',
})
})
it('auto=true never resurrects a missing invocation (new / plan without bag stay null)', () => {
expect(purposeInvocation({ purpose: 'new', auto: true })).toBeNull()
expect(purposeInvocation({ purpose: 'plan', auto: true })).toBeNull()
expect(purposeInvocation({ purpose: 'plan', argument: ' ', auto: true })).toBeNull()
})
})
describe('parseModelRef + buildCreateSessionBody (OPS122 — o modelo chega à SESSÃO)', () => {
it('splits provider/model at the FIRST slash', () => {
expect(parseModelRef('opencode/muse-spark-1.3-contributor-free')).toEqual({
providerID: 'opencode',
id: 'muse-spark-1.3-contributor-free',
})
expect(parseModelRef('openrouter/openrouter/free')).toEqual({
providerID: 'openrouter',
id: 'openrouter/free',
})
expect(parseModelRef('opencode-go/deepseek-v4.1-flash')).toEqual({
providerID: 'opencode-go',
id: 'deepseek-v4.1-flash',
})
})
it('fails high on a model without provider/id — never sends a half ref', () => {
expect(() => parseModelRef('deepseek-flash')).toThrow(/provider\/model|inválido/)
expect(() => parseModelRef('/deepseek-flash')).toThrow(/inválido/)
expect(() => parseModelRef('deepseek/')).toThrow(/inválido/)
expect(() => parseModelRef('')).toThrow()
expect(() => parseModelRef(null as unknown as string)).toThrow()
})
it('builds the POST /session body with the model ref, or an empty body without one', () => {
expect(buildCreateSessionBody({ model: 'opencode-go/deepseek-v4.1-flash' })).toEqual({
model: { providerID: 'opencode-go', id: 'deepseek-v4.1-flash' },
})
expect(buildCreateSessionBody({ model: null })).toEqual({})
expect(buildCreateSessionBody({ model: undefined })).toEqual({})
expect(buildCreateSessionBody({})).toEqual({})
expect(buildCreateSessionBody()).toEqual({})
expect(() => buildCreateSessionBody({ model: 'no-slash' })).toThrow(/inválido|provider/)
})
it('never emits a variant (OPS95 — variants live on the machine config)', () => {
const body = buildCreateSessionBody({ model: 'opencode-go/deepseek-v4.1-flash' })
expect(body.model).not.toHaveProperty('variant')
})
})
describe('assertKnownFlags (dono único da mensagem fail-high)', () => {
it('is silent when every flag is accepted or a bare `--`', () => {
expect(() =>
assertKnownFlags({ flags: { '': true, stay: true }, accepted: ['stay'] }),
).not.toThrow()
})
it('throws with a neighbor suggestion, or as-is without one', () => {
expect(() => assertKnownFlags({ flags: { stya: true }, accepted: ['stay'] })).toThrow(
/--stya[\s\S]*--stay/,
)
expect(() => assertKnownFlags({ flags: { nope: true }, accepted: ['stay'] })).toThrow(
'flag desconhecida: --nope.',
)
})
})
describe('validateSessionFlags + nearestAcceptedFlag (OPS110-F1 — flag desconhecida falha alto)', () => {
it('pins the accepted flag names per verb', () => {
expect(SESSION_FLAG_ALLOWLIST).toEqual({
serve: ['hostname', 'port'],
start: ['purpose', 'dir', 'model', 'issue', 'argument', 'new', 'hostname', 'port'],
start: [
'purpose',
'dir',
'model',
'issue',
'argument',
'new',
'skill-auto',
'hostname',
'port',
],
attach: ['session', 'branch', 'issue'],
stop: ['session', 'branch', 'issue'],
list: ['json', 'hostname', 'port'],
@ -275,6 +391,7 @@ describe('validateSessionFlags + nearestAcceptedFlag (OPS110-F1 — flag desconh
issue: '1',
argument: 'x',
new: true,
'skill-auto': true,
hostname: 'h',
port: '1',
},

View file

@ -15,6 +15,7 @@ import {
worktreeEnvironment,
} from '../../scripts/lib/worktree-env.mjs'
import {
assertSkillAutoSupported,
branchNameForIssue,
FIX_BRANCH_PREFIX,
fixBranchName,
@ -28,8 +29,10 @@ import {
PLAN_BRANCH_PREFIX,
planBranchName,
resolveWorktreeModel,
validateWorktreeFlags,
WORK_BRANCH_PREFIX,
workBranchName,
WORKTREE_FLAG_ALLOWLIST,
WORKTREE_MODEL_FLAGS,
WORKTREE_MODEL_MAP,
WORKTREE_TERMINAL_ENV,
@ -317,6 +320,43 @@ describe('opencodeLaunchDirective (terminal-only agent-session launch, OPS26 + O
}),
).toBeNull()
})
it('skillAuto carries --skill-auto to the session CLI (OPS122 — o --auto do humano)', () => {
expect(
opencodeLaunchDirective({
dir,
purpose: 'next',
terminal: true,
issueNumber: 1088,
skillAuto: true,
}),
).toBe(
`launch node scripts/agent-session.mjs start --purpose=next --dir=${dir} --model=${presetInEffect()} --skill-auto --issue=1088`,
)
expect(opencodeLaunchDirective({ dir, purpose: 'plan', terminal: true, skillAuto: true })).toBe(
`launch node scripts/agent-session.mjs start --purpose=plan --dir=${dir} --model=${presetInEffect()} --skill-auto`,
)
expect(
opencodeLaunchDirective({
dir,
purpose: 'fix',
terminal: true,
skillAuto: true,
argument: 'bug x',
}),
).toBe(
`launch node scripts/agent-session.mjs start --purpose=fix --dir=${dir} --model=${presetInEffect()} --skill-auto --argument="bug x"`,
)
})
it('omits --skill-auto by default and outside the terminal (supervised stays byte-identical)', () => {
expect(opencodeLaunchDirective({ dir, purpose: 'next', terminal: true })).not.toContain(
'--skill-auto',
)
expect(
opencodeLaunchDirective({ dir, purpose: 'next', terminal: false, skillAuto: true }),
).toBeNull()
})
})
describe('namespaceLaunchDescriptor (purpose→options single source, OPS119+)', () => {
@ -481,6 +521,122 @@ describe('resolveWorktreeModel + WORKTREE_MODEL_MAP (OPS93 menu, OPS95 values, O
})
})
describe('validateWorktreeFlags + WORKTREE_FLAG_ALLOWLIST (OPS122 — flag desconhecida falha alto)', () => {
it('pins the accepted names per verb (model flags included)', () => {
const modelFlags = [...WORKTREE_MODEL_FLAGS]
expect(WORKTREE_FLAG_ALLOWLIST).toEqual({
next: ['issue', 'stay', 'no-migrate', 'auto', ...modelFlags],
plan: ['stay', 'no-migrate', 'auto', ...modelFlags],
new: ['stay', 'no-migrate', 'auto', ...modelFlags],
fix: ['stay', 'no-migrate', 'headless', 'directive', 'auto', ...modelFlags],
kill: ['force', 'stay'],
})
})
it('accepts every documented flag per verb', () => {
expect(() =>
validateWorktreeFlags({
subcommand: 'next',
flags: { issue: '1088', stay: true, 'no-migrate': true, auto: true, zen: true },
}),
).not.toThrow()
expect(() =>
validateWorktreeFlags({ subcommand: 'plan', flags: { auto: true, go: true } }),
).not.toThrow()
expect(() =>
validateWorktreeFlags({
subcommand: 'fix',
flags: { headless: true, directive: '/d', auto: true, free: true },
}),
).not.toThrow()
expect(() =>
validateWorktreeFlags({ subcommand: 'kill', flags: { force: true } }),
).not.toThrow()
})
it('suggests the neighbor: --zen flag typo and --stay typo', () => {
expect(() => validateWorktreeFlags({ subcommand: 'next', flags: { zenm: true } })).toThrow(
/--zenm[\s\S]*--zen/,
)
expect(() => validateWorktreeFlags({ subcommand: 'plan', flags: { stya: true } })).toThrow(
/--stya[\s\S]*--stay/,
)
})
it('reports an unknown flag without a suggestion as-is', () => {
expect(() =>
validateWorktreeFlags({ subcommand: 'next', flags: { totallyWrong: true } }),
).toThrow('flag desconhecida: --totallyWrong.')
})
it('rejects a flag that belongs to another verb', () => {
expect(() => validateWorktreeFlags({ subcommand: 'plan', flags: { issue: '1' } })).toThrow(
/--issue/,
)
expect(() => validateWorktreeFlags({ subcommand: 'next', flags: { headless: true } })).toThrow(
/--headless/,
)
expect(() => validateWorktreeFlags({ subcommand: 'kill', flags: { auto: true } })).toThrow(
/--auto/,
)
})
it('ignores a bare `--` and stays silent for an unknown/absent verb', () => {
expect(() =>
validateWorktreeFlags({ subcommand: 'next', flags: { '': true, stay: true } }),
).not.toThrow()
expect(() =>
validateWorktreeFlags({ subcommand: 'bogus', flags: { whatever: true } }),
).not.toThrow()
expect(() =>
validateWorktreeFlags({ subcommand: null, flags: { whatever: true } }),
).not.toThrow()
})
})
describe('assertSkillAutoSupported (OPS122 — `--auto` não-honrável falha alto)', () => {
it('honors next/fix always and plan only with a bag', () => {
expect(() => assertSkillAutoSupported({ purpose: 'next' })).not.toThrow()
expect(() => assertSkillAutoSupported({ purpose: 'fix' })).not.toThrow()
expect(() => assertSkillAutoSupported({ purpose: 'fix', bag: 'bug' })).not.toThrow()
expect(() => assertSkillAutoSupported({ purpose: 'plan', bag: 'uma ideia' })).not.toThrow()
})
it('fails high on new (neutral session, no skill to auto-submit)', () => {
expect(() => assertSkillAutoSupported({ purpose: 'new' })).toThrow(/new|neutra/)
expect(() => assertSkillAutoSupported({ purpose: 'new', bag: 'ideia' })).toThrow(/new|neutra/)
})
it('fails high on plan without a bag (OPS119 keeps it driverless)', () => {
expect(() => assertSkillAutoSupported({ purpose: 'plan' })).toThrow(/bag|driverless|sem/)
expect(() => assertSkillAutoSupported({ purpose: 'plan', bag: ' ' })).toThrow(
/bag|driverless|sem/,
)
})
it('fails high on --stay (the launch is suppressed, so --auto would be dead)', () => {
expect(() => assertSkillAutoSupported({ purpose: 'next', stay: true })).toThrow(/--stay/)
})
it('fails high on --headless (the auto-unblock path has no skill invocation)', () => {
expect(() => assertSkillAutoSupported({ purpose: 'fix', bag: 'bug', headless: true })).toThrow(
/--headless/,
)
})
it('fails high outside the interactive terminal (the /worktree command never launches)', () => {
expect(() => assertSkillAutoSupported({ purpose: 'next', terminal: false })).toThrow(/terminal/)
expect(() => assertSkillAutoSupported({ purpose: 'plan', bag: 'x', terminal: false })).toThrow(
/terminal/,
)
})
it('fails high on an unknown purpose (never invents a command)', () => {
expect(() => assertSkillAutoSupported({ purpose: 'bogus' })).toThrow(/bogus/)
expect(() => assertSkillAutoSupported({})).toThrow()
})
})
describe('workBranchName (per-invocation neutral worktrees)', () => {
it('no bag → first free sequential work/<n>', () => {
expect(workBranchName({})).toBe('work/1')