diff --git a/.agents/shell/worktree.sh b/.agents/shell/worktree.sh index f19e5c00..bc4fc7b5 100644 --- a/.agents/shell/worktree.sh +++ b/.agents/shell/worktree.sh @@ -8,7 +8,7 @@ # # No terminal (esta função), o script também imprime a diretiva `launch node # scripts/agent-session.mjs start --purpose= --dir= -# --model= [--issue=] [--argument=""]` (OPS26+OPS33+OPS93+OPS95+OPS110): +# --model= [--skill-auto] [--issue=] [--argument=""]` (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 --auto`, `/plan-issue --auto `, +# `/bug-fix --auto `. 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 /.agents/shell/worktree.sh # diff --git a/.agents/skills/bug-fix/SKILL.md b/.agents/skills/bug-fix/SKILL.md index 4ad23c73..a422abf0 100644 --- a/.agents/skills/bug-fix/SKILL.md +++ b/.agents/skills/bug-fix/SKILL.md @@ -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 ` (a flag vive dentro do prompt, junto da descrição; sem parser dedicado). O transporte padrão é o `pnpm worktree fix --auto `, que auto-submete exatamente `/bug-fix --auto `. + +- **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. diff --git a/.opencode/commands/bug-fix.md b/.opencode/commands/bug-fix.md index f2a56b1d..dfb38faa 100644 --- a/.opencode/commands/bug-fix.md +++ b/.opencode/commands/bug-fix.md @@ -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] . 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 diff --git a/.opencode/commands/worktree.md b/.opencode/commands/worktree.md index ee3d54bf..ff8439ef 100644 --- a/.opencode/commands/worktree.md +++ b/.opencode/commands/worktree.md @@ -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 ` 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. `--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-`, 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. No terminal, o bag é também a mensagem de abertura: o launch carrega `--argument=""` e o driver auto-submete `/plan-issue `; **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 ` 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 ` 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. `--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-`, 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. No terminal, o bag é também a mensagem de abertura: o launch carrega `--argument=""` e o driver auto-submete `/plan-issue `; **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 ` 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/`, 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; 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/`, ou `fix/` 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=""` e o driver destacado auto-submete `/bug-fix ` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd ` 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/`, ou `fix/` 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=""` e o driver destacado auto-submete `/bug-fix ` (a descrição chega com a skill). Mesmo contrato de `cd` do `plan`: aplique a última linha `cd ` 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
` na última linha — aplique-o (`cd
`) 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 --auto` (next), `/plan-issue --auto ` (plan com bag), `/bug-fix --auto ` (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` diff --git a/docs/changelog/2026-09-16-ops122.md b/docs/changelog/2026-09-16-ops122.md new file mode 100644 index 00000000..6e41949e --- /dev/null +++ b/docs/changelog/2026-09-16-ops122.md @@ -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/` 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 --auto` (next), `/plan-issue --auto ` (plan com bag) e `/bug-fix --auto ` (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 ` 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). diff --git a/docs/plans/worktree-launch-honra-flags-impl.md b/docs/plans/worktree-launch-honra-flags-impl.md new file mode 100644 index 00000000..478b7936 --- /dev/null +++ b/docs/plans/worktree-launch-honra-flags-impl.md @@ -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 --auto`, `/plan-issue --auto `, `/bug-fix --auto `; 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 ` 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
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
--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 [ --auto]`, `[--auto ]`, `[--auto ][]`. +- **`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/.json` e `GET /session/`) 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. diff --git a/scripts/agent-session.mjs b/scripts/agent-session.mjs index dec2aa52..b0670fad 100644 --- a/scripts/agent-session.mjs +++ b/scripts/agent-session.mjs @@ -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 [flags] serve [--hostname=H] [--port=P] sobe/reaproveita o opencode serve compartilhado - start --purpose= --dir= [--model=] [--issue=N] [--argument=""] [--new] [--hostname=H] [--port=P] + start --purpose= --dir= [--model=] [--issue=N] [--argument=""] [--skill-auto] [--new] [--hostname=H] [--port=P] cria a sessão, sobe o driver destacado e anexa o TUI attach [--session=|--branch=|--issue=N] (re)entra na sessão (default: branch do cwd) stop [--session=|--branch=|--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), diff --git a/scripts/lib/agent-session.mjs b/scripts/lib/agent-session.mjs index 84fc093a..09e6b76f 100644 --- a/scripts/lib/agent-session.mjs +++ b/scripts/lib/agent-session.mjs @@ -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 --auto`, `/plan-issue --auto `, + * `/bug-fix --auto ` (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 }} [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, 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 }} [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 diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index 31bc3b13..7f02dce0 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -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 }} [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 ` 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 `-` @@ -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}`) diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index fb2853b8..4313efbc 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -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/` (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 -),') @@ -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 quando a flag está presente', ) console.log( - `\n fix [bag] [--stay] [--no-migrate] [--headless --directive ] [--cheap|--pro|--zen|--go|--alibaba|--glm|--free]`, + `\n fix [bag] [--stay] [--no-migrate] [--headless --directive ] [--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/ (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
` 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 --auto | /plan-issue --auto |') + console.log(' /bug-fix --auto . 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 ` 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)`) diff --git a/tests/unit/agentSession.unit.spec.ts b/tests/unit/agentSession.unit.spec.ts index 8432d6dd..3d1c9412 100644 --- a/tests/unit/agentSession.unit.spec.ts +++ b/tests/unit/agentSession.unit.spec.ts @@ -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 and /bug-fix --auto ', () => { + 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', }, diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index 143bcc63..6ca46e4c 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -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/', () => { expect(workBranchName({})).toBe('work/1')