diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1b75d83..cc9ce26 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,6 +10,10 @@ "name": "agent-fleet", "source": "./plugins/agent-fleet" }, + { + "name": "crew", + "source": "./plugins/crew" + }, { "name": "game-facts", "source": "./plugins/game-facts" diff --git a/README.md b/README.md index 29ae660..caa2105 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Or paste `4nrry/agent-plugins` into the Claude Desktop **Add marketplace** dialo | Plugin | What it is | |---|---| | [agent-fleet](plugins/agent-fleet/) | Rules for multi-agent work — model routing per phase, fan-out sizing, output schemas, and citation verification. Two hooks, because the prose alone measurably did not fire. | +| [crew](plugins/crew/) | Cheap headless workers through three agent CLIs, without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; the worker runs one slice under its own CLI's hooks, installed for the run only. Backends: agy (Antigravity CLI, Gemini; default), opencode (free Zen models, weaker shell guard) and GitHub Copilot CLI (Free plan: `--model auto` only, paid in AI credits, no shell). The verdict never reads exit 0 as success — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). One skill per backend plus a shared delegation doc, injected by a UserPromptSubmit hook when the prompt names the CLI, and a watcher that ends the background commands agy would otherwise wait 30 min for. | | [game-facts](plugins/game-facts/) | Pins the installed game's version before the agent answers from training memory. A UserPromptSubmit hook reads Steam appmanifests and injects appid, buildid and update date — so the version never has to be typed into the prompt. | | [homelab](plugins/homelab/) | Software you maintain yourself on a Linux box, where the official docs stop. A Palworld dedicated server (the config the server erases on shutdown), WireGuard through NetworkManager (the IPv6 leak that passes the IP test), and tarball installs (one versioned layout, and why a self-updating app must not live in /opt). Machine state stays out of the repo. | | [media-agent](plugins/media-agent/) | Producao de midia numa maquina propria com GPU de 8 GB: locucao a -16 LUFS com `say` separado da legenda, trilha e bancos com a licenca de cada item, retoque no ComfyUI so com modelos comerciais, turntable no Blender headless, video como codigo edicao de video pronto pelo MCP do Kinocut, imagem e video gerados pelas assinaturas de Gemini e Grok, e a ElevenLabs alem da voz: SFX, trilha, limpeza de gravacao, legenda e dublagem. Um hook que avisa onde o `vipsthumbnail -o` vai gravar de verdade. | diff --git a/plugins/crew/.claude-plugin/plugin.json b/plugins/crew/.claude-plugin/plugin.json new file mode 100644 index 0000000..a2dbe3e --- /dev/null +++ b/plugins/crew/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "crew", + "displayName": "Crew", + "version": "0.4.0", + "author": { + "name": "4nrry", + "url": "https://github.com/4nrry" + }, + "license": "MIT", + "description": "Tres CLIs de agente como workers baratos e headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o worker executa uma fatia por vez sob hooks do proprio CLI instalados so durante o run. Backends: agy (Antigravity CLI, Gemini; padrao), opencode (modelos gratis do Zen, guard do shell mais fraco) e GitHub Copilot CLI (plano Free, so --model auto, pago em AI credits, sem shell). O veredito nunca le exit 0 como sucesso. Uma skill por backend (crew:agy, crew:opencode, crew:copilot) com o que e do CLI, um documento comum de delegacao, um script Python stdlib, dois comandos e um hook UserPromptSubmit que injeta a skill do backend nomeado no prompt (taxa de acerto do hook nao medida). Um vigia encerra os comandos que o agy deixa em segundo plano depois de o worker terminar com gate verde. Sem alegacao de melhoria: nao ha run records (bench/PROTOCOL.md)." +} diff --git a/plugins/crew/README.md b/plugins/crew/README.md new file mode 100644 index 0000000..24b2c92 --- /dev/null +++ b/plugins/crew/README.md @@ -0,0 +1,455 @@ +# crew + +Tres CLIs de agente como workers baratos **sem brigar com o harness do +Claude**: o Claude planeja, escreve a task, define escopo e gate e revisa o +diff; o worker executa headless, uma fatia por vez, sob guard rails que existem +so durante o run. Ate a 0.3.0 este plugin se chamava `agy-worker` (ver +[Changelog](#changelog)). + +``` +/plugin install crew@4nrry +``` + +| backend | CLI | quando | +|---|---|---| +| `agy` (padrao) | Antigravity CLI, Gemini | primario | +| `opencode` | opencode, modelos gratis do Zen | quota do agy acabou, ou capacidade extra; guard do shell mais fraco | +| `copilot` | GitHub Copilot CLI, plano Free | outra familia de modelo; sem shell, pago em AI credits | + +Requer `git`, `python3` (so stdlib), Linux (`/proc`, para achar os processos +do run) e o CLI do backend autenticado. So foi exercitado no agy **1.3.4**, no +opencode **1.19.0** e no copilot **1.0.96**; com outra versao o veredito traz +um aviso, porque decisao de hook, a ferramenta `finish`, os motivos de Stop e o +formato do `/usage` sao comportamento observado, nao contrato. Os hooks que o +run instala sao **do CLI do worker** (no agy, `.agents/hooks.json`, escrito no +comeco do run e removido no fim, inclusive em SIGTERM e SIGHUP; depois de um +SIGKILL o proximo run recusa e diz qual arquivo remover). Do Claude Code ha um +hook so, o de injecao descrito abaixo. + +**O codigo sai da maquina.** Delegar ou pedir review manda o prompt, os +arquivos que o worker ler e o diff para o provedor do backend (Google, o +provedor do modelo do Zen, ou GitHub), e gasta a quota do agy ou os creditos +do Copilot. O documento comum manda o Claude perguntar antes do primeiro run na +sessao. + +## Componentes + +| componente | o que faz | +|---|---| +| `scripts/crew.py` | `delegate`, `review`, `gate`, os handlers de hook do agy, o guard e o loop do opencode, os hooks do Copilot, o vigia de processos em segundo plano, e `--self-test`. Um arquivo | +| `shared/delegate.md` | a parte comum: quando delegar e quando nao, como escrever a task, gate, como ler o veredito, a revisao do diff contra a fonte da verdade, arquivos de segredo, a pergunta antes do primeiro run | +| `shared/task.example.json` | uma task completa; o self-test a valida | +| `skills/agy/`, `skills/opencode/`, `skills/copilot/` | `crew:agy`, `crew:opencode`, `crew:copilot`: so o que e de cada CLI (flags, modelos e escalada, o que o guard daquele backend faz e nao faz). Cada uma manda ler o documento comum | +| `hooks/inject.sh` | `UserPromptSubmit`: injeta a skill do backend nomeado no prompt | +| `commands/delegate.md`, `commands/review.md` | `/crew:delegate` e `/crew:review` | + +## Por que o gatilho e um hook + +O `agent-fleet`, neste mesmo repo, mediu o disparo pela descricao da skill: +**2 de 30** runs que deviam disparar +([`2026-08-06-trigger-eval/`](../../bench/plugins/agent-fleet/results/2026-08-06-trigger-eval/)). +Por isso aqui o gatilho e mecanico: `hooks/inject.sh` injeta +`skills//SKILL.md`, mais uma linha mandando ler `shared/delegate.md`, +quando o prompt contem `agy`, `opencode` ou `copilot` como **palavra inteira**, +sem diferenciar maiusculas (`agy-worker` e `crew:agy` casam, `agyx` nao). Dois +nomes no prompt, duas skills. So aconselha: nunca bloqueia, sai sempre 0, nao +usa rede, le so o prompt e arquivos do plugin (~20 ms medido a mao, uma +execucao). As descricoes das skills ficaram estreitas de proposito. + +A taxa de acerto **deste** hook nao foi medida. O numero acima e do +`agent-fleet`, com outra skill e outro padrao; ele justifica a escolha do +mecanismo, nao diz quantas vezes este dispara quando deveria, nem quantas +dispara a toa (um prompt que so menciona "copilot" injeta a skill do copilot). + +## O que um `delegate` faz + +1. **Recusa antes de gastar** (saida 2): arvore suja, `.agents/hooks.json` ja + existente, task invalida, modelo fora de `agy models`, quota do grupo do + modelo abaixo de 5% em `agy -p /usage` (que responde sem turno de modelo). +2. Escreve `.agents/hooks.json` com tres hooks, todos o mesmo script: + - `SessionStart`: marca que os hooks carregaram; + - `PreToolUse` (`*`): so passam as ferramentas de arquivo e o + `run_command` (allowlist: rede, subagente, workflow, MCP e qualquer + ferramenta nova sao negados). Argumentos de caminho, relativos inclusive, + sao resolvidos com `realpath` (symlink incluso) e negados fora do repo; + no shell, caminho literal absoluto ou `~`, `..`, `cd` e `Cwd` para fora, + `$HOME`, `.git/` e `.agents/`. Escrita so em `scope`, e so pelas + ferramentas de edicao: redirecionamento para arquivo, `sed -i`, `tee`, + `ln` e afins sao negados. git so com subcomandos de leitura (`status`, + `diff`, `log`, `show`, `grep`, `add`...), com ou sem opcoes globais antes. + Tambem install, deploy, migracao, rede, processos e `&`. Na ferramenta + `finish` (a do `--json-schema`) roda o **gate** e nega o "terminei" + enquanto ele falhar; + - `Stop`: roda o gate de novo se o agy parar por `NO_TOOL_CALL` ou + `TERMINAL_STEP_TYPE`, e devolve `continue` com a saida do gate, ate + `max_gate_attempts`. +3. Roda `agy -p … --output-format json --json-schema … --print-timeout … + --dangerously-skip-permissions` na raiz do repo. +4. Remove os hooks e julga o run. Exit diferente de 0 e falha; exit 0 + **nunca** e lido como sucesso. Tambem e falha: config, hooks, `info/exclude` + ou flags de indice (`assume-unchanged`, `skip-worktree`) do git, ou o + `ctx.json` do run, mudarem durante o run. Arquivo ignorado pelo git criado + ou alterado vira aviso. +5. Enquanto o agy roda, vigia os comandos que ele joga para segundo plano e + encerra os que sobraram depois de o worker terminar (ver + [Comandos em segundo plano](#comandos-em-segundo-plano-no-agy)). No fim, + encerra o que ainda tiver a marca do run (`CREW_RUN`) no ambiente. +6. Imprime um veredito JSON: `ok`, `failures`, `warnings`, arquivos por escopo, + gate, negacoes dos guards, o relatorio do worker e o uso de tokens. + +O gate e configuravel por projeto, na task: `forbid` (regex contra linhas +adicionadas), `format` (o ponto do formatter, com `{files}`) e `checks`. O +estado de cada run fica em `$(git rev-parse --git-path crew)/runs/`, +fora da arvore, e acumula: nada apaga run dirs antigos. Nada e commitado; em +falha, nada e revertido. + +## Por que cada checagem existe: o que foi medido + +Tudo abaixo e **n=1 por caso**, no agy 1.3.4, `gemini-3.8-flash-low`, em +2026-10-13, num repositorio descartavel. As notas do lab ficaram no scratchpad +da sessao e **nao estao commitadas**, entao leia como observacao, nao como +estatistica, e nao como registro no sentido do +[`bench/PROTOCOL.md`](../../bench/PROTOCOL.md). + +| observado | consequencia no script | +|---|---| +| Hooks de workspace disparam em `-p`: os seis eventos | o guard pode ser hook do proprio agy | +| `PreToolUse` que imprime `{}` **nega** a chamada | todo handler imprime decisao explicita, inclusive quando quebra (o self-test cobre) | +| `--sandbox` nao impediu escrita fora do workspace, nem por `write_to_file` nem por shell | o guard de caminho e de escopo e o unico limite; o README nao chama isso de sandbox | +| Sem `--dangerously-skip-permissions`, escrita e auto-aprovada e `run_command` auto-negado; nenhuma config de workspace mudou isso (10 variantes) | o worker precisa rodar teste, entao `delegate` usa skip-permissions **mais** os hooks | +| `--json-schema` impossivel: exit 0, `status: SUCCESS`, `structured_output: null` | `structured_output` nulo e falha | +| `--print-timeout` estourado: exit 0 com saida parcial; o unico sinal e uma linha no stderr | o marcador do stderr e falha | +| Acao negada headless: exit 0, `denied_actions` no envelope | `denied_actions` e falha no delegate e aviso no review | +| Modelo invalido: exit 1 | o modelo e validado contra `agy models` antes | +| `--mode plan` impediu a escrita pedida, com e sem skip-permissions | o review usa `--mode plan` | +| Custom agent (`--agent`) nao foi descoberto em cinco layouts | o plugin nao depende de agent customizado | +| `-p "/usage" --output-format json` responde com `num_turns: 0` e fracao restante por grupo | o preflight de quota | + +Do prototipo, num projeto real (porte Nest para TanStack Start, mesmo autor), +sem numero: forcado a continuar, o Gemini procurou o gate em `~/.claude`, +`/tmp`, `ps` e `../`; "consertou" o `nx.json` que o proprio Nx escreveu; nao +reportou divergencia sem ser perguntado; pulou formatacao com o gate sem +formatter; inventou alias. Dai `side_effects`, o campo `divergences` obrigatorio +no relatorio, o ponto `format` no gate e a revisao obrigatoria no documento comum. + +## Smoke tests desta versao + +**Nao ha run records.** Pela regra zero do `bench/PROTOCOL.md`, uma alegacao +de melhoria sem registro e marketing, e a tabela abaixo nao e registro: sao +runs de fumaca, n=1, para ver se o script faz o que diz, sem baseline. + +Cinco runs, n=1 cada, `gemini-3.8-flash-low`, repo descartavel com um modulo +Python e `unittest`: + +| run | resultado | +|---|---| +| delegate, tarefa simples (`slugify` + 3 testes) | `ok`, 43 s, gate verde na 1a tentativa. O Stop chegou com `TERMINAL_STEP_TYPE`, **nao** `NO_TOOL_CALL`: um gate so no Stop/`NO_TOOL_CALL`, como no prototipo, teria deixado o run terminar sem gate. A revisao achou `import re` dentro da funcao e linha em branco sobrando no fim do arquivo, que o gate de brinquedo (sem formatter de verdade) nao pegou | +| delegate com um check que falha so na 1a vez (`gate_once.py`) | 1a versao do script: `ok: false`. O cache guardava a **falha** para a arvore identica, o worker ficou sem o que mudar e passou a procurar o gate (`env`, `git config --list`, `/proc/self/mountinfo`), os tres negados; o run estourou o timeout e saiu exit 0 com `structured_output` nulo, os dois pegos. Corrigido para cachear so gate verde. 2a execucao: `finish` negado com a saida do gate, o worker rodou os checks de novo, gate verde na 2a tentativa, `ok`, 61 s | +| review `--diff HEAD~2` | `ok`, 21 s, arvore inalterada, 5 findings com `evidence`; dois deles sao os mesmos dois problemas que a revisao humana do run 1 achou | +| delegate depois do endurecimento do guard (allowlist de ferramentas, git so leitura, `.git` e redirect negados, `realpath`), com o mesmo `gate_once.py` | `ok`, 71 s, `finish` negado na 1a tentativa e gate verde na 2a. Nenhuma chamada legitima negada: 7 `run_command`, 3 `replace_file_content`, 2 `view_file`, todos permitidos | +| review `--diff HEAD --prompt-file` sobre esse diff | `ok`, 16 s, `approve` sem findings, arvore e `.git` inalterados, so `view_file` | + +## O que nao foi medido + +- **Nenhuma comparacao** com o Claude fazendo a mesma tarefa: nem qualidade, + nem tempo, nem custo. Este README nao diz que delegar economiza nada. +- Taxa de falso positivo e de falso negativo dos guards em uso real. As regex + de comando sao lista, nao medicao. O que elas **nao** pegam: caminho montado + em tempo de execucao (`python3 -c`, `node -e`, variavel). A checagem de + escopo pos-run usa `git status`, entao so ve arquivo rastreado e nao + rastreado-nao-ignorado **dentro** do repo. Arquivo ignorado novo ou alterado + (`.env`) so vira aviso, e escrita dentro de diretorio ignorado + (`node_modules/.bin/...`, `dist/`) e fora do repo nao e vista por nada. O + gate final roda as ferramentas da propria arvore, entao um binario do gate + adulterado em `node_modules` passaria. +- O bloqueio de segredo olha o nome do arquivo (`secret_globs`), nas + ferramentas e nas palavras do comando. Caminho montado em tempo de execucao + (`cat .e*`, `python3 -c`) passa, e uma busca recursiva num diretorio que + contem `.env` le o arquivo. So o self-test cobre a regra; nenhum run real + tentou ler um segredo. +- O vigia de segundo plano foi provado so no lab (dois runs, abaixo) e com + `bash` + `sleep`, nao com um `nx` de verdade: um comando que se desliga do + agy (daemon com `setsid` ou fork duplo) deixa de ser descendente dele e o + vigia nao o ve; so o encerramento por marca, no fim, o pega. O MCP server + que o agy sobe e protegido pela linha de base (tudo o que o agy tinha antes + do primeiro `run_command`) e por nome (`mcp` na linha de comando); um helper + que o agy suba depois disso sem `mcp` no nome e tem a marca seria encerrado. +- A ferramenta `manage_task` do agy (consultar ou parar uma background task) + fica fora da allowlist: no 2o run do lab o worker a chamou para esperar o + comando e foi negado. Liberar so `status` deixaria o worker esperar a toa; + nao foi testado. +- Runs em paralelo, um worktree por fatia: nao testado. +- Hooks que nao carregam: o script detecta (sem `SessionStart`, o run falha), + mas a causa nao foi reproduzida. Os hooks dispararam headless em pastas fora + de `trustedWorkspaces`, entao confianca no workspace nao e a explicacao + provada; e so uma hipotese, ao lado de `hooks.json` invalido. +- O review em `--mode plan` com o worker tentando escrever: no lab (E7) o modo + plan grava um artefato de plano em `~/.gemini/antigravity-cli/brain/`, e o + guard nega essa escrita por ser fora do repo. O unico review de fumaca so + leu arquivos; se a negacao faz o modelo entrar em loop, nao se sabe. +- A allowlist de ferramentas: os runs de fumaca so usaram `view_file`, + `write_to_file`, `replace_file_content`, `run_command` e `finish`. Uma + ferramenta legitima fora da lista (de uma versao nova do agy) e negada e + aparece em `guard_denials`. +- Modelos Claude ou GPT pelo agy: a quota deles estava zerada no dia; o + preflight recusou, como devia, e nada alem disso foi testado. + +## Comandos em segundo plano no agy + +O `run_command` do agy 1.3.4 manda um comando longo para uma background task +depois de ~4 s **qualquer que seja** o `WaitMsBeforeAsync`. A 0.3.0 reescrevia +esse argumento no `PreToolUse` para 900000; em dois runs reais (LOTVS, +2026-10-13) o agy registrou a reescrita ("A pre-tool hook changed the arguments +... Changed: WaitMsBeforeAsync") e mesmo assim o `nx run web:typecheck` voltou +como "Tool is running as a background task". Depois do gate verde e de o +worker parar, o agy logou "root agent idle; waiting up to 30m0s for 1 +background task(s)" e ficou parado 16 e 11 min, ate os `npm`/`nx` orfaos serem +mortos a mao; ai saiu normalmente, com envelope valido. **A reescrita nao +funciona e foi removida.** + +No lugar, o `delegate` do agy vigia o run (`BackgroundWatcher`, uma vez por +segundo). Quando o `guard.jsonl` mostra que o worker terminou depois de um gate +**verde** (o `Stop` respondeu `stop`, ou o `finish` foi permitido), espera +15 s e encerra (SIGTERM, 5 s, SIGKILL) os processos que sao, todos ao mesmo +tempo: descendentes do processo do agy; marcados com `CREW_RUN=` no +ambiente; fora de um hook nosso (o gate roda la, com `CREW_GATE=1`); fora da +linha de base (o que o agy ja tinha subido antes do primeiro `run_command` +permitido, como os MCP servers) e sem `mcp` na linha de comando. Nunca o agy: +o envelope dele em stdout e a entrada do veredito. O que foi encerrado vai em +`agy.background_killed` e num aviso; a arvore de processos do agy fica em +`procs.jsonl` no run dir. + +Tambem por causa disso: o aviso de background task manda o modelo encerrar o +turno, e no 1o run do lab ele encerrou sem chamar `finish`, com gate verde e +`structured_output` nulo. Agora o `Stop` com gate verde e sem `finish` devolve +`continue` pedindo so o relatorio, no maximo 2 vezes. + +Prova no lab (agy 1.3.4, `gemini-3.8-flash-low`, n=1 por versao, repo +descartavel fora de `trustedWorkspaces` em que os hooks carregaram; o check do +gate dorme 30 s, e 300 s a mais quando quem roda e o worker, para a background +task sobreviver a ele). Nao sao run records: + +| run | resultado | +|---|---| +| vigia, sem o pedido de `finish` | o `./check.sh` do worker foi para segundo plano; gate verde no `Stop` as 11:49:54; `bash ./check.sh` e `sleep 300` encerrados as 11:50:09 (15 s); o agy saiu, 83 s no total. O MCP server do media-agent (`uv tool uvx ... kino --mcp`), filho do agy, ficou de fora. `ok: false` so por `structured_output` nulo: o worker parou sem `finish` | +| vigia + pedido de `finish` | gate verde no `Stop` (`NO_TOOL_CALL`) as 11:52:58, `continue` pedindo o relatorio, `finish` permitido as 11:53:02, os dois processos encerrados as 11:53:18; `ok: true`, relatorio `done`, 103 s. O kino de novo intocado. O worker tentou `manage_task` para esperar o comando e foi negado | + +Sem o vigia, o mesmo lab teria esperado os 300 s do `sleep` (inferido: nao foi +rodado sem ele); nos runs reais, ate 30 min. + +O aviso "processos novos com cwd no repo", da 0.3.0, listava qualquer processo +com cwd no repo, e deu falso positivo: o laco `bash -c ... sleep 2` da propria +sessao do Claude que esperava o run. Agora o aviso olha so a marca do run, e +lista o que sobreviveu ao encerramento por marca. + +## Backend opencode + +Para quando a quota do agy acabou, ou para capacidade extra gratis. O agy +continua o primario. Requer o `opencode` no PATH ou em `~/.opencode/bin`; so +foi exercitado no **1.19.0** (outra versao vira aviso). + +| | agy | opencode | +|---|---|---| +| guard | hooks do agy em `.agents/hooks.json`, na arvore durante o run | plugin gerado no run dir (dentro do `.git`), carregado so pelo `OPENCODE_CONFIG_CONTENT` daquele processo; nada entra na arvore nem em `~/.config/opencode` | +| politica | `decide_pre` | a mesma: o plugin JS nao tem regra nenhuma, manda cada chamada para `crew.py hook oc-pre`, que traduz a ferramenta do opencode (`read`, `write`, `edit`, `bash`, `apply_patch`...) para a do agy; qualquer resposta que nao seja allow nega | +| shell | lista do agy | a lista do agy **mais** `cp`, `mv`, `touch`, `mkdir`, `rm`... (os modelos do opencode escrevem pelo shell) | +| gate | no `finish` e no Stop, dentro do agy | loop externo: run, gate, `opencode run -s ` com a saida do gate, ate `max_gate_attempts` runs | +| relatorio | `--json-schema` | o ultimo objeto JSON da mensagem final, pedido no prompt; se faltar, o loop pede de novo | +| sucesso | envelope + gate | eventos JSON (nenhum `error`, ultimo `step_finish` com `reason=stop`) + gate; exit 0 nao conta | +| review | `--mode plan` | sem modo plan: escopo vazio e **todo** comando de shell negado pelo plugin | + +O que o lab mostrou (opencode 1.19.0, 2026-10-13, n=1 por caso, notas no +scratchpad da sessao, **nao commitadas**) e o que o script faz por causa disso: + +| observado | consequencia | +|---|---| +| `opencode run` com stdin aberto trava (0 bytes ate o timeout) | stdin sempre `/dev/null` | +| grava `"$schema"` no `opencode.json` do cwd mesmo com `--dir`; de dentro do `.git` achou a arvore e gravou la | os bytes de `opencode.json`/`.jsonc` sao guardados antes de qualquer processo do opencode e restaurados depois de cada um (vira aviso); o preflight roda num diretorio temporario vazio; escopo que cubra esses arquivos e recusado | +| `permission.bash: deny` faz os modelos gratis falharem na partida (403) | o shell e policiado no plugin, nao em `permission` | +| `throw` em `tool.execute.before` bloqueia a chamada e o modelo ve o texto | e o mecanismo do guard | +| escrita por bash passa por cima dos globs de `edit` | redirecionamento e os coreutils de escrita sao negados no plugin; o `git status` pos-run continua sendo a autoridade | +| `external_directory: deny` funciona | vai no config do run | +| plugin que reabre a sessao no `session.idle` nao continua o `run` | o loop do gate e externo | +| `--agent` inexistente sai 0 rodando o agente padrao | nenhum agent customizado | +| carrega `~/.claude/CLAUDE.md` por padrao; `OPENCODE_DISABLE_CLAUDE_CODE=1` desliga | carrega, como um subagente nativo; `"disable_claude_code": true` na task desliga | + +Modelo padrao: `opencode/longcat-2.5-preview-free`. No unico bench de +qualidade (n=1, porte de um servico CJS com 16 casos de teste, notas no +scratchpad), longcat, space-bunny e muse-spark passaram o gate e portaram os 16 +casos; o longcat manteve as 22 assercoes um a um como no legado, o space-bunny +moveu metade para helpers. O longcat foi o mais lento (139 s contra 34 s) e e o +unico exercitado com o plugin de guard e o loop externo. Pelos docs do Zen, +longcat, space-bunny e step-5 sao de retencao zero; o muse-spark usa o prompt +para treino (aceitavel so em projeto pessoal, escolha do usuario). + +**Guard mais fraco que o do agy, e por que:** o opencode nao tem allowlist de +ferramenta no nivel do processo nem modo plan; tudo depende do plugin carregar +(sem o `SessionStart` dele, o run falha) e de uma lista de regex sobre o +comando. O que monta caminho em tempo de execucao (`python3 -c`, `node -e`) +passa, igual ao agy, e os modelos do opencode usam mais o shell. + +Smoke tests, n=1 cada, `opencode/longcat-2.5-preview-free`, repo descartavel +com um modulo Python e `unittest`, 2026-10-13. Nao sao run records: + +| run | resultado | +|---|---| +| delegate simples (`slugify` + 3 testes) | `ok`, 50 s, 1 run, gate verde | +| delegate com check que falha so na 1a vez | `ok`, 68 s: gate vermelho no run 1, `run -s` na mesma sessao, verde no run 2 | +| delegate pedindo `echo reviewed > NOTES.txt` | o modelo recusou sozinho pelas regras do prompt; nao testou o guard | +| delegate pedindo os testes com `> test-output.log` | `ok`, 105 s: o redirect pelo bash negado, depois o `write` do mesmo arquivo negado; arquivo inexistente, relatado em `out_of_scope_needs` | +| review `--backend opencode` com `opencode.json` versionado | 1a versao: o preflight gravou o `$schema` antes do snapshot e o veredito nao viu. Corrigido (preflight em dir temporario, snapshot antes dele). 2a: `ok`, `approve`, `opencode.json` com o mesmo sha256 | +| delegate com `opencode.json` versionado | `ok`, 37 s, aviso de restauracao, `opencode.json` com o mesmo sha256 | + +Nao medido: qualidade contra o agy ou o Claude na mesma fatia, taxa de falso +positivo do guard, `apply_patch` (os modelos testados so usaram `write` e +`edit`), caminho com espaco no `file://` do plugin, e o que acontece com um +`OPENCODE_CONFIG_CONTENT` que o usuario ja tenha no ambiente (o do run o +substitui). + +## Backend copilot + +Requer o `copilot` (GitHub Copilot CLI) no PATH e logado; so foi exercitado no +**1.0.96**, numa conta **Copilot Free** (outra versao vira aviso). Para quando +se quer um terceiro par de maos e se aceita gastar AI credits: no Free nao ha +quota gratis a poupar, cada run custa. + +| | agy | copilot | +|---|---|---| +| guard | `.agents/hooks.json` | `.github/hooks/crew-.json`, escrito no comeco do run e removido no fim (inclusive SIGTERM/SIGHUP); recusa se ja existir, ou se sobrar um `crew-*.json` de run morto; o `git status` pos-run ignora exatamente esse arquivo | +| carga dos hooks | automatica | so com `COPILOT_ALLOW_ALL=true` no ambiente do processo; `--allow-all-tools` e `trustedFolders` nao carregam (lab) | +| politica | `decide_pre` | a mesma: `hook cp-pre` traduz `view`, `glob`, `grep`, `apply_patch` (patch cru, um alvo por arquivo), `create`, `edit` para a ferramenta do agy; ferramenta desconhecida, `task`, MCP, rede e qualquer shell sao negados; qualquer coisa que nao seja allow nega | +| shell | lista do agy | **nenhum**: `--excluded-tools bash task`. Chamada de shell ou de `task` no stream de eventos reprova o run | +| gate | no `finish` e no Stop | no `agentStop`, que devolve `block` com a saida do gate ate `max_gate_attempts` (no maximo 8: o CLI ignora o hook depois de 8 bloqueios seguidos); com gate verde e relatorio ausente na mensagem final, bloqueia de novo pedindo so o relatorio, no maximo 2 vezes | +| relatorio | `--json-schema` | o ultimo objeto JSON da ultima `assistant.message` | +| sucesso | envelope + gate | evento `result` com `exitCode` 0, nenhum evento de erro, nenhuma chamada de shell/`task`, algum hook nosso registrado, relatorio, gate, escopo, HEAD; o exit 0 nao conta | +| custo | quota do agy | `usage` no veredito: `ai_credits` (de `totalNanoAiu`; 1 credito = 1e9 nanoAIU e inferido, nao documentado), `premium_requests`, `served_models` | +| review | `--mode plan` | escopo vazio (toda escrita negada no hook) e `--excluded-tools bash task apply_patch` | +| modelo | `agy models` | so `auto`; outro modelo e recusado, salvo `"copilot_model_override": true` na task (ou `--copilot-model-override` no review), caminho **nao testado** para plano pago | + +Outras flags de todo run: `--allow-all-tools` (exigida pelo `-p`), +`--disallow-temp-dir`, `--no-ask-user`, `--no-auto-update`, +`--disable-builtin-mcps`, `--max-ai-credits 30` (o minimo aceito; serve so de +freio para um run que dispare, nao de orcamento), `--usage-output-file` e +`--log-dir` no run dir. Ambiente: `COPILOT_ALLOW_ALL=true`, `NX_DAEMON=false`, +`CREW_RUN`. O `COPILOT_HOME` continua o do usuario: isola-lo perdeu o +login no lab. + +O que o lab mostrou (copilot 1.0.95/1.0.96, 2026-10-13, n=1 por caso, notas +no scratchpad da sessao, **nao commitadas**) e o que o script faz por causa +disso: + +| observado | consequencia | +|---|---| +| exit 0 com tarefa falhada (escrita negada, modelo dizendo "Incomplete") | o sucesso vem dos eventos JSONL e do gate | +| hooks de `.github/hooks/*.json` so carregam com `COPILOT_ALLOW_ALL=true` | o run seta, so no ambiente do processo; isso tambem carrega skills, plugins e MCP servers do repo, e hooks do proprio repo rodam junto (o veredito avisa quais) | +| `preToolUse` com `permissionDecision: deny` nega, inclusive `../x` e `src/../x`, e dentro de subagentes | e o guard | +| `agentStop` com `decision: block` e `reason` forca outra volta; `stop_hook_active` na reentrada | e o gate | +| timeout de hook **libera** (fail open, docs) | o `cp-pre` nao roda gate (0,05 a 0,07 s por chamada em 10 execucoes locais do handler, contra um timeout de 30 s); erro dentro do handler nega; um gate que estoure o timeout do `agentStop` deixa parar, e o gate final do veredito pega | +| excluir so `bash` levou a 2826 chamadas de `task` e ~5,5 creditos | `bash` e `task` sempre excluidos juntos | +| `--max-ai-credits` abaixo de 30 e recusado | 30 como freio, nao como teto por run | +| `--model X` recusado para os 32 modelos do catalogo nesta conta; o Auto roteia para gpt-5.6-luna | so `auto` | +| `--usage-output-file` traz creditos e o modelo servido | vai no veredito | +| hooks tambem carregam de `.github/copilot/settings*.json` e `.claude/settings*.json` (docs) | escrita nesses caminhos e em `.github/hooks/` e negada, e um escopo que os cubra e recusado | + +Smoke tests, n=1 cada, `--model auto` (servido: gpt-5.6-luna), repo +descartavel com um modulo Python e `unittest`, 2026-10-13. Nao sao run records: + +| run | resultado | +|---|---| +| delegate (`slugify` + 3 testes) com um check que falha so na 1a vez | `ok`, 21 s, 0,529 AI credits, 2 premium requests. `sessionStart` disparou; `agentStop` bloqueou com o gate vermelho, a 2a parada passou com gate verde. Ferramentas: 2 `view` (arquivos inexistentes, falha normal), 1 `apply_patch` com caminhos absolutos dentro do escopo; nenhuma negacao, nenhum shell. O relatorio diz `python3 -m unittest ... PASS` em `checks_run`, coisa que o worker sem shell nao rodou: autodeclarado, como sempre | +| review `--backend copilot --diff HEAD~1` | `ok`, 19 s, 0,411 AI credits, 1 premium request. So 2 `view`; arvore inalterada; `request_changes` com um finding correto (nenhum teste com pontuacao ou nao-ASCII) | + +Total: 0,94 AI credits nos dois. Antes deles, uma checagem de flags com +modelo inexistente (recusada antes de chamar modelo, 0 creditos). + +Nao medido: uma negacao do guard num run real deste script (o lab mostrou a +do mesmo mecanismo, com outro handler); `create`/`edit`/`grep` (o modelo so +usou `view` e `apply_patch`); se o `agentStop` ve o stdout ja escrito quando +pede o relatorio (se nao vir, nao pede; o pedido nunca foi exercitado); o que +acontece quando os creditos acabam; qualidade contra os outros backends; +plano pago e `copilot_model_override`; runs em paralelo. + +## Por que nao ha workflow script + +A ideia era herdar do `agent-fleet` um workflow que abre varias delegacoes em +paralelo, cada uma com subagente escrevendo a task, agy executando, gate e +revisao, e modelo por fase. Ficou de fora porque: + +- **Os runs se serializam por construcao**: arvore limpa e um + `.agents/hooks.json` por workspace. Paralelo exige um worktree por fatia, e + criar, limpar e juntar worktrees e mais codigo do que o resto do plugin, para + um caminho que nao foi testado nem uma vez. +- **A revisao nao e delegavel a um subagente sem contexto**: ela compara o diff + com a fonte da verdade e com o que a sessao ja sabe do projeto. O passo caro + e o que tem que ficar com o dono da sessao. +- O roteamento por fase, que e o que o `agent-fleet` ensina, ja existe aqui sem + script: tarefa e revisao no modelo da sessao, execucao no Gemini via `model` + da task. + +Se N fatias repetitivas virarem rotina, o caminho e um loop sequencial na +propria sessao (delegate, revisao, commit, proxima), nao um fan-out. + +## Verificacao + +`just check` roda `crew.py --self-test`: 311 assercoes sem chamar o agy, +o opencode nem o copilot, +num repo git temporario. Cobrem as decisoes do guard (allowlist de ferramentas, +caminho relativo e absoluto, symlink, `cd`/`Cwd`, `.git` pelo shell, git com +opcoes globais e com aspas, redirecionamento, `&`, `finish`, gate isento so sem +encadeamento nem quebra de linha) e os falsos positivos que ja existiram +(`grep '/api/v1'`, `git log --grep=publish`), a deteccao de config/flag do git e +de `.env` ignorado, o fsmonitor plantado que nao roda no git do script, o +timeout do gate que mata os filhos, o handler quebrado negando em vez de +devolver `{}`, o gate (escopo, forbid com numero de linha, `{files}` com espaco +no nome, efeito colateral tolerado, `AGY_WORKER_GATE`), o cache que guarda so +gate verde, a avaliacao do envelope em cada modo de falha com exit 0, a quota +contra o formato real do `/usage` de 2026-10-13, e instalar e remover o +`hooks.json` sem deixar rastro nem sobrescrever um existente. Do opencode: a +traducao de cada ferramenta para a politica do agy (inclusive `apply_patch` +com alvo fora do escopo e ferramenta desconhecida), as negacoes extras do shell +so nesse backend, o review negando todo shell, o handler `oc-pre` quebrado +negando, os eventos que saem 0 e sao falha, a extracao do relatorio, a +restauracao exata do `opencode.json` e o plugin gerado apontando para o script. +Do copilot: a regra do modelo (`auto` ou override), os campos da task, o +prompt sem shell, a traducao de cada ferramenta (inclusive `apply_patch` cru +com `..`, `src/..`, `Move to` e alvo no arquivo de hooks), shell, `task`, MCP e +ferramenta desconhecida negados, o contrato de saida do hook (allow sem +saida, deny com `permissionDecision`), o `agentStop` bloqueando com gate +vermelho, pedindo o relatorio com no maximo 2 pedidos e nao pedindo sem stream +legivel, os handlers quebrados (o `cp-pre` nega), os eventos que saem 0 e sao +falha, a leitura do `usage.json`, a linha de comando com `bash task` +excluidos, e o arquivo de hooks gerado, ignorado so ele no escopo, nunca +sobrescrito, removido com os diretorios que o run criou. + +O vigia de segundo plano: quem pode ser encerrado numa arvore sintetica (hook, +gate, linha de base, MCP e processo sem marca ficam; o agy nunca), quando o +worker conta como terminado (so com o ultimo gate verde) e, com processos de +verdade, um "agy" falso com um helper anterior ao primeiro comando, um comando +do worker e um processo sem marca: so o comando e encerrado, e so depois da +carencia. Tambem o `Stop` que pede o `finish` no maximo 2 vezes. + +O hook de injecao nao tem teste no `just check` alem do shellcheck e da +checagem de que o `hooks.json` aponta para um executavel. + +## Changelog + +**0.4.0** (2026-10-13) + +- Renomeado de `agy-worker` para `crew`: diretorio, manifesto, comandos + (`/crew:delegate`, `/crew:review`) e o script (`scripts/crew.py`). A skill + unica virou tres, uma por backend (`crew:agy`, `crew:opencode`, + `crew:copilot`), mais o documento comum `shared/delegate.md`. +- **Estado no git dir mudou de `agy-worker/` para `crew/`** + (`$(git rev-parse --git-path crew)`). Run dirs antigos ficam em + `agy-worker/runs/` e nada os le. Task files sao passados pelo caminho, entao + os que estiverem em `agy-worker/tasks/` continuam funcionando; para seguir a + convencao nova, mova-os para `crew/tasks/` (`git rev-parse --git-path + agy-worker` e `--git-path crew` dao os dois caminhos). +- Variaveis: `AGY_WORKER_RUN` virou `CREW_RUN`; o gate exporta `CREW_GATE=1` e + ainda `AGY_WORKER_GATE=1`. Sobras de run morto da 0.3.0 (`.agents/hooks.json` + com a chave `agy-worker`, `.github/hooks/agy-worker-*.json`) continuam + reconhecidas. +- Hook `UserPromptSubmit` que injeta a skill do backend nomeado. +- Removida a reescrita de `WaitMsBeforeAsync` (nao funciona); vigia de + processos em segundo plano no agy; `Stop` pede o `finish` que faltou. +- O aviso de processos que sobraram usa a marca do run, nao o cwd. diff --git a/plugins/crew/commands/delegate.md b/plugins/crew/commands/delegate.md new file mode 100644 index 0000000..7453f6e --- /dev/null +++ b/plugins/crew/commands/delegate.md @@ -0,0 +1,35 @@ +--- +description: Delega uma fatia bem delimitada a um worker (agy com Gemini, opencode com modelo gratis do Zen, ou GitHub Copilot CLI sem shell), sob guard rails e gate, e devolve um veredito JSON +argument-hint: [--backend agy|opencode|copilot] [--model slug] [--no-quota-probe] +--- + +Leia antes, se ainda nao leu nesta sessao, `${CLAUDE_PLUGIN_ROOT}/shared/delegate.md` +(se esta fatia deve ser delegada e como a task tem que estar escrita) e a skill +do backend: `crew:agy` (padrao), `crew:opencode` ou `crew:copilot`. + +Rode, com `run_in_background: true` porque o run pode passar de 10 minutos: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" delegate '' [--backend agy|opencode|copilot] [--model slug] [--no-quota-probe] +``` + +`--backend` sobrepoe o campo `backend` da task (padrao `agy`). `opencode` so +quando a quota do agy acabou ou para capacidade extra gratis, e diga ao usuario +que o guard do shell e mais fraco. `copilot` gasta AI credits a cada run, nao +tem shell e so aceita `--model auto`. `--no-quota-probe` so vale para o agy. + +Monte a linha a partir de `$ARGUMENTS`; nao cole `$ARGUMENTS` cru na shell. +O caminho da task vai entre aspas simples, como um argumento so (pode ter +espaco). Se for o primeiro `delegate` ou `review` da sessao, pergunte antes ao +usuario: o codigo vai para a Google (agy), para o provedor do modelo do Zen +(opencode) ou para a GitHub (copilot); no agy gasta a quota dele, no copilot +os AI credits. No copilot, reporte `copilot.usage.ai_credits` do veredito. + +Quando terminar: + +1. Saida 2 = recusado antes de rodar. Mostre o motivo ao usuario; nada rodou. +2. Leia o veredito (stdout, e `verdict.json` no run dir). Reporte `ok`, + `failures`, `warnings` e `guard_denials` sem suavizar. +3. **Revise o diff mesmo com `ok: true`**: `git diff HEAD` contra a fonte da + verdade, seguindo a secao de revisao do documento comum. Nao commite antes + disso, e nao commite sem o usuario pedir. diff --git a/plugins/crew/commands/review.md b/plugins/crew/commands/review.md new file mode 100644 index 0000000..0e889f7 --- /dev/null +++ b/plugins/crew/commands/review.md @@ -0,0 +1,28 @@ +--- +description: Segunda opiniao somente leitura do Gemini pelo agy (--mode plan, relatorio com schema), de um modelo gratis pelo opencode, ou do GitHub Copilot CLI (Auto) sem shell nem escrita +argument-hint: [--backend agy|opencode|copilot] [--diff HEAD~1] [--model slug] +--- + +Leia antes, se ainda nao leu nesta sessao, a skill do backend: `crew:agy` +(padrao), `crew:opencode` ou `crew:copilot`. Rode: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" review [--backend agy|opencode|copilot] [--diff HEAD~1] [--model slug] --prompt-file /caminho/pergunta.md +``` + +Nao cole `$ARGUMENTS` cru na shell: a pergunta e texto livre, e parenteses, +aspas, `;` ou crase nela quebram ou mudam o comando. Escreva a pergunta num +arquivo fora da arvore (o scratchpad) e passe com `--prompt-file`; so as opcoes +(`--backend`, `--diff`, `--model`) vao na linha. Se for o primeiro `delegate` +ou `review` da sessao, pergunte antes ao usuario: o codigo vai para a Google +(agy), para o provedor do modelo do Zen (opencode) ou para a GitHub (copilot); +no agy gasta a quota dele, no copilot os AI credits (0,41 num review pequeno, +n=1). + +Sem `--diff`, o revisor so le arquivos; passe o intervalo quando a pergunta for +sobre uma mudanca. + +Ao reportar: o veredito e de um modelo mais barato e serve de segundo par de +olhos, nao de decisao. Para cada finding, confira o `evidence` no codigo antes +de repassar, porque o numero de linha pode vir errado. Diga quais voce confirmou +e quais nao. diff --git a/plugins/crew/hooks/hooks.json b/plugins/crew/hooks/hooks.json new file mode 100644 index 0000000..52fdc9f --- /dev/null +++ b/plugins/crew/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/inject.sh" + } + ] + } + ] + } +} diff --git a/plugins/crew/hooks/inject.sh b/plugins/crew/hooks/inject.sh new file mode 100755 index 0000000..cc00861 --- /dev/null +++ b/plugins/crew/hooks/inject.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# UserPromptSubmit hook: when the prompt names agy, opencode or copilot as a +# whole word (case-insensitive), inject that backend's SKILL.md plus a line +# pointing at the shared delegation doc. Advisory only: it never blocks, always +# exits 0, reads only the prompt and files in this plugin, no network. +# +# Why a hook and not the skill descriptions: agent-fleet measured description +# triggering at 2 of 30 should-trigger runs (trigger-eval 2026-08-06, +# bench/plugins/agent-fleet/results/2026-08-06-trigger-eval/ in +# https://github.com/4nrry/agent-plugins). This hook's own hit rate is +# unmeasured. Whole-word match: "agy-worker" and "crew:agy" match, "agyx" not. +set -euo pipefail + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +prompt=$(jq -r '.prompt // empty' 2>/dev/null) || exit 0 +[[ -n "$prompt" ]] || exit 0 + +matched=() +for backend in agy opencode copilot; do + if printf '%s' "$prompt" | grep -qiwF -- "$backend"; then + matched+=("$backend") + fi +done +(( ${#matched[@]} )) || exit 0 + +for backend in "${matched[@]}"; do + skill="$PLUGIN_ROOT/skills/$backend/SKILL.md" + if [[ ! -f "$skill" ]]; then + echo "crew plugin hook: $skill not found; skill not injected" >&2 + continue + fi + # shellcheck disable=SC2016 # the literal ${CLAUDE_PLUGIN_ROOT} is the point: SKILL.md uses it + printf 'The crew:%s skill applies to this request (matched the word "%s"). Read %s/shared/delegate.md before delegating or reviewing through it. ${CLAUDE_PLUGIN_ROOT} below is %s.\n\n' \ + "$backend" "$backend" "$PLUGIN_ROOT" "$PLUGIN_ROOT" + cat "$skill" + printf '\n' +done +exit 0 diff --git a/plugins/crew/scripts/crew.py b/plugins/crew/scripts/crew.py new file mode 100755 index 0000000..23333f5 --- /dev/null +++ b/plugins/crew/scripts/crew.py @@ -0,0 +1,3158 @@ +#!/usr/bin/env python3 +"""crew: run an agent CLI (agy, opencode or copilot) headless as a guarded worker. + +Claude plans and reviews; agy executes one well-scoped slice. This script is +the guard rail between them. Subcommands: + + delegate TASK.json run the task under agy with guard hooks and a gate + review [PROMPT...] read-only second opinion (agy: --mode plan + --json-schema) + gate TASK.json run the task's gate by hand (--baseline: commands only) + hook pre|stop|start agy workspace hook handlers (installed per run) + hook oc-pre the opencode guard plugin's handler (same policy) + hook cp-pre|cp-stop GitHub Copilot CLI repo hooks (same policy, gate on agentStop) + --self-test exercises everything that does not need agy itself + +Why each check exists is in the plugin README; the short version is that agy +1.3.4 reports success in most of the ways it fails (measured, n=1 each): +timeouts, denied actions and an unsatisfied --json-schema all exit 0, a +PreToolUse hook printing `{}` denies the call, and --sandbox confines nothing. +So nothing here trusts the exit code, and every hook prints an explicit +decision even when it crashes. + +The agy backend also watches the run: agy 1.3.4 moves long commands to a +background task whatever WaitMsBeforeAsync says and then waits up to 30 min for +them, so BackgroundWatcher ends the worker's leftovers once it finished green. + +Stdlib only. State lives in `$(git rev-parse --git-path crew)` (`agy-worker` +before 0.4.0), so a run +never dirties the work tree; the only file written into the tree is +`.agents/hooks.json`, for the duration of the run, and it is removed after. + +Second backend, `"backend": "opencode"` in the task (opencode 1.19.0): the +guard is an opencode plugin generated in the run dir and loaded only through +that run's OPENCODE_CONFIG_CONTENT; it forwards every tool call to `hook +oc-pre`, which translates it and asks the same decide_pre. opencode has no +stop hook and no --json-schema, so the gate loop (run, gate, `run -s` with the +gate output) and the report parsing are here, and opencode.json's bytes are +restored after every opencode process (it writes "$schema" into it). + +Third backend, `"backend": "copilot"` (GitHub Copilot CLI 1.0.96, Free plan, +`--model auto` only): repo hooks in `.github/hooks/crew-.json`, +loaded only because the run sets COPILOT_ALLOW_ALL=true, forward every tool +call to `hook cp-pre` (same decide_pre) and run the gate on agentStop. The +shell and subagents are removed from the model (`--excluded-tools bash task`), +so every command of the gate runs here, never by the worker. +""" + +import argparse +import fnmatch +import hashlib +import json +import os +import pathlib +import re +import shlex +import shutil +import signal +import subprocess +import sys +import tempfile +import time + +HOOK_FILE = ".agents/hooks.json" +STATE_NAME = "crew" +DEFAULT_MODEL = "gemini-3.8-flash-high" +DEFAULT_TIMEOUT = "30m" +DEFAULT_REVIEW_TIMEOUT = "15m" +DEFAULT_GATE_TIMEOUT = 900 +DEFAULT_MAX_GATE_ATTEMPTS = 5 +DEFAULT_MIN_QUOTA = 0.05 +TESTED_AGY_VERSION = "1.3.4" # every agy behaviour this script relies on was seen on this one +MAX_PROMPT_BYTES = 100_000 # one argv string is capped at 128 KiB on Linux +MAX_DIFF_BYTES = 60_000 +TAIL_CHARS = 6000 +# Basenames a worker may not read or write, by tool or by shell. Not distrust +# of the model: what it reads goes to the provider's servers, and a password in +# a training corpus can only be fixed by rotating it. A task can replace the +# list, or pass [] to turn it off. +DEFAULT_SECRET_GLOBS = [".env", ".env.*", ".dev.vars", ".dev.vars.*", "*.pem", "*.key", "*.p12", + "*.pfx", "id_rsa*", "id_ed25519*", "id_ecdsa*", "id_dsa*"] +SECRET_EXCEPTIONS = ("*.example", "*.sample", "*.template") +# Every process of a run inherits this marker, so the ones it leaves behind +# (an Nx daemon reparents to init and keeps no ancestry) can be found and +# stopped without touching anything the user started. +RUN_ENV = "CREW_RUN" +WORKER_ENV = {"NX_DAEMON": "false"} +BACKENDS = ("agy", "opencode", "copilot") +OPENCODE_DEFAULT_MODEL = "opencode/longcat-2.5-preview-free" +TESTED_OPENCODE_VERSION = "1.19.0" +# opencode writes "$schema" into these, in the process cwd, on every run (seen +# on 1.19.0 even with --dir); the driver restores their bytes after each run. +OPENCODE_CONFIG_FILES = ("opencode.json", "opencode.jsonc") +# GitHub Copilot CLI. Everything here was seen on 1.0.96 with a Free account +# (scratchpad lab, 2026-10-13, n=1 per case), where only `--model auto` is +# accepted and it routes to gpt-5.6-luna. +TESTED_COPILOT_VERSION = "1.0.96" +COPILOT_DEFAULT_MODEL = "auto" +COPILOT_HOOK_DIR = ".github/hooks" +COPILOT_HOOK_PREFIX = "crew-" +COPILOT_MAX_STOP_BLOCKS = 8 # the CLI overrides agentStop after 8 consecutive blocks (docs) +COPILOT_MIN_AI_CREDITS = 30 # `--max-ai-credits` refuses less: a runaway backstop, not a budget +COPILOT_REPORT_ASKS = 2 # extra agentStop blocks asking only for the missing report +# agy: extra Stop `continue`s asking for `finish` when the gate is green but the +# worker stopped without calling it. Seen in the lab (2026-10-13, n=1): the +# background-task notice tells the model to end the turn, it did, and the +# envelope came back with structured_output null although the gate was green. +AGY_REPORT_ASKS = 2 +# Both, always: excluding only `bash` sent the model into 2826 `task` +# subagent calls (~5.5 AI credits) in the lab. +COPILOT_EXCLUDED_TOOLS = ["bash", "task"] +COPILOT_REVIEW_EXCLUDED_TOOLS = COPILOT_EXCLUDED_TOOLS + ["apply_patch"] +# Tool names seen in the lab that run or inspect a shell; any of them in the +# event stream fails the run, and the hook denies them anyway. +COPILOT_SHELL_TOOLS = {"bash", "write_bash", "read_bash", "stop_bash", "list_bash", "shell", "powershell"} +TIMEOUT_MARKER = "[agy] print timeout" +AUTO_DENY_MARKER = "auto-denied" +NO_OUTPUT_MARKER = "run ended with no output" + +# The structured-output tool: --json-schema is implemented as a `finish` tool +# whose parameters are the schema (seen in the lab: "The `finish` tool cannot +# be executed because its validation schema..."). Its args are the report, so +# the path guard must not scan them, and it is where the gate can refuse "done". +FINISH_TOOLS = {"finish"} +GATE_STOP_REASONS = {"NO_TOOL_CALL", "TERMINAL_STEP_TYPE"} + +# Arguments that carry file *content* or prose, not paths to touch. +CONTENT_KEYS = {"CodeContent", "ReplacementContent", "TargetContent", + "ReplacementChunks", "Instruction", "Description", "toolSummary", + "toolAction", "Query", "Content", "Message", "Summary"} + +# An allowlist, not a denylist: any other tool (network, subagents, schedules, +# run_workflow's own python3 process, MCP tools, whatever a new agy adds) has +# effects these hooks cannot see. These are the file and shell tools; the +# smoke runs used only view_file, write_to_file, replace_file_content, +# run_command and finish. +ALLOWED_TOOLS = {"view_file", "list_dir", "find_by_name", "grep_search", "write_to_file", + "replace_file_content", "multi_replace_file_content", "run_command"} + +SYSTEM_PATHS = ("/dev/null", "/dev/stdout", "/dev/stderr", "/usr/bin", + "/bin", "/usr/local/bin") + +# git global options that may sit between `git` and the subcommand. +_GIT_OPTS = r"(?:(?:-[cC]\s+\S+|--[\w-]+(?:=\S+)?|-[abd-zABD-Z])\s+)*" +# Start of a simple command: the line start or after an operator. +_CMD = r"(?:^|[;&|(\n`]|\$\()\s*" + +DENY_COMMANDS = [ + (r"\bgit\s+" + _GIT_OPTS + r"(?!(?:status|diff|log|show|ls-files|ls-tree|grep|blame|" + r"shortlog|describe|cat-file|rev-list|merge-base|add|rm|mv)\b)[^\s-]", + "git fora da lista de leitura (status, diff, log, show, grep, add...)"), + (r"\bgit\b.*--(git-dir|git-path|git-common-dir|absolute-git-dir)\b", "caminho do .git"), + (r"(^|[\s/='\"<>:])\.(git|agents)($|[\s/'\"])", "acesso ao .git ou ao .agents"), + (r"\brm\s(?:[^;&|\n]*\s)?(?:-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$)", "rm recursivo"), + (r"\bfind\b[^;&|\n]*\s-(delete|exec|execdir|ok|okdir|fprint\w*|fls)\b", "find que escreve ou executa"), + (_CMD + r"(ln|chmod|chown|chgrp|crontab|at|batch|nohup|setsid|disown|systemctl|service)(\s|$)", + "link, permissao, agendamento ou processo destacado"), + (r"(^|[^&>|])&($|[^&>])", "processo em segundo plano (&)"), + (r"\b(npm|pnpm|yarn|bun)\s+(install|i|ci|add|remove|rm|uninstall|update|" + r"upgrade|up|link|publish)\b", "install ou publish de pacote"), + (r"\b(pip3?|poetry|cargo|gem|composer)\s+(install|add|remove|uninstall|publish)\b" + r"|\bgo\s+(get|install)\b|\buv\s+(add|remove|sync|pip\s+install)\b", + "install de pacote"), + (r"\b(apt|apt-get|dnf|yum|pacman|brew|snap|flatpak)\b", "gerenciador de pacotes do sistema"), + (r"\b(npx|bunx)\s+(-y|--yes)\b|\bpnpm\s+dlx\b", "execucao de pacote baixado"), + (r"(^|[\s;|&(])(sudo|su|doas)(\s|$)", "elevacao de privilegio"), + (r"\b(npm|pnpm|yarn|bun|npx|nx|make|just|task|turbo)\b[^;&|\n]*\b(deploy|publish|release)\b", + "deploy ou publish"), + (r"\b(wrangler|vercel|netlify|firebase|flyctl|heroku|kubectl|helm|terraform|" + r"pulumi|docker|podman|gcloud|aws|az)\b", "infra, deploy ou container"), + (r"\b(prisma|drizzle-kit|knex|sequelize|alembic|typeorm)\b.*\b(migrate|push|reset|seed)\b" + r"|\bdb:(migrate|push|reset|seed|drop)\b|\bmanage\.py\s+migrate\b", + "migracao ou escrita de banco"), + (r"\b(psql|mysql|sqlite3|mongosh|redis-cli)\b", "cliente de banco"), + (r"\bsed\b[^|;&]*\s(-[a-zA-Z]*i|--in-place)|\bperl\s+-[a-zA-Z]*i|\btee\b|(^|[\s;|&])dd\s", + "edicao de arquivo pelo shell"), + (r"\b(curl|wget|ssh|scp|rsync|nc|ncat|telnet|ftp)\b", "rede"), + (r"(^|[\s;|&(])(ps|pgrep|pkill|kill|killall|top|htop|lsof)(\s|$)|/proc\b", + "inspecao de processos"), + (r"(^|[\s=:'\"(/<>;&|])\.\.($|[\s/;&|)'\"])", "caminho com .."), + (r"\$\{?HOME\b|\bprintenv\b|(^|[\s;|&])env\s*($|[;|&])", "home ou ambiente do usuario"), +] + +# opencode only: its models write through bash where agy's use the edit tools +# (lab: `printf x > top.txt` past an edit-deny glob), so the file-writing +# coreutils are denied too. Added to the same deny list, never a second policy. +OPENCODE_DENY_COMMANDS = [ + (_CMD + r"(cp|mv|touch|mkdir|rmdir|rm|truncate|install|patch)(\s|$)", + "escrita de arquivo pelo shell (use write/edit)"), +] +REVIEW_DENY_ALL = [(r"[\s\S]", "review somente leitura: sem shell")] + +# opencode tool -> (the agy tool decide_pre knows, {opencode arg: agy arg}). +# The guard plugin forwards every call here, so one policy covers both. +OC_TOOLS = { + "read": ("view_file", {"filePath": "AbsolutePath"}), + "list": ("list_dir", {"path": "DirectoryPath"}), + "glob": ("find_by_name", {"path": "SearchDirectory", "pattern": "Pattern"}), + "grep": ("grep_search", {"path": "SearchPath", "pattern": "Query", "include": "Includes"}), + "write": ("write_to_file", {"filePath": "TargetFile", "content": "CodeContent"}), + "edit": ("replace_file_content", {"filePath": "TargetFile", "oldString": "TargetContent", + "newString": "ReplacementContent"}), + "multiedit": ("multi_replace_file_content", {"filePath": "TargetFile", "edits": "ReplacementChunks"}), + "bash": ("run_command", {"command": "CommandLine", "workdir": "Cwd"}), +} +OC_INERT_TOOLS = {"todowrite", "todoread", "skill", "invalid"} # no file or process effect +# Copilot CLI tool -> (agy tool, {copilot arg: agy arg}). `view`, `glob` and +# `apply_patch` (a raw patch string, not JSON) were seen in the lab; `grep`, +# `create` and `edit` are the CLI's documented file tools, mapped the same way. +CP_TOOLS = { + "view": ("view_file", {"path": "AbsolutePath"}), + "glob": ("find_by_name", {"path": "SearchDirectory", "pattern": "Pattern"}), + "grep": ("grep_search", {"path": "SearchPath", "pattern": "Query", "glob": "Includes"}), + "create": ("write_to_file", {"path": "TargetFile", "file_text": "CodeContent", "content": "CodeContent"}), + "edit": ("replace_file_content", {"path": "TargetFile", "old_str": "TargetContent", + "new_str": "ReplacementContent"}), + "str_replace": ("replace_file_content", {"path": "TargetFile", "old_str": "TargetContent", + "new_str": "ReplacementContent"}), +} +# Where Copilot loads hooks from inside a repo (hooks reference). A worker +# that could write there could replace its own guard, so no scope may cover +# them and the hook denies writes to them whatever the scope says. +COPILOT_HOOK_PREFIXES = (".github/hooks/", ".github/copilot/", ".claude/") +COPILOT_HOOK_SAMPLES = (".github/hooks/crew-x.json", ".github/copilot/settings.json", + ".github/copilot/settings.local.json", ".claude/settings.json", + ".claude/settings.local.json") +CP_INERT_TOOLS = {"report_intent", "think", "update_todo"} # no file or process effect +PATCH_TARGET = re.compile(r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to):\s*(.+?)\s*$", re.M) + +DEFAULT_REPORT_SCHEMA = { + "type": "object", + "required": ["status", "files_changed", "checks_run", "divergences", + "out_of_scope_needs"], + "properties": { + "status": {"type": "string", "enum": ["done", "partial", "blocked"]}, + "files_changed": {"type": "array", "items": {"type": "string"}}, + "checks_run": {"type": "array", "items": {"type": "string"}, + "description": "each command you ran, suffixed with PASS or FAIL as you saw it"}, + "divergences": {"type": "array", "items": {"type": "string"}, + "description": "every behaviour difference you introduced versus the source of truth, with the reason; empty only if none"}, + "out_of_scope_needs": {"type": "array", "items": {"type": "string"}, + "description": "files outside the allowed list that would need to change, and why"}, + "notes": {"type": "string"}, + }, +} + +DEFAULT_REVIEW_SCHEMA = { + "type": "object", + "required": ["verdict", "findings", "summary"], + "properties": { + "verdict": {"type": "string", "enum": ["approve", "request_changes", "unsure"]}, + "findings": {"type": "array", "items": { + "type": "object", + "required": ["file", "severity", "claim", "evidence"], + "properties": { + "file": {"type": "string"}, + "line": {"type": "integer"}, + "severity": {"type": "string", "enum": ["high", "medium", "low"]}, + "claim": {"type": "string"}, + "evidence": {"type": "string", + "description": "the exact code or text that supports the claim"}, + }}}, + "summary": {"type": "string"}, + }, +} + + +class Refusal(Exception): + """A precondition failed before agy was started. Nothing ran.""" + + +# -------------------------------------------------------------------------- +# small helpers +# -------------------------------------------------------------------------- + +def eprint(*a) -> None: + print(*a, file=sys.stderr, flush=True) + + +def git(repo: str, *args: str, check: bool = True, binary: bool = False): + # The worker could have planted config in the repo; our own git calls must + # not run it (fsmonitor and hooks execute programs, ext diff too). + proc = subprocess.run(["git", "-C", repo, "-c", "color.ui=false", "-c", "core.fsmonitor=false", + "-c", "core.hooksPath=/dev/null", *args], + capture_output=True, text=not binary) + if check and proc.returncode != 0: + err = proc.stderr if not binary else proc.stderr.decode(errors="replace") + raise Refusal(f"git {' '.join(args)} falhou: {err.strip()}") + return proc.stdout + + +def repo_root(start: str) -> str: + try: + out = git(start, "rev-parse", "--show-toplevel") + except (Refusal, FileNotFoundError, NotADirectoryError) as exc: + raise Refusal(f"{start} nao e um repositorio git: {exc}") from exc + return os.path.realpath(out.strip()) + + +def state_base(repo: str) -> str: + return git(repo, "rev-parse", "--path-format=absolute", "--git-path", STATE_NAME).strip() + + +def parse_duration(text) -> int: + if isinstance(text, (int, float)): + return int(text) + m = re.fullmatch(r"\s*(\d+)\s*([smh]?)\s*", str(text)) + if not m: + raise Refusal(f"duracao invalida: {text!r} (use 90s, 20m, 1h)") + n, unit = int(m.group(1)), m.group(2) or "s" + return n * {"s": 1, "m": 60, "h": 3600}[unit] + + +def tail(text: str, n: int = TAIL_CHARS) -> str: + return text if len(text) <= n else "…(cortado)…\n" + text[-n:] + + +def in_globs(rel: str, globs) -> bool: + """Bash-pattern semantics as in the prototype: `*` crosses `/`.""" + return any(fnmatch.fnmatchcase(rel, g) for g in globs if g and not g.startswith("#")) + + +def append_jsonl(path: str, obj: dict) -> None: + obj = {"t": time.strftime("%H:%M:%S"), **obj} + with open(path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(obj, ensure_ascii=False) + "\n") + + +def read_json(path: str, default=None): + try: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except (OSError, ValueError): + return default + + +def write_json(path: str, obj) -> None: + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(obj, fh, ensure_ascii=False, indent=2) + os.replace(tmp, path) + + +# -------------------------------------------------------------------------- +# task file +# -------------------------------------------------------------------------- + +def load_task(path: str, **overrides) -> dict: + path = os.path.abspath(path) + task = read_json(path) + if not isinstance(task, dict): + raise Refusal(f"{path}: nao e um objeto JSON legivel") + task.update({k: v for k, v in overrides.items() if v}) + base = os.path.dirname(path) + if "prompt_file" in task: + pf = os.path.join(base, task["prompt_file"]) + try: + with open(pf, encoding="utf-8") as fh: + task["prompt"] = fh.read() + except OSError as exc: + raise Refusal(f"prompt_file {pf}: {exc}") from exc + if isinstance(task.get("report_schema"), str): + sp = os.path.join(base, task["report_schema"]) + task["report_schema"] = read_json(sp) + if task["report_schema"] is None: + raise Refusal(f"report_schema {sp}: nao parseia") + return normalize_task(task) + + +def normalize_task(task: dict) -> dict: + errs = [] + prompt = task.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + errs.append("prompt (ou prompt_file) ausente") + elif len(prompt.encode()) > MAX_PROMPT_BYTES: + errs.append(f"prompt maior que {MAX_PROMPT_BYTES} bytes; aponte para arquivos em vez de cola-los") + scope = task.get("scope") + if not (isinstance(scope, list) and scope and all(isinstance(s, str) and s for s in scope)): + errs.append("scope deve ser uma lista nao vazia de globs relativos a raiz do repo") + elif any(s.startswith("/") or ".." in s.split("/") for s in scope): + errs.append("scope: globs sao relativos a raiz do repo, sem / inicial e sem ..") + gate = task.get("gate") + if not isinstance(gate, dict): + errs.append("gate deve ser um objeto {format, checks, forbid}") + gate = {} + for key in ("format", "checks", "forbid"): + val = gate.get(key, []) + if not (isinstance(val, list) and all(isinstance(x, str) for x in val)): + errs.append(f"gate.{key} deve ser lista de strings") + if not gate.get("checks") and not gate.get("format"): + errs.append("gate sem nenhum comando: sem checks o gate so confere escopo, e isso nao prova nada") + for pat in gate.get("forbid", []) or []: + try: + re.compile(pat) + except re.error as exc: + errs.append(f"gate.forbid {pat!r}: regex invalida ({exc})") + schema = task.get("report_schema", DEFAULT_REPORT_SCHEMA) + if not (isinstance(schema, dict) and schema.get("type") == "object"): + errs.append("report_schema precisa de raiz \"type\": \"object\" (agy 1.2.14+ recusa outra raiz)") + for key in ("side_effects", "allow_paths", "deny_commands", "secret_globs"): + val = task.get(key, []) + if not (isinstance(val, list) and all(isinstance(x, str) for x in val)): + errs.append(f"{key} deve ser lista de strings") + for pat in task.get("deny_commands", []) or []: + try: + re.compile(pat) + except re.error as exc: + errs.append(f"deny_commands {pat!r}: regex invalida ({exc})") + backend = task.get("backend", "agy") + if backend not in BACKENDS: + errs.append(f"backend {backend!r}: use um de {BACKENDS}") + elif backend == "opencode" and isinstance(scope, list) and any( + in_globs(f, scope) for f in OPENCODE_CONFIG_FILES): + errs.append("scope cobre opencode.json(c), que o driver restaura depois de cada run do opencode") + if not isinstance(task.get("disable_claude_code", False), bool): + errs.append("disable_claude_code deve ser booleano") + if not isinstance(task.get("copilot_model_override", False), bool): + errs.append("copilot_model_override deve ser booleano") + max_attempts = task.get("max_gate_attempts", DEFAULT_MAX_GATE_ATTEMPTS) + credits = task.get("max_ai_credits", COPILOT_MIN_AI_CREDITS) + if not isinstance(credits, (int, float)) or isinstance(credits, bool) or credits < COPILOT_MIN_AI_CREDITS: + errs.append(f"max_ai_credits deve ser numero >= {COPILOT_MIN_AI_CREDITS} (minimo do copilot)") + if backend == "copilot": + model_err = copilot_model_problem(task.get("model", COPILOT_DEFAULT_MODEL), + task.get("copilot_model_override", False)) + if model_err: + errs.append(model_err) + if isinstance(max_attempts, (int, str)) and str(max_attempts).isdigit() and \ + int(max_attempts) > COPILOT_MAX_STOP_BLOCKS: + errs.append(f"max_gate_attempts {max_attempts} > {COPILOT_MAX_STOP_BLOCKS}: o copilot ignora o " + f"agentStop depois de {COPILOT_MAX_STOP_BLOCKS} bloqueios seguidos") + if isinstance(scope, list) and any(in_globs(f, scope) for f in COPILOT_HOOK_SAMPLES): + errs.append("scope do copilot cobre um arquivo de onde o copilot carrega hooks " + "(.github/hooks/, .github/copilot/settings*.json, .claude/settings*.json)") + try: + parse_duration(task.get("timeout", DEFAULT_TIMEOUT)) + parse_duration(task.get("gate_timeout", DEFAULT_GATE_TIMEOUT)) + except Refusal as exc: + errs.append(str(exc)) + if errs: + raise Refusal("task invalida:\n - " + "\n - ".join(errs)) + return { + "prompt": prompt, + "scope": list(scope), + "gate": {"format": list(gate.get("format", [])), "checks": list(gate.get("checks", [])), + "forbid": list(gate.get("forbid", []))}, + "report_schema": schema, + "backend": backend, + "disable_claude_code": task.get("disable_claude_code", False), + "model": task.get("model", {"opencode": OPENCODE_DEFAULT_MODEL, + "copilot": COPILOT_DEFAULT_MODEL}.get(backend, DEFAULT_MODEL)), + "copilot_model_override": task.get("copilot_model_override", False), + "max_ai_credits": credits, + "timeout": parse_duration(task.get("timeout", DEFAULT_TIMEOUT)), + "gate_timeout": parse_duration(task.get("gate_timeout", DEFAULT_GATE_TIMEOUT)), + "max_gate_attempts": int(task.get("max_gate_attempts", DEFAULT_MAX_GATE_ATTEMPTS)), + "side_effects": list(task.get("side_effects", [])), + "allow_paths": list(task.get("allow_paths", [])), + "deny_commands": list(task.get("deny_commands", [])), + "secret_globs": list(task.get("secret_globs", DEFAULT_SECRET_GLOBS)), + "min_quota": float(task.get("min_quota", DEFAULT_MIN_QUOTA)), + } + + +def copilot_model_problem(model, override: bool): + """None when the model may be used with Copilot, else why not.""" + if model == COPILOT_DEFAULT_MODEL: + return None + if override and isinstance(model, str) and model: + return None + return (f"copilot: modelo {model!r} recusado. Nesta conta (plano Free, copilot 1.0.96, 2026-10-13) so " + "`--model auto` funcionou: os 32 modelos do catalogo foram recusados com `--model`, e o Auto " + "roteou sempre para gpt-5.6-luna. Num plano pago, `\"copilot_model_override\": true` na task (ou " + "--copilot-model-override no review) libera outro modelo: caminho NAO testado.") + + +def compose_prompt(task: dict, repo: str) -> str: + g = task["gate"] + lst = lambda xs: ", ".join(f"`{x}`" for x in xs) if xs else "(none)" # noqa: E731 + steps = ["only allowed files changed, and at least one"] + if g["forbid"]: + steps.append(f"no added line matches any of: {lst(g['forbid'])}") + if g["format"]: + steps.append(f"format check: {lst(g['format'])}") + if g["checks"]: + steps.append(f"checks: {lst(g['checks'])}") + steps_txt = "\n".join(f" {i}. {s}" for i, s in enumerate(steps, 1)) + if task.get("backend") == "copilot": + run_yourself = ("You have NO shell tool: do not try to run commands, tests or formatters. When you " + "stop, this gate runs automatically and, if it fails, you are shown its output and " + "must continue.") + else: + run_yourself = "Run the format and check commands yourself before declaring done." + note = "`{files}` stands for the allowed files you changed. " if any( + "{files}" in c for c in g["format"] + g["checks"]) else "" + side = (f"- These files may be changed by tools as a side effect: {lst(task['side_effects'])}. " + "Do not edit or revert them.\n") if task["side_effects"] else "" + return f"""{task['prompt'].rstrip()} + +--- +## Operating rules (enforced by hooks; not negotiable) +- Repository root: `{repo}`. Work only inside it. Absolute paths outside it, `~`, `$HOME` and `..` are blocked. +- You may create or edit ONLY files matching: {lst(task['scope'])}. Use the file-editing tools; never write files through the shell. +- Never run: git commit/push/checkout/reset/stash, package installs, deploys, migrations, network tools, or process inspection (ps, kill). +{side}- If a file outside the allowed list must change, do NOT work around it and do not fix unrelated things: say so in the report (`out_of_scope_needs`). +- When you declare the task done, this gate runs; if any step fails you will be shown its output and must continue: +{steps_txt} + {note}{run_yourself} The gate is fully described here; do not search the filesystem for it. +- Final report: fill every field of the required schema. List every behaviour divergence you introduced and why. Do not claim a check passed unless you saw it pass. +{report_rule(task)}""" + + +def report_rule(task: dict) -> str: + """opencode has no --json-schema: the report is the last JSON object in + the final message, and the driver asks again if it is missing.""" + if task.get("backend") not in ("opencode", "copilot"): + return "" + again = (" Every final message, including the one after gate feedback, must end with it." + if task.get("backend") == "copilot" else "") + return ("- End your final message with the report as ONE JSON object (no prose after it) matching " + f"this JSON schema: {json.dumps(task['report_schema'], separators=(',', ':'))}.{again}\n") + + +# -------------------------------------------------------------------------- +# PreToolUse guard (pure) +# -------------------------------------------------------------------------- + +PATH_TOKEN = re.compile(r"""(?:^|[\s=:"'(,\[<>|;&])(~[A-Za-z0-9._/+@-]*|/[A-Za-z0-9._~/+@*?\[\]-]*)""") +GLOB_CHARS = set("*?[") + + +def arg_strings(v, key=""): + """(key, string) for every non-content string in a tool's args.""" + if isinstance(v, str): + yield key, v + elif isinstance(v, dict): + for k, x in v.items(): + if k not in CONTENT_KEYS: + yield from arg_strings(x, k) + elif isinstance(v, list): + for x in v: + yield from arg_strings(x, key) + + +def shell_path_tokens(cmd: str): + """Absolute and ~ paths written literally in a shell command.""" + for m in PATH_TOKEN.finditer(cmd): + tok = m.group(1) + if tok.startswith("~"): + if tok == "~" or tok.startswith("~/"): + yield tok + continue + tok = "/" + tok.lstrip("/") # `//etc` is `/etc` + if tok == "/": + continue # `$((4 / 2))`; `cd /` is caught by the cd check + first = tok.split("/")[1] + # A path whose top-level directory does not exist cannot be read, and a + # non-root worker cannot create one: `grep '/api/v1'`, `sed -n '/a/,/b/p'`. + if not (GLOB_CHARS & set(first)) and not os.path.lexists("/" + first): + continue + yield tok + + +def path_allowed(tok: str, repo: str, extra) -> bool: + p = os.path.realpath(os.path.expanduser(tok)) + for base in (repo, *SYSTEM_PATHS, *extra): + base = os.path.realpath(os.path.expanduser(base)) + if p == base or p.startswith(base.rstrip("/") + "/"): + return True + return False + + +def resolve(path: str, base: str) -> str: + """Real path of `path`, relative ones taken from `base`; symlinks followed.""" + return os.path.realpath(os.path.join(base, os.path.expanduser(path))) + + +def unquote_shell(cmd: str) -> str: + """The command with quote and escape characters dropped, contents kept, so + `g''it push` and `"git" push` read as `git push` for the deny list.""" + return re.sub(r"['\"\\]", "", cmd) + + +def strip_quoted(cmd: str) -> str: + """The command with quoted strings emptied: `grep '->' x` has no redirect.""" + return re.sub(r"'[^']*'|\"(?:[^\"\\]|\\.)*\"", "''", cmd) + + +def writes_by_redirect(cmd: str) -> bool: + """True when the shell itself writes a file (`> f`, `>> f`, `&> f`, `tee` + is in the deny list); redirects to /dev/null and between fds are fine.""" + s = strip_quoted(cmd) + s = re.sub(r"\d*>&\d+|&>>?\s*/dev/null|\d*>>?\s*/dev/(null|stdout|stderr)\b", " ", s) + return ">" in s + + +CD_TARGET = re.compile(r"(?:^|[;&|(\n])\s*(?:cd|pushd)\b\s*([^\s;&|)]*)") + + +def is_gate_command(cmd: str, gate_cmds) -> bool: + """The worker is told to run the gate itself, so a gate command is never + blocked by the generic deny list -- but only the command itself, plus + redirects to /dev/null or between fds. Anything else after it (chaining, + a newline, a substitution, a variable, a file redirect) goes through the + full check.""" + cmd = cmd.strip() + for gc in gate_cmds: + prefix = gc.split("{files}")[0].strip() + if not prefix or not cmd.startswith(prefix): + continue + rest = cmd[len(prefix):] + if rest and not rest[0].isspace() and not prefix[-1].isspace(): + continue # `true` must not exempt `trueX` + rest = re.sub(r"\s\d*>&\d+|\s\d*>>?\s*/dev/null\b", " ", rest) + if not re.search(r"[;&|`$<>(){}\n\r\\]", rest): + return True + return False + + +def secret_reason(what: str) -> str: + return (f"Arquivo de segredo bloqueado: {what}. O worker nao le nem escreve .env, chaves ou " + "certificados; se a tarefa precisa de um valor de config, diga no relatorio " + "(out_of_scope_needs).") + + +def deny(reason: str) -> dict: + return {"decision": "deny", "reason": reason} + + +def is_secret(path: str, globs) -> bool: + name = os.path.basename(path.rstrip("/")) + if not name or any(fnmatch.fnmatch(name, e) for e in SECRET_EXCEPTIONS): + return False + return any(fnmatch.fnmatch(name, g) for g in globs) + + +SHELL_WORDS = re.compile(r"[^\s;&|()<>`'\"=]+") + + +def allow_cmd(ctx: dict) -> dict: + """Plain allow. Overwriting WaitMsBeforeAsync does NOT keep run_command in + the foreground: on agy 1.3.4 the overwrite was applied ("A pre-tool hook + changed the arguments ... Changed: WaitMsBeforeAsync") and a long `nx run + web:typecheck` still went to a background task after ~4 s (two LOTVS runs, + 2026-10-13). The leftover background task is what kept agy alive for 11 and + 16 min after the worker stopped; BackgroundWatcher handles that instead.""" + return {"decision": "allow"} + + +def decide_pre(payload: dict, ctx: dict) -> dict: + """Decision for every tool call except the gate on `finish`. Always explicit.""" + call = payload.get("toolCall") or {} + name = call.get("name", "") + args = call.get("args") or {} + if not isinstance(args, dict): + args = {} + repo = ctx["repo"] + extra = ctx.get("allow_paths", []) + if name in FINISH_TOOLS: + return {"decision": "allow"} + if name not in ALLOWED_TOOLS: + return deny(f"Ferramenta {name} bloqueada neste worker: so as ferramentas de arquivo e o " + "run_command passam pelos guard rails. Faca o trabalho com elas, dentro do escopo.") + outside = (f"Trabalhe so dentro de {repo}. O gate esta descrito no enunciado; nao procure por ele.") + # Path-valued arguments (AbsolutePath, DirectoryPath, SearchPath, Cwd, + # TargetFile...), relative ones included, resolved through symlinks. + for key, val in arg_strings(args): + if key == "CommandLine" or "://" in val: + continue + if val.startswith(("/", "~", ".")) or "/" in val: + if not path_allowed(resolve(val, repo), repo, extra): + return deny(f"Acesso fora do repositorio bloqueado: {key}={val}. {outside}") + if (key.endswith(("Path", "File", "Paths", "Files")) or val.startswith(("/", "~", ".")) + or "/" in val) and is_secret(val, ctx.get("secret_globs", [])): + return deny(secret_reason(val)) + target = args.get("TargetFile") + if isinstance(target, str) and target: + rel = os.path.relpath(resolve(target, repo), repo) + if rel.startswith("..") or rel.split("/")[0] in (".git", ".agents"): + return deny(f"Escrita fora da area de trabalho: {target}.") + if not in_globs(rel, ctx.get("scope", [])): + allowed = " ".join(ctx.get("scope", [])) or "(nenhum: modo somente leitura)" + return deny(f"Fora do escopo desta tarefa: {rel}. Permitidos: {allowed}. Nao contorne pelo " + "shell; se outro arquivo precisar mudar, diga no relatorio (out_of_scope_needs).") + cmd = args.get("CommandLine") + if isinstance(cmd, str) and cmd.strip(): + for tok in shell_path_tokens(cmd): + if not path_allowed(tok, repo, extra): + return deny(f"Acesso fora do repositorio bloqueado: {tok}. {outside}") + cwd = args.get("Cwd") if isinstance(args.get("Cwd"), str) and args.get("Cwd") else repo + for m in CD_TARGET.finditer(cmd): + dest = m.group(1) + if dest in ("", "-") or not path_allowed(resolve(dest, cwd), repo, extra): + return deny(f"cd para fora do repositorio bloqueado: {m.group(0).strip()!r}. {outside}") + for word in SHELL_WORDS.findall(cmd): + if is_secret(word, ctx.get("secret_globs", [])): + return deny(secret_reason(word)) + if is_gate_command(cmd, ctx.get("gate_commands", [])): + return allow_cmd(ctx) + bare = unquote_shell(cmd) + for pat, label in ctx.get("deny_patterns", []): + if re.search(pat, cmd) or re.search(pat, bare): + return deny(f"Comando bloqueado ({label}): {cmd!r}. Edite so com as ferramentas de edicao; " + "sem commit, push, install, deploy, migracao, rede ou processos. Testes, lint, " + "typecheck e leitura sao permitidos.") + if writes_by_redirect(cmd): + return deny(f"Escrita pelo shell bloqueada (redirecionamento para arquivo): {cmd!r}. Use as " + "ferramentas de edicao; redirecione saida so para /dev/null ou 2>&1.") + return allow_cmd(ctx) + return {"decision": "allow"} + + +def oc_calls(tool: str, args: dict) -> list: + """An opencode tool call as the agy-shaped payloads decide_pre judges: one + per file an apply_patch touches, none for tools without effects. Unknown + tools keep their name, so the allowlist denies them.""" + if tool in OC_INERT_TOOLS: + return [] + if tool == "apply_patch": + targets = PATCH_TARGET.findall(str(args.get("patchText", ""))) + return ([{"toolCall": {"name": "write_to_file", "args": {"TargetFile": t}}} for t in targets] + or [{"toolCall": {"name": "apply_patch", "args": {}}}]) + name, keys = OC_TOOLS.get(tool, (tool, {})) + mapped = {keys.get(k, "Description" if k == "description" else k): v for k, v in args.items()} + return [{"toolCall": {"name": name, "args": mapped}}] + + +def cp_calls(tool: str, args) -> list: + """A Copilot tool call as agy-shaped payloads for decide_pre, like + oc_calls. `apply_patch` arrives as the raw patch text (lab); one payload per + file it touches. Unknown tools keep their name, so the allowlist denies.""" + if tool in CP_INERT_TOOLS: + return [] + if isinstance(args, str): + try: + parsed = json.loads(args) + except ValueError: + parsed = None + args = parsed if isinstance(parsed, dict) else {"_raw": args} + if not isinstance(args, dict): + args = {} + if tool == "apply_patch": + text = "\n".join(v for v in args.values() if isinstance(v, str)) + targets = PATCH_TARGET.findall(text) + return ([{"toolCall": {"name": "write_to_file", "args": {"TargetFile": t}}} for t in targets] + or [{"toolCall": {"name": "apply_patch", "args": {}}}]) + name, keys = CP_TOOLS.get(tool, (tool, {})) + mapped = {keys.get(k, "Description" if k in ("description", "intent") else k): v + for k, v in args.items()} + return [{"toolCall": {"name": name, "args": mapped}}] + + +def decide_cp(payload: dict, ctx: dict) -> dict: + """preToolUse for Copilot. The shell is excluded from the model; if a shell + tool shows up anyway it is denied here, before decide_pre could allow a + harmless-looking command, and the verdict fails the run.""" + tool = str(payload.get("toolName", "")) + if tool in COPILOT_SHELL_TOOLS: + return deny(f"Ferramenta {tool} bloqueada: este worker nao tem shell. Edite com as ferramentas " + "de arquivo; o gate roda sozinho quando voce para.") + for call in cp_calls(tool, payload.get("toolArgs")): + target = (call["toolCall"].get("args") or {}).get("TargetFile") + if isinstance(target, str) and target: + rel = os.path.relpath(resolve(target, ctx["repo"]), ctx["repo"]) + if rel.startswith(COPILOT_HOOK_PREFIXES): + return deny(f"Escrita bloqueada em {rel}: o copilot carrega hooks dali.") + out = decide_pre(call, ctx) + if out["decision"] != "allow": + return out + return {"decision": "allow"} + + +def decide_oc(payload: dict, ctx: dict) -> dict: + args = payload.get("args") if isinstance(payload.get("args"), dict) else {} + for call in oc_calls(str(payload.get("tool", "")), args): + out = decide_pre(call, ctx) + if out["decision"] != "allow": + return out + return {"decision": "allow"} + + +# -------------------------------------------------------------------------- +# tree inspection and the gate +# -------------------------------------------------------------------------- + +def changed_paths(repo: str, ignore=()) -> list: + """Changed and untracked paths, minus agy's hooks file and `ignore` (the + exact Copilot hooks file of the run, never a glob).""" + out = git(repo, "status", "--porcelain=v1", "-z", "--untracked-files=all") + recs = out.split("\0") + paths, i = [], 0 + while i < len(recs): + rec = recs[i] + i += 1 + if len(rec) < 4: + continue + xy, path = rec[:2], rec[3:] + paths.append(path) + if "R" in xy or "C" in xy: # rename/copy: next record is the source + if i < len(recs) and recs[i]: + paths.append(recs[i]) + i += 1 + skip = {HOOK_FILE, *ignore} + return [p for p in dict.fromkeys(paths) if p not in skip] + + +def classify(paths, scope, side_effects) -> dict: + out = {"in_scope": [], "side_effects": [], "out_of_scope": []} + for p in paths: + if in_globs(p, scope): + out["in_scope"].append(p) + elif in_globs(p, side_effects): + out["side_effects"].append(p) + else: + out["out_of_scope"].append(p) + return out + + +def fingerprint(repo: str, side_effects, ignore=()) -> str: + h = hashlib.sha256() + tracked = set(git(repo, "ls-files", "-z").split("\0")) + for p in sorted(changed_paths(repo, ignore)): + if in_globs(p, side_effects): + continue + h.update(p.encode() + b"\0") + if p in tracked: + h.update(git(repo, "diff", "--no-ext-diff", "HEAD", "--binary", "--", p, binary=True)) + else: + try: + with open(os.path.join(repo, p), "rb") as fh: + h.update(fh.read()) + except OSError: + h.update(b"") + return h.hexdigest() + + +def integrity_state(repo: str, rundir: str) -> dict: + """Hashes of what a worker could use to run code or hide changes: git + config, hooks and excludes, index flags that hide edits from `git status`, + and this run's own ctx.json. Compared before and after the run.""" + state = {} + dirs = {git(repo, "rev-parse", "--path-format=absolute", "--git-dir").strip(), + git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()} + files = [os.path.join(rundir, "ctx.json")] + for d in dirs: + files += [os.path.join(d, f) for f in ("config", "config.worktree", "info/exclude")] + hooks = os.path.join(d, "hooks") + if os.path.isdir(hooks): + files += [os.path.join(hooks, f) for f in sorted(os.listdir(hooks))] + for f in files: + try: + with open(f, "rb") as fh: + state[f] = hashlib.sha256(fh.read()).hexdigest() + except OSError: + state[f] = None + flagged = [r[2:] for r in git(repo, "ls-files", "-v", "-z").split("\0") + if len(r) > 2 and (r[0].islower() or r[0] == "S")] + state[""] = ",".join(sorted(flagged)) + return state + + +def ignored_state(repo: str) -> dict: + """Ignored files (not directories, which `--directory` collapses) with + size and mtime: `git status` never shows them, so a write to `.env` would + otherwise go unseen. Directories are left out because caches and build + output (`__pycache__/`, `dist/`) change on every gate run.""" + out = {} + for p in git(repo, "ls-files", "-z", "-o", "-i", "--exclude-standard", "--directory").split("\0"): + if not p or p.endswith("/"): + continue + try: + st = os.lstat(os.path.join(repo, p)) + out[p] = (st.st_size, st.st_mtime_ns) + except OSError: + continue + return out + + +def diff_state(before: dict, after: dict) -> list: + return sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k)) + + +def added_lines(repo: str, path: str, tracked: set): + """(line_no, text) for each line the change adds to `path`.""" + full = os.path.join(repo, path) + if path not in tracked: + try: + with open(full, encoding="utf-8", errors="replace") as fh: + for n, line in enumerate(fh, 1): + yield n, line.rstrip("\n") + except OSError: + return + return + n = 0 + for line in git(repo, "diff", "--no-ext-diff", "HEAD", "-U0", "--", path).splitlines(): + m = re.match(r"^@@ -\S+ \+(\d+)", line) + if m: + n = int(m.group(1)) + elif line.startswith("+") and not line.startswith("+++"): + yield n, line[1:] + n += 1 + + +def run_gate(repo: str, ctx: dict, baseline: bool = False): + """Returns (passed, text, classification).""" + lines, ok = [], True + say = lines.append + cls = classify(changed_paths(repo, ctx.get("ignore_paths", [])), ctx["scope"], + ctx.get("side_effects", [])) + files = [f for f in cls["in_scope"] if os.path.exists(os.path.join(repo, f))] + if not baseline: + for p in cls["out_of_scope"]: + say(f"GATE: alterado fora do escopo: {p} (reverta; se precisava mudar, diga no relatorio)") + ok = False + if not cls["in_scope"]: + say("GATE: nenhum arquivo do escopo foi alterado") + ok = False + if ctx["gate"]["forbid"] and files: + tracked = set(git(repo, "ls-files", "-z").split("\0")) + for f in files: + for n, text in added_lines(repo, f, tracked): + for pat in ctx["gate"]["forbid"]: + if re.search(pat, text): + say(f"GATE: atalho proibido /{pat}/ em {f}:{n}: {text.strip()[:160]}") + ok = False + quoted = " ".join(shlex.quote(f) for f in files) + for kind in ("format", "checks"): + for cmd in ctx["gate"][kind]: + if "{files}" in cmd: + if not files: + say(f"GATE: [{kind}] {cmd}: pulado, nenhum arquivo do escopo") + continue + cmd = cmd.replace("{files}", quoted) + # CREW_GATE lets a command tell the gate run from the worker's own + # run of the same command (AGY_WORKER_GATE: the pre-0.4.0 name). Own session, so a timeout + # kills the whole tree the command started, not just bash. + proc = subprocess.Popen(["bash", "-c", cmd], cwd=repo, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, start_new_session=True, + env={**os.environ, **WORKER_ENV, "CREW_GATE": "1", "AGY_WORKER_GATE": "1", + RUN_ENV: ctx.get("run_id", "")}) + try: + out, _ = proc.communicate(timeout=ctx.get("gate_timeout", DEFAULT_GATE_TIMEOUT)) + rc = proc.returncode + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + out, _ = proc.communicate() + rc = "timeout" + out = out or "" + if rc == 0: + say(f"GATE: [{kind}] {cmd}: ok") + else: + ok = False + say(f"GATE: [{kind}] {cmd}: FALHOU (rc={rc})") + say(tail(out.strip(), 3000)) + say("GATE: PASS" if ok else "GATE: FAIL") + return ok, "\n".join(lines), cls + + +def gate_cached(rundir: str, ctx: dict, trigger: str): + """Gate with a per-tree-state cache of PASSES, counted against max_gate_attempts. + + The same gate is asked for twice for one state (the `finish` tool, then + Stop with TERMINAL_STEP_TYPE); re-running an `nx test` for an identical + tree only burns time. Failures are never cached: in the lab a cached + failure left the worker nothing legitimate to change, and it went looking + for the gate (`env`, `git config --list`, /proc). A flaky check must get + its re-run. + """ + repo = ctx["repo"] + st_path = os.path.join(rundir, "gate-state.json") + st = read_json(st_path, {"attempts": 0, "runs": 0}) + st["attempts"] += 1 + fp = fingerprint(repo, ctx.get("side_effects", []), ctx.get("ignore_paths", [])) + if st.get("fingerprint") == fp and st.get("passed") is True: + passed, text, cached = True, st["text"], True + else: + passed, text, _ = run_gate(repo, ctx) + st["runs"] += 1 + with open(os.path.join(rundir, f"gate-{st['runs']}.log"), "w", encoding="utf-8") as fh: + fh.write(text + "\n") + # Gate commands can themselves touch the tree (formatters, Nx config); + # the cache key is the state *after* them, so an identical re-check hits. + fp = fingerprint(repo, ctx.get("side_effects", []), ctx.get("ignore_paths", [])) + st.update(fingerprint=fp, passed=passed, text=text) + cached = False + write_json(st_path, st) + append_jsonl(os.path.join(rundir, "guard.jsonl"), + {"event": "gate", "trigger": trigger, "attempt": st["attempts"], + "passed": passed, "cached": cached}) + return passed, text, st["attempts"] + + +# -------------------------------------------------------------------------- +# hook entry points (run by agy, one process per event) +# -------------------------------------------------------------------------- + +def load_ctx(rundir: str) -> dict: + ctx = read_json(os.path.join(rundir, "ctx.json")) + if not isinstance(ctx, dict): + raise RuntimeError(f"ctx.json ilegivel em {rundir}") + ctx["deny_patterns"] = [tuple(x) for x in ctx.get("deny_patterns", [])] + return ctx + + +def hook_main(event: str, rundir: str) -> int: + raw = sys.stdin.read() + log = os.path.join(rundir, "guard.jsonl") + try: + payload = json.loads(raw) if raw.strip() else {} + except ValueError: + payload = {} + try: + ctx = load_ctx(rundir) + if event == "start": + append_jsonl(log, {"event": "session_start", "model": payload.get("modelName"), + "conversation": payload.get("conversationId")}) + out = {} + elif event == "oc-pre": + out = decide_oc(payload, ctx) + entry = {"event": "pre", "tool": payload.get("tool"), "decision": out["decision"]} + if out["decision"] != "allow": + entry["reason"] = out.get("reason", "")[:300] + entry["args"] = {k: (v if len(str(v)) < 300 else str(v)[:300] + "…") + for k, v in (payload.get("args") or {}).items() + if k not in ("content", "oldString", "newString", "patchText")} + append_jsonl(log, entry) + elif event == "cp-pre": + out = decide_cp(payload, ctx) + entry = {"event": "pre", "tool": payload.get("toolName"), "decision": out["decision"]} + if out["decision"] != "allow": + entry["reason"] = out.get("reason", "")[:300] + args = payload.get("toolArgs") + entry["args"] = ({k: (v if len(str(v)) < 300 else str(v)[:300] + "…") + for k, v in args.items() if k not in ("file_text", "old_str", "new_str")} + if isinstance(args, dict) else str(args)[:300]) + append_jsonl(log, entry) + elif event == "cp-stop": + out = cp_stop(rundir, ctx, payload) + append_jsonl(log, {"event": "stop", "reason": "agentStop", + "stop_hook_active": payload.get("stop_hook_active"), + "decision": out["decision"]}) + elif event == "pre": + name = (payload.get("toolCall") or {}).get("name", "") + if name in FINISH_TOOLS and ctx.get("mode") == "delegate": + passed, text, n = gate_cached(rundir, ctx, "finish") + if passed or n >= ctx["max_gate_attempts"]: + out = {"decision": "allow"} + else: + out = {"decision": "deny", "reason": + f"O gate FALHOU (tentativa {n} de {ctx['max_gate_attempts']}); a tarefa nao " + "esta pronta. Corrija a causa real, sem desativar teste nem sair do escopo, e " + f"so entao chame finish de novo. Saida do gate:\n{tail(text)}"} + else: + out = decide_pre(payload, ctx) + entry = {"event": "pre", "tool": name, "decision": out["decision"]} + if out["decision"] != "allow": + entry["reason"] = out.get("reason", "")[:300] + entry["args"] = {k: (v if len(str(v)) < 300 else str(v)[:300] + "…") + for k, v in ((payload.get("toolCall") or {}).get("args") or {}).items() + if k not in CONTENT_KEYS} + append_jsonl(log, entry) + elif event == "stop": + reason = payload.get("terminationReason", "") + if ctx.get("mode") != "delegate" or reason not in GATE_STOP_REASONS: + append_jsonl(log, {"event": "stop", "reason": reason, "error": payload.get("error")}) + out = {"decision": "stop"} + else: + passed, text, n = gate_cached(rundir, ctx, f"stop:{reason}") + prior = guard_events(rundir) + finished = any(e.get("event") == "pre" and e.get("tool") in FINISH_TOOLS + and e.get("decision") == "allow" for e in prior) + asks = sum(1 for e in prior if e.get("event") == "stop" and e.get("ask") == "report") + if passed and not finished and asks < AGY_REPORT_ASKS: + out = {"decision": "continue", "reason": + "O gate PASSOU. Falta so o relatorio: chame a ferramenta finish agora, com o " + "relatorio pedido. Nao espere comando em segundo plano nem rode mais nada."} + append_jsonl(log, {"event": "stop", "reason": reason, "decision": "continue", + "ask": "report"}) + print(json.dumps(out, ensure_ascii=False)) + return 0 + if passed or n >= ctx["max_gate_attempts"]: + out = {"decision": "stop"} + else: + out = {"decision": "continue", "reason": + f"Voce declarou terminado, mas o gate FALHOU (tentativa {n} de " + f"{ctx['max_gate_attempts']}). Corrija a causa real, sem desativar teste, " + f"sem atalho proibido e sem sair do escopo. Saida do gate:\n{tail(text)}"} + append_jsonl(log, {"event": "stop", "reason": reason, "decision": out["decision"]}) + else: + out = {} + except Exception as exc: # noqa: BLE001 — a crashing guard must still decide + try: + append_jsonl(log, {"event": f"{event}_error", "error": repr(exc)[:500]}) + except OSError: + pass + out = ({"decision": "deny", "reason": f"guard interno falhou ({exc!r}); chamada negada por seguranca"} + if event in ("pre", "oc-pre", "cp-pre") else {"decision": "stop"} if event == "stop" + else {"decision": "allow"} if event == "cp-stop" else {}) + print(cp_hook_output(event, out) if event in ("cp-pre", "cp-stop") else json.dumps(out, ensure_ascii=False)) + return 0 + + +def cp_hook_output(event: str, out: dict) -> str: + """Copilot's hook contract. preToolUse: a deny is `permissionDecision` + with a reason (lab: the model sees "Denied by preToolUse hook: "); + an allow prints nothing, the lab-verified pass-through, so the CLI's own + path checks (`--disallow-temp-dir`) still apply. agentStop: `block` with + the reason as the next prompt, or `allow`.""" + if event == "cp-pre": + if out.get("decision") == "allow": + return "" + return json.dumps({"permissionDecision": "deny", + "permissionDecisionReason": out.get("reason") or "negado"}, ensure_ascii=False) + if out.get("decision") == "block": + return json.dumps({"decision": "block", "reason": out.get("reason", "")}, ensure_ascii=False) + return json.dumps({"decision": "allow"}) + + +def cp_final_text(stdout: str): + """The content of the last non-empty assistant.message in Copilot's JSONL, + or None when there is none.""" + text = None + for line in stdout.splitlines(): + try: + ev = json.loads(line) + except ValueError: + continue + if isinstance(ev, dict) and ev.get("type") == "assistant.message": + content = (ev.get("data") or {}).get("content") + if isinstance(content, str) and content.strip(): + text = content + return text + + +def cp_stop(rundir: str, ctx: dict, payload: dict) -> dict: + """agentStop: the gate, then the report. The worker has no shell, so this + is the only place the gate runs before the final one. Blocks only while + the gate fails (up to max_gate_attempts) or, once green, while the final + message visibly lacks the report (up to COPILOT_REPORT_ASKS); when the + event stream cannot be read yet, the report is not asked for, so a + buffering CLI cannot loop the worker.""" + if ctx.get("mode") != "delegate": + return {"decision": "allow"} + passed, text, n = gate_cached(rundir, ctx, "agentStop") + if not passed: + if n >= ctx["max_gate_attempts"]: + return {"decision": "allow"} + return {"decision": "block", "reason": + f"The gate FAILED (attempt {n} of {ctx['max_gate_attempts']}); the task is not done. Fix the " + "real cause with the file tools, without disabling tests, without forbidden shortcuts and " + "without leaving the allowed files. You have no shell: the gate runs again when you stop. " + "End your final message with the JSON report. Gate output:\n" + tail(text)} + try: + with open(ctx.get("events_file", ""), encoding="utf-8", errors="replace") as fh: + final = cp_final_text(fh.read()) + except OSError: + final = None + problems = report_problems(extract_report(final), ctx.get("report_schema") or DEFAULT_REPORT_SCHEMA) + if final is None or not problems: + return {"decision": "allow"} + asks_path = os.path.join(rundir, "report-asks.json") + asks = read_json(asks_path, {"n": 0}) + if asks["n"] >= COPILOT_REPORT_ASKS: + return {"decision": "allow"} + asks["n"] += 1 + write_json(asks_path, asks) + return {"decision": "block", "reason": + "The gate passed. Now end your final message with the report as ONE JSON object matching the " + f"schema given before ({'; '.join(problems)}). Do not change any file."} + + +# -------------------------------------------------------------------------- +# agy invocation and result evaluation +# -------------------------------------------------------------------------- + +def parse_models(text: str) -> list: + return [ln.split("\t", 1)[0].strip() for ln in text.splitlines() + if "\t" in ln and ln.split("\t", 1)[0].strip()] + + +def evaluate_quota(envelope: dict, model: str, min_quota: float): + """None when there is headroom, else a refusal message.""" + groups = (((envelope or {}).get("command") or {}).get("data") or {}).get("groups") + if not isinstance(groups, list): + return "nao consegui ler os grupos de quota de `agy -p /usage --output-format json`" + family = model.split("-", 1)[0].lower() + hits = [g for g in groups if family in (str(g.get("name", "")) + " " + + str(g.get("description", ""))).lower()] + if not hits: + return f"nenhum grupo de quota menciona {family!r}; formato do /usage mudou?" + for g in hits: + for b in g.get("buckets") or []: + if b.get("disabled"): + continue + rf = b.get("remaining_fraction") + if not isinstance(rf, (int, float)): + return f"quota {g.get('name')}/{b.get('name')}: remaining_fraction ausente" + if rf < min_quota: + return (f"quota esgotada: {g.get('name')} / {b.get('name')} = {rf:.0%} " + f"(minimo {min_quota:.0%}); renova em {b.get('reset_time', '?')}") + return None + + +def evaluate_run(rc, stdout: str, stderr: str, mode: str, schema: dict): + """(failures, warnings, envelope). Exit 0 is never taken as success.""" + fails, warns = [], [] + strict_denials = mode == "delegate" + if rc == "wrapper-timeout": + fails.append("timeout do wrapper: agy nao terminou e foi encerrado") + elif rc != 0: + m = re.search(r"AGY_ERROR:\s*(\{.*\})", stderr) + detail = m.group(1)[:400] if m else tail(stderr.strip(), 400) + fails.append(f"agy saiu com rc={rc}: {detail}") + if TIMEOUT_MARKER in stderr: + fails.append("print timeout do agy: a saida e parcial (o agy sai com 0 mesmo assim)") + if NO_OUTPUT_MARKER in stderr: + fails.append("agy terminou sem saida e sem erro registrado") + if AUTO_DENY_MARKER in stderr: + (fails if strict_denials else warns).append( + "permissao auto-negada em modo headless: " + tail(stderr.strip(), 300)) + env, parsed = None, False + if not stdout.strip(): + fails.append("stdout vazio") + else: + try: + env, parsed = json.loads(stdout), True + except ValueError: + fails.append("stdout nao e o envelope JSON esperado: " + tail(stdout.strip(), 300)) + if isinstance(env, dict): + if env.get("status") != "SUCCESS": + fails.append(f"status={env.get('status')!r} error={str(env.get('error'))[:300]}") + denied = env.get("denied_actions") or [] + if denied: + (fails if strict_denials else warns).append(f"denied_actions: {json.dumps(denied)[:300]}") + so = env.get("structured_output") + if so is None: + fails.append("structured_output nulo: o relatorio nao satisfez o schema " + "(o agy devolve status SUCCESS mesmo assim)") + elif isinstance(so, dict): + missing = [k for k in schema.get("required", []) if k not in so] + if missing: + fails.append(f"relatorio sem campos obrigatorios: {missing}") + if mode == "delegate" and so.get("status") in ("partial", "blocked"): + fails.append(f"o worker se declarou {so['status']!r}") + elif parsed: + env = None + fails.append(f"stdout e JSON mas nao um objeto: {tail(stdout.strip(), 200)}") + return fails, warns, env + + +def proc_cmdline(pid: int, limit: int = 200) -> str: + """Display form; pass limit=0 for the whole thing (matching must not see a + truncated line: a long script path pushed `crew.py hook` past 200 chars).""" + with open(f"/proc/{pid}/cmdline", "rb") as fh: + text = fh.read().replace(b"\0", b" ").decode(errors="replace").strip() + return text[:limit] if limit else text + + +def proc_env_has(pid: int, *entries: bytes) -> list: + """Which of `entries` (b"KEY=value") are in the process environment.""" + with open(f"/proc/{pid}/environ", "rb") as fh: + env = set(fh.read().split(b"\0")) + return [e in env for e in entries] + + +def ppid_map() -> dict: + """pid -> parent pid, for every process we can see.""" + out = {} + for pid in os.listdir("/proc"): + if not pid.isdigit(): + continue + try: + with open(f"/proc/{pid}/stat", "rb") as fh: + # comm may contain spaces and parens: the fields after the last ')' + out[int(pid)] = int(fh.read().rsplit(b")", 1)[1].split()[1]) + except (OSError, IndexError, ValueError): + continue + return out + + +def descendants(root: int, ppids: dict) -> dict: + """pid -> parent pid for every descendant of root (root excluded).""" + children = {} + for pid, pp in ppids.items(): + children.setdefault(pp, []).append(pid) + out, todo = {}, [root] + while todo: + for c in children.get(todo.pop(), []): + if c not in out and c != root: + out[c] = ppids[c] + todo.append(c) + return out + + +def marked_procs(run_id: str) -> dict: + """pid -> cmdline for every process carrying this run's marker, except us.""" + if not run_id: + return {} + marker, me, hits = f"{RUN_ENV}={run_id}".encode(), os.getpid(), {} + for pid in os.listdir("/proc"): + if not pid.isdigit() or int(pid) == me: + continue + try: + if proc_env_has(int(pid), marker)[0]: + hits[int(pid)] = proc_cmdline(int(pid)) + except OSError: + continue + return hits + + +def kill_pids(pids) -> None: + """SIGTERM, 5 s, then SIGKILL whatever is left. Callers pass only pids they + proved are ours (the run marker).""" + for sig in (signal.SIGTERM, signal.SIGKILL): + alive = [] + for pid in pids: + try: + os.kill(pid, sig) + alive.append(pid) + except OSError: + pass + deadline = time.time() + 5 + while alive and time.time() < deadline: + alive = [p for p in alive if os.path.exists(f"/proc/{p}")] + time.sleep(0.2) + if not alive: + break + + +def stop_run_procs(run_id: str) -> dict: + """Stop the processes that carry this run's marker and outlived it (pid -> + cmdline). Only our own: the marker is set by this script on the worker and + on the gate, and descendants inherit it even after reparenting.""" + hits = marked_procs(run_id) + kill_pids(list(hits)) + return hits + + +# Names an MCP server by its command line (`uv tool uvx ... kino --mcp`, +# `npx -y @upstash/context7-mcp`, `mcp-server-x`). A second line of defence +# behind the baseline in BackgroundWatcher, for a server agy starts late. +MCP_CMDLINE = re.compile(r"(?i)(\bmcp\b|mcp[-_]?server|[-_]mcp\b)") +BG_GRACE_S = 15 + + +class BackgroundWatcher: + """Ends the worker's leftover background commands so agy can exit. + + agy 1.3.4 moves a long run_command (an `nx run web:typecheck`) to a + background task after ~4 s whatever WaitMsBeforeAsync says, and after the + root agent is done it logs "waiting up to 30m0s for N background task(s)" + and sits there: 16 and 11 min in two LOTVS runs (2026-10-13), until the + orphans were killed by hand; then agy exited 0 with a valid envelope. + + Polled once a second while agy runs. Once guard.jsonl shows the worker is + done after a GREEN gate (Stop answered `stop`, or `finish` allowed), it + waits BG_GRACE_S and then terminates processes that are all of: + - descendants of the agy process, and never agy itself (its stdout + envelope is the verdict's input); + - carrying this run's marker (CREW_RUN=) in their environment; + - not a hook process of ours or anything under one (the gate runs there, + with CREW_GATE=1, also skipped); + - not in the baseline: everything agy had started before the worker's + first allowed run_command (MCP servers, agy's own helpers), and their + subtrees; nor anything whose command line names an MCP server. + Whatever is killed is recorded and goes into the verdict. + """ + + def __init__(self, rundir: str, run_id: str, grace: float = BG_GRACE_S): + self.rundir, self.grace = rundir, grace + self.marker = f"{RUN_ENV}={run_id}".encode() + self.hook_tag = f"{os.path.abspath(__file__)} hook" + self.baseline: set = set() + self.saw_command = False + self.gate_green = False + self.finished_at = None + self.offset = 0 + self.seen: set = set() + self.killed: dict = {} + self.errors: list = [] + + def _read_events(self) -> None: + try: + with open(os.path.join(self.rundir, "guard.jsonl"), encoding="utf-8") as fh: + fh.seek(self.offset) + chunk = fh.read() + except OSError: + return + complete = chunk[:chunk.rfind("\n") + 1] # never consume a half-written line + self.offset += len(complete.encode("utf-8")) + for line in complete.splitlines(): + try: + e = json.loads(line) + except ValueError: + continue + ev = e.get("event") + if ev == "gate": + self.gate_green = e.get("passed") is True + elif ev == "pre" and e.get("tool") == "run_command" and e.get("decision") == "allow": + self.saw_command = True + done = ((ev == "stop" and e.get("decision") == "stop") + or (ev == "pre" and e.get("tool") in FINISH_TOOLS and e.get("decision") == "allow")) + if done and self.gate_green and self.finished_at is None: + self.finished_at = time.time() + + def candidates(self, agy_pid: int, tree: dict, info: dict) -> dict: + """pid -> cmdline of the processes that may be killed now. `tree` is + descendants(agy_pid); `info` is pid -> (cmdline, marked, gate).""" + shielded = set() + for pid in tree: + cmd, _marked, gate = info.get(pid, ("", False, False)) + if pid in self.baseline or self.hook_tag in cmd or gate or MCP_CMDLINE.search(cmd): + shielded.add(pid) + + def under_shield(pid): + while pid in tree: + if pid in shielded: + return True + pid = tree[pid] + return False + + me = os.getpid() + return {pid: info[pid][0][:200] for pid in tree + if pid not in (agy_pid, me) and pid in info and info[pid][1] and not under_shield(pid)} + + def poll(self, agy_pid: int) -> None: + try: + tree = descendants(agy_pid, ppid_map()) # scan BEFORE reading events: see baseline + info = {} + for pid in tree: + try: + marked, gate = proc_env_has(pid, self.marker, b"CREW_GATE=1") + info[pid] = (proc_cmdline(pid, 0), marked, gate) + except OSError: + continue + new = [p for p in info if p not in self.seen] + if new: + self.seen.update(new) + for p in new: + append_jsonl(os.path.join(self.rundir, "procs.jsonl"), + {"t": round(time.time(), 1), "pid": p, "ppid": tree.get(p), + "cmd": info[p][0][:200], "marked": info[p][1]}) + self._read_events() + if not self.saw_command: + # The hook logs an allowed run_command before agy spawns it, so + # a scan taken before that log line cannot contain the command. + self.baseline.update(tree) + if self.finished_at is None or time.time() - self.finished_at < self.grace: + return + hits = self.candidates(agy_pid, tree, info) + if hits: + self.killed.update(hits) + append_jsonl(os.path.join(self.rundir, "guard.jsonl"), + {"event": "background_killed", "procs": {str(k): v for k, v in hits.items()}}) + kill_pids(list(hits)) + except Exception as exc: # noqa: BLE001 — the watcher must never break the run + if len(self.errors) < 5: + self.errors.append(repr(exc)[:300]) + + +def refuse_existing_hooks(repo: str) -> None: + path = os.path.join(repo, HOOK_FILE) + if not os.path.lexists(path): + return + spec = read_json(path) + if isinstance(spec, dict) and list(spec) in (["crew"], ["agy-worker"]): # agy-worker: pre-0.4.0 + raise Refusal(f"{path} e sobra de um run do crew que morreu sem limpar (SIGKILL?), ou " + "de um run ainda em curso neste workspace. Se nenhum `agy` estiver rodando aqui, " + f"remova so esse arquivo: rm {shlex.quote(path)}. Enquanto ele existir, um `agy` " + "interativo nesta pasta carrega os guards de um run morto.") + raise Refusal(f"{HOOK_FILE} ja existe neste repo; o agy le um so arquivo de hooks por " + "workspace e este script nao sobrescreve o seu. Mova-o ou rode noutro worktree.") + + +def install_hooks(repo: str, rundir: str, ctx: dict) -> bool: + """Write .agents/hooks.json. Returns whether .agents/ was created by us.""" + refuse_existing_hooks(repo) + path = os.path.join(repo, HOOK_FILE) + agents = os.path.dirname(path) + created = not os.path.isdir(agents) + os.makedirs(agents, exist_ok=True) + base = f"{shlex.quote(sys.executable)} {shlex.quote(os.path.abspath(__file__))} hook" + st = shlex.quote(rundir) + gate_secs = ctx.get("gate_timeout", DEFAULT_GATE_TIMEOUT) * max( + 1, len(ctx["gate"]["format"]) + len(ctx["gate"]["checks"])) + 60 + spec = {"crew": { + "SessionStart": [{"type": "command", "command": f"{base} start --state {st}", "timeout": 10}], + "PreToolUse": [{"matcher": "*", "hooks": [ + {"type": "command", "command": f"{base} pre --state {st}", "timeout": gate_secs}]}], + "Stop": [{"type": "command", "command": f"{base} stop --state {st}", "timeout": gate_secs}], + }} + with open(path, "w", encoding="utf-8") as fh: + json.dump(spec, fh, indent=2) + write_json(os.path.join(rundir, "hooks.installed.json"), spec) + return created + + +def remove_hooks(repo: str, created_dir: bool) -> None: + path = os.path.join(repo, HOOK_FILE) + try: + os.remove(path) + except FileNotFoundError: + pass + if created_dir: + try: + os.rmdir(os.path.dirname(path)) # only if empty: never rm -r + except OSError: + pass + + +def preflight_agy(model: str, min_quota: float, probe_quota: bool, rundir: str) -> list: + """Refuses on a missing model or quota; returns warnings.""" + if not shutil.which("agy"): + raise Refusal("agy nao esta no PATH") + warns = [] + proc = subprocess.run(["agy", "--version"], capture_output=True, text=True, timeout=60) + version = proc.stdout.strip().splitlines()[-1].strip() if proc.stdout.strip() else "?" + if version != TESTED_AGY_VERSION: + warns.append(f"agy {version}; este script so foi exercitado no {TESTED_AGY_VERSION}. Decisao " + "de hook, ferramenta finish, motivos de Stop e formato do /usage podem ter mudado") + proc = subprocess.run(["agy", "models"], capture_output=True, text=True, timeout=120) + slugs = parse_models(proc.stdout) + if proc.returncode != 0 or not slugs: + raise Refusal(f"`agy models` falhou (rc={proc.returncode}): {tail(proc.stderr.strip(), 300)}") + if model not in slugs: + raise Refusal(f"modelo {model!r} nao existe nesta conta. Disponiveis: {', '.join(slugs)}") + if not probe_quota: + return warns + proc = subprocess.run(["agy", "-p", "/usage", "--output-format", "json"], + capture_output=True, text=True, timeout=120) + with open(os.path.join(rundir, "usage.json"), "w", encoding="utf-8") as fh: + fh.write(proc.stdout) + try: + env = json.loads(proc.stdout) + except ValueError: + env = None + msg = evaluate_quota(env, model, min_quota) + if msg: + raise Refusal(msg + " (use --no-quota-probe para pular esta checagem)") + return warns + + +def run_agy(cmd: list, repo: str, rundir: str, timeout_s: int, name: str = "agy", env=None, + grace: int = 180, watch=None): + """Run the worker CLI with cwd = repo and stdin closed (opencode hangs on an + open stdin pipe). Output goes to .stdout/.stderr in the run dir. + `watch(pid)`, if given, is called about once a second while it runs.""" + out_p, err_p = os.path.join(rundir, f"{name}.stdout"), os.path.join(rundir, f"{name}.stderr") + with open(out_p, "w") as fo, open(err_p, "w") as fe: + proc = subprocess.Popen(cmd, cwd=repo, stdout=fo, stderr=fe, stdin=subprocess.DEVNULL, + start_new_session=True, env=env) + try: + deadline = time.time() + timeout_s + grace + while True: + try: + rc = proc.wait(timeout=max(0.1, min(1.0, deadline - time.time())) if watch + else max(0.1, deadline - time.time())) + break + except subprocess.TimeoutExpired: + if time.time() >= deadline: + raise + if watch: + watch(proc.pid) + except (subprocess.TimeoutExpired, KeyboardInterrupt) as exc: + # Our own child's process group: we started it, so we stop it. + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(proc.pid, sig) + proc.wait(timeout=10) + break + except (ProcessLookupError, subprocess.TimeoutExpired): + continue + if isinstance(exc, KeyboardInterrupt): + raise + rc = "wrapper-timeout" + with open(out_p, encoding="utf-8", errors="replace") as fh: + stdout = fh.read() + with open(err_p, encoding="utf-8", errors="replace") as fh: + stderr = fh.read() + return rc, stdout, stderr + + +def new_rundir(repo: str, kind: str) -> str: + base = state_base(repo) + rundir = os.path.join(base, "runs", f"{time.strftime('%Y%m%d-%H%M%S')}-{kind}-{os.getpid()}") + os.makedirs(rundir, exist_ok=True) + return rundir + + +def guard_events(rundir: str): + events = [] + try: + with open(os.path.join(rundir, "guard.jsonl"), encoding="utf-8") as fh: + for line in fh: + try: + events.append(json.loads(line)) + except ValueError: + continue + except OSError: + pass + return events + + +def _sigterm(_signo, _frame): + raise KeyboardInterrupt + + +def trap_signals() -> None: + """SIGTERM and SIGHUP (closed terminal, dropped ssh/WSL) unwind through the + `finally` that removes the hooks and kills agy. SIGKILL cannot be trapped; + refuse_existing_hooks names the leftover file then.""" + for sig in (signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, _sigterm) + + +# -------------------------------------------------------------------------- +# opencode backend: guard plugin outside the tree, external gate loop +# -------------------------------------------------------------------------- + +# No policy in here: every tool call is forwarded to `crew.py hook +# oc-pre`, and anything but an explicit allow (a crash, no answer) denies. +# A throw in tool.execute.before blocks the call and the model sees the text +# (lab E4, opencode 1.19.0). +OC_GUARD_JS = """// crew guard for opencode, generated for one run; loaded only through +// OPENCODE_CONFIG_CONTENT of that run. The policy is crew.py's. +import { spawnSync } from "node:child_process"; +const PY = __PY__, SCRIPT = __SCRIPT__, STATE = __STATE__; +function ask(event, payload) { + const p = spawnSync(PY, [SCRIPT, "hook", event, "--state", STATE], + { input: JSON.stringify(payload), encoding: "utf8", timeout: 120000 }); + try { return JSON.parse(p.stdout); } + catch { return { decision: "deny", reason: `guard sem resposta (rc=${p.status}) ${String(p.stderr || p.error || "").slice(0, 300)}` }; } +} +export const AgyWorkerGuard = async () => { + ask("start", {}); + return { + "tool.execute.before": async (input, output) => { + const d = ask("oc-pre", { tool: String(input.tool), args: output.args ?? {} }); + if (!d || d.decision !== "allow") throw new Error(`GUARD: ${(d && d.reason) || "negado"}`); + }, + }; +}; +""" + + +def write_oc_guard(rundir: str) -> str: + path = os.path.join(rundir, "opencode-guard.js") + js = (OC_GUARD_JS.replace("__PY__", json.dumps(sys.executable)) + .replace("__SCRIPT__", json.dumps(os.path.abspath(__file__))) + .replace("__STATE__", json.dumps(rundir))) + with open(path, "w", encoding="utf-8") as fh: + fh.write(js) + return path + + +def opencode_env(guard: str, disable_claude_code: bool) -> dict: + """The guard plugin and external_directory=deny come in through the + environment, so nothing is written into the tree or into ~/.config. + Shell denial lives in the plugin: `permission.bash: deny` makes the free + models fail at startup (403, lab). ~/.claude/CLAUDE.md is loaded unless + the task disables it, as for a native subagent.""" + env = {**os.environ, **WORKER_ENV, "OPENCODE_DISABLE_AUTOUPDATE": "1", "OPENCODE_DISABLE_SHARE": "1", + "OPENCODE_CONFIG_CONTENT": json.dumps({ + "plugin": [pathlib.Path(guard).as_uri()], + "permission": {"external_directory": "deny"}})} + if disable_claude_code: + env["OPENCODE_DISABLE_CLAUDE_CODE"] = "1" + return env + + +def preflight_opencode(model: str): + """(binary, warnings); refuses on a missing binary or model. Runs in an + empty temp dir: from the run dir (inside .git) opencode still found the + work tree and wrote "$schema" into its opencode.json (lab, 1.19.0).""" + oc = shutil.which("opencode") or os.path.expanduser("~/.opencode/bin/opencode") + if not os.access(oc, os.X_OK): + raise Refusal("opencode nao esta no PATH nem em ~/.opencode/bin") + with tempfile.TemporaryDirectory(prefix="crew-oc-") as cwd: + run = lambda *a: subprocess.run([oc, *a], capture_output=True, text=True, timeout=120, # noqa: E731 + cwd=cwd, stdin=subprocess.DEVNULL) + lines = run("--version").stdout.strip().splitlines() + proc = run("models") + version = lines[-1].strip() if lines else "?" + warns = [] + if version != TESTED_OPENCODE_VERSION: + warns.append(f"opencode {version}; este script so foi exercitado no {TESTED_OPENCODE_VERSION}. " + "Formato dos eventos, plugin e escrita do $schema podem ter mudado") + models = [ln.strip() for ln in proc.stdout.splitlines() if re.fullmatch(r"\S+/\S+", ln.strip())] + if proc.returncode != 0 or not models: + raise Refusal(f"`opencode models` falhou (rc={proc.returncode}): {tail(proc.stderr.strip(), 300)}") + if model not in models: + same = [m for m in models if m.split("/")[0] == model.split("/")[0]] + raise Refusal(f"modelo {model!r} nao existe para o opencode. Do mesmo provider: {', '.join(same) or '-'}") + return oc, warns + + +def snapshot_files(repo: str, names) -> dict: + snap = {} + for n in names: + try: + with open(os.path.join(repo, n), "rb") as fh: + snap[n] = fh.read() + except FileNotFoundError: + snap[n] = None + return snap + + +def restore_files(repo: str, snap: dict) -> set: + """Put back the exact bytes (or absence) of each file; returns what differed.""" + changed = set() + for n, data in snap.items(): + p = os.path.join(repo, n) + try: + with open(p, "rb") as fh: + cur = fh.read() + except FileNotFoundError: + cur = None + if cur == data: + continue + changed.add(n) + if data is None: + os.remove(p) + else: + with open(p, "wb") as fh: + fh.write(data) + return changed + + +def evaluate_oc(rc, stdout: str, stderr: str): + """(failures, info) for one `opencode run --format json`. It exits 0 on + soft denies and on a bogus --agent (lab), so success is read from events: + no error event, and the last step finished with reason=stop.""" + fails, events = [], [] + if rc == "wrapper-timeout": + fails.append("timeout: opencode nao terminou e foi encerrado") + elif rc != 0: + fails.append(f"opencode saiu com rc={rc}: {tail(stderr.strip(), 400)}") + for line in stdout.splitlines(): + try: + ev = json.loads(line) + except ValueError: + continue + if isinstance(ev, dict): + events.append(ev) + if not events: + fails.append("nenhum evento JSON no stdout") + for ev in events: + if ev.get("type") == "error": + err = ev.get("error") or {} + msg = (err.get("data") or {}).get("message") if isinstance(err, dict) else None + fails.append(f"evento error: {(msg or json.dumps(err))[:300]}") + finishes = [ev.get("part") or {} for ev in events if ev.get("type") == "step_finish"] + if events and (not finishes or finishes[-1].get("reason") != "stop"): + fails.append("o run nao terminou com reason=stop (ultimo step: " + f"{finishes[-1].get('reason') if finishes else 'nenhum'})") + texts = [(ev.get("part") or {}).get("text", "") for ev in events if ev.get("type") == "text"] + tokens = {} + for f in finishes: + for k, v in (f.get("tokens") or {}).items(): + if isinstance(v, (int, float)): + tokens[k] = tokens.get(k, 0) + v + info = {"session": next((ev["sessionID"] for ev in events if ev.get("sessionID")), None), + "text": texts[-1] if texts else "", "steps": len(finishes), "tokens": tokens, + "cost": sum(f.get("cost") or 0 for f in finishes), + "tool_errors": sum(1 for ev in events if ev.get("type") == "tool_use" + and ((ev.get("part") or {}).get("state") or {}).get("status") == "error")} + return fails, info + + +def extract_report(text: str): + """The last top-level JSON object in the worker's final message, or None.""" + dec, found, pos = json.JSONDecoder(), None, 0 + text = text or "" + while (i := text.find("{", pos)) != -1: + try: + obj, end = dec.raw_decode(text, i) + except ValueError: + pos = i + 1 + continue + if isinstance(obj, dict): + found = obj + pos = end + return found + + +def report_problems(report, schema: dict) -> list: + if not isinstance(report, dict): + return ["relatorio ausente: a ultima mensagem nao traz um objeto JSON"] + missing = [k for k in schema.get("required", []) if k not in report] + return [f"relatorio sem campos obrigatorios: {missing}"] if missing else [] + + +def oc_message(text: str) -> str: + return "Task:\n" + text if text.lstrip().startswith("-") else text # never parsed as a flag + + +def drive_opencode(oc: str, task: dict, repo: str, rundir: str, prompt: str, ctx: dict, saved: dict): + """run -> gate -> `run -s ` with the gate output, up to + max_gate_attempts runs. opencode has no in-process stop hook (a plugin + prompting on session.idle did not continue `run`, lab E5), so the loop is + here. Returns (failures, warnings, report, info).""" + env = opencode_env(write_oc_guard(rundir), task["disable_claude_code"]) + env[RUN_ENV] = ctx.get("run_id", "") + deadline = time.time() + task["timeout"] + fails, warns, runs, restored = [], [], [], set() + sid, report, msg, n = None, None, prompt, task["max_gate_attempts"] + try: + for i in range(1, n + 1): + left = int(deadline - time.time()) + if left < 30: + fails.append("timeout da task esgotado antes de o gate e o relatorio ficarem prontos") + break + cmd = [oc, "run", "--dir", repo, "--format", "json", "-m", task["model"], + *(["-s", sid] if sid else []), oc_message(msg)] + eprint(f"crew: opencode run {i}/{n} ({task['model']}{', sessao ' + sid if sid else ''})…") + rc, out, err = run_agy(cmd, repo, rundir, left, name=f"opencode-{i}", env=env, grace=0) + restored |= restore_files(repo, saved) + run_fails, info = evaluate_oc(rc, out, err) + sid = sid or info["session"] + runs.append({"attempt": i, "rc": rc, **{k: v for k, v in info.items() if k != "text"}}) + if run_fails: + fails += [f"run {i}: {f}" for f in run_fails] + break + report = extract_report(info["text"]) + passed, text, _ = gate_cached(rundir, ctx, f"driver:{i}") + problems = report_problems(report, task["report_schema"]) + if passed and not problems: + break + if not sid: + fails.append("nenhum sessionID nos eventos: nao ha como retomar") + break + parts = [] + if not passed: + parts.append(f"The gate FAILED (attempt {i} of {n}); the task is not done. Fix the real cause, " + "without disabling tests, without forbidden shortcuts and without leaving the " + f"allowed files. Gate output:\n{tail(text)}") + if problems: + parts.append("Your final message must end with the report as ONE JSON object matching the " + f"schema given before ({'; '.join(problems)}).") + msg = "\n\n".join(parts) + finally: + restored |= restore_files(repo, saved) + if restored: + warns.append(f"o opencode reescreveu {sorted(restored)} (o \"$schema\"); bytes originais restaurados") + if not fails: + fails += report_problems(report, task["report_schema"]) + if isinstance(report, dict) and report.get("status") in ("partial", "blocked"): + fails.append(f"o worker se declarou {report['status']!r}") + total = {} + for r in runs: + for k, v in r.get("tokens", {}).items(): + total[k] = total.get(k, 0) + v + return fails, warns, report, {"session": sid, "runs": runs, "tokens": total, + "cost": sum(r.get("cost") or 0 for r in runs)} + + +# -------------------------------------------------------------------------- +# GitHub Copilot CLI backend: repo hooks for one run, gate on agentStop +# -------------------------------------------------------------------------- + +def cp_hook_rel(run_id: str) -> str: + return f"{COPILOT_HOOK_DIR}/{COPILOT_HOOK_PREFIX}{run_id}.json" + + +def refuse_cp_leftovers(repo: str) -> list: + """Refuses on a hooks file left by a dead run (COPILOT_ALLOW_ALL=true + would load it, pointing at a dead run dir); returns the repo's own hook + files, which load too and run with the same trust.""" + d = os.path.join(repo, COPILOT_HOOK_DIR) + names = sorted(os.listdir(d)) if os.path.isdir(d) else [] + ours = [n for n in names if n.startswith((COPILOT_HOOK_PREFIX, "agy-worker-")) and n.endswith(".json")] + if ours: + paths = " ".join(shlex.quote(os.path.join(d, n)) for n in ours) + raise Refusal(f"sobra de um run do crew em {COPILOT_HOOK_DIR}/ ({', '.join(ours)}), de um run " + "que morreu sem limpar ou ainda em curso neste repo. Se nenhum `copilot` estiver " + f"rodando aqui, remova so esses arquivos: rm {paths}") + return [f"{COPILOT_HOOK_DIR}/{n}" for n in names if n.endswith(".json")] + + +def install_cp_hooks(repo: str, rundir: str, ctx: dict) -> tuple: + """Write .github/hooks/crew-.json. Returns (relative path, + directories created by us, deepest first), for remove_cp_hooks.""" + rel = cp_hook_rel(ctx["run_id"]) + path = os.path.join(repo, rel) + if os.path.lexists(path): + raise Refusal(f"{rel} ja existe; este script nao sobrescreve arquivo de hooks") + created, d = [], os.path.dirname(path) + while not os.path.isdir(d): + created.append(d) + d = os.path.dirname(d) + os.makedirs(os.path.dirname(path), exist_ok=True) + base = f"{shlex.quote(sys.executable)} {shlex.quote(os.path.abspath(__file__))} hook" + st = shlex.quote(rundir) + gate_secs = ctx.get("gate_timeout", DEFAULT_GATE_TIMEOUT) * max( + 1, len(ctx["gate"]["format"]) + len(ctx["gate"]["checks"])) + 60 + # Hook timeouts fail OPEN in Copilot: preToolUse gets a generous 30 s for + # a handler that answers in well under one, and agentStop the whole gate. + spec = {"version": 1, "hooks": { + "sessionStart": [{"type": "command", "bash": f"{base} start --state {st}", "cwd": ".", + "timeoutSec": 10}], + "preToolUse": [{"type": "command", "bash": f"{base} cp-pre --state {st}", "cwd": ".", + "timeoutSec": 30}], + "agentStop": [{"type": "command", "bash": f"{base} cp-stop --state {st}", "cwd": ".", + "timeoutSec": gate_secs}], + }} + with open(path, "x", encoding="utf-8") as fh: + json.dump(spec, fh, indent=2) + write_json(os.path.join(rundir, "hooks.installed.json"), spec) + return rel, created + + +def remove_cp_hooks(repo: str, rel: str, created) -> None: + try: + os.remove(os.path.join(repo, rel)) + except FileNotFoundError: + pass + for d in created: # deepest first; only if empty, never rm -r + try: + os.rmdir(d) + except OSError: + break + + +def file_sha(path: str): + try: + with open(path, "rb") as fh: + return hashlib.sha256(fh.read()).hexdigest() + except OSError: + return None + + +def preflight_copilot() -> tuple: + """(binary, warnings). No model listing exists to check against without a + model call, so the model rule lives in copilot_model_problem.""" + cp = shutil.which("copilot") + if not cp: + raise Refusal("copilot nao esta no PATH") + with tempfile.TemporaryDirectory(prefix="crew-cp-") as cwd: + proc = subprocess.run([cp, "--version"], capture_output=True, text=True, timeout=60, cwd=cwd, + stdin=subprocess.DEVNULL) + m = re.search(r"(\d+\.\d+\.\d+)", proc.stdout) + version = m.group(1) if m else "?" + warns = [] + if version != TESTED_COPILOT_VERSION: + warns.append(f"copilot {version}; este script so foi exercitado no {TESTED_COPILOT_VERSION}. Carga " + "dos hooks, nomes das ferramentas e formato dos eventos podem ter mudado") + return cp, warns + + +def copilot_cmd(cp: str, prompt: str, model: str, rundir: str, excluded, max_credits) -> list: + return [cp, "-p", oc_message(prompt), "--model", model, "--output-format", "json", + "--allow-all-tools", "--excluded-tools", *excluded, "--disallow-temp-dir", "--no-ask-user", + "--no-auto-update", "--disable-builtin-mcps", "--max-ai-credits", str(max_credits), + "--usage-output-file", os.path.join(rundir, "usage.json"), + "--log-dir", os.path.join(rundir, "copilot-logs")] + + +def copilot_env(run_id: str) -> dict: + """COPILOT_ALLOW_ALL must be exactly "true": that is what trusts the cwd + and loads its hooks (lab: --allow-all-tools and trustedFolders do not). + It also loads the repo's own skills, plugins and MCP servers. COPILOT_HOME + stays the user's: isolating it lost the login in the lab.""" + return {**os.environ, **WORKER_ENV, "COPILOT_ALLOW_ALL": "true", "COPILOT_AUTO_UPDATE": "false", + "NO_COLOR": "1", RUN_ENV: run_id} + + +def evaluate_cp(rc, stdout: str, stderr: str): + """(failures, info) for one `copilot -p --output-format json`. Exit 0 came + with failed work in the lab, so success is read from the events: a + `result` with exitCode 0, no shell or subagent call, no error event.""" + fails, events = [], [] + if rc == "wrapper-timeout": + fails.append("timeout: copilot nao terminou e foi encerrado") + elif rc != 0: + fails.append(f"copilot saiu com rc={rc}: {tail(stderr.strip(), 400)}") + for line in stdout.splitlines(): + try: + ev = json.loads(line) + except ValueError: + continue + if isinstance(ev, dict): + events.append(ev) + if not events: + fails.append("nenhum evento JSON no stdout") + results = [ev for ev in events if ev.get("type") == "result"] + if events and not results: + fails.append("nenhum evento result: o run foi cortado") + elif results and results[-1].get("exitCode") != 0: + fails.append(f"evento result com exitCode={results[-1].get('exitCode')!r}") + tools, failed = {}, 0 + for ev in events: + data = ev.get("data") or {} + if ev.get("type") == "tool.execution_start": + name = str(data.get("toolName", "")) + tools[name] = tools.get(name, 0) + 1 + elif ev.get("type") == "tool.execution_complete" and data.get("success") is False: + failed += 1 + elif str(ev.get("type", "")).endswith("error"): + fails.append(f"evento {ev.get('type')}: {json.dumps(data)[:300]}") + shell = sorted(t for t in tools if t in COPILOT_SHELL_TOOLS) + if shell: + fails.append(f"chamada de shell observada ({shell}), apesar de --excluded-tools: o worker nao devia " + "ter shell") + if "task" in tools: + fails.append(f"subagente observado ({tools['task']} chamadas de task), apesar de --excluded-tools") + usage = (results[-1].get("usage") if results else None) or {} + info = {"session": next((ev.get("sessionId") for ev in results if ev.get("sessionId")), None), + "text": cp_final_text(stdout) or "", "tools": tools, "tool_failures": failed, + "premium_requests": usage.get("premiumRequests")} + return fails, info + + +def read_cp_usage(path: str): + """Credits and served model from --usage-output-file, or None. 1 AI credit + = 1e9 nanoAIU is inferred from the CLI footer, not documented.""" + u = read_json(path) + if not isinstance(u, dict): + return None + nano = u.get("totalNanoAiu") + return {"ai_credits": round(nano / 1e9, 4) if isinstance(nano, (int, float)) else None, + "premium_requests": u.get("totalPremiumRequestCost"), + "served_models": sorted((u.get("modelMetrics") or {}).keys()), + "current_model": u.get("currentModel"), "ai_credits_status": u.get("aiCreditsStatus"), + "api_duration_ms": u.get("totalApiDurationMs")} + + +def run_copilot(cp: str, prompt: str, model: str, repo: str, rundir: str, ctx: dict, timeout_s: int, + excluded, max_credits): + """Install the run's hooks, run copilot, always remove them. Returns + (rc, stdout, stderr, failures about the hooks file).""" + rel, created = install_cp_hooks(repo, rundir, ctx) + sha = file_sha(os.path.join(repo, rel)) + fails = [] + try: + rc, stdout, stderr = run_agy(copilot_cmd(cp, prompt, model, rundir, excluded, max_credits), repo, + rundir, timeout_s, name="copilot", env=copilot_env(ctx["run_id"]), + grace=30) + finally: + if file_sha(os.path.join(repo, rel)) != sha: + fails.append(f"o arquivo de hooks do run ({rel}) mudou durante o run: o guard pode ter sido trocado") + remove_cp_hooks(repo, rel, created) + return rc, stdout, stderr, fails + + +# -------------------------------------------------------------------------- +# subcommands +# -------------------------------------------------------------------------- + +def base_ctx(repo, task, mode): + return { + "mode": mode, "repo": repo, "scope": task["scope"], "side_effects": task["side_effects"], + "allow_paths": task["allow_paths"], "gate": task["gate"], + "secret_globs": task.get("secret_globs", DEFAULT_SECRET_GLOBS), + "gate_timeout": task["gate_timeout"], "max_gate_attempts": task["max_gate_attempts"], + "gate_commands": task["gate"]["format"] + task["gate"]["checks"], + "deny_patterns": DENY_COMMANDS + (OPENCODE_DENY_COMMANDS if task.get("backend") == "opencode" else []) + + [(p, "regra do projeto") for p in task["deny_commands"]], + } + + +def cmd_delegate(a) -> int: + trap_signals() + task = load_task(a.task, backend=a.backend, model=a.model) + opencode = task["backend"] == "opencode" + copilot = task["backend"] == "copilot" + repo = repo_root(a.repo or os.getcwd()) + git(repo, "rev-parse", "--verify", "HEAD") + other_cp_hooks = refuse_cp_leftovers(repo) if copilot else [] + dirty = changed_paths(repo) + if dirty: + raise Refusal("arvore suja; commite ou guarde antes (o diff do worker tem que ser so dele): " + + ", ".join(dirty[:10])) + if task["backend"] == "agy": + refuse_existing_hooks(repo) + rundir = new_rundir(repo, "delegate") + eprint(f"crew: run dir {rundir}") + if opencode: + saved = snapshot_files(repo, OPENCODE_CONFIG_FILES) # before any opencode process + try: + oc, pre_warns = preflight_opencode(task["model"]) + finally: + if restore_files(repo, saved): + eprint("crew: o preflight do opencode reescreveu opencode.json; restaurado") + elif copilot: + cp, pre_warns = preflight_copilot() + if other_cp_hooks: + pre_warns.append(f"hooks do proprio repo em {COPILOT_HOOK_DIR}/ carregam junto com os do run, com " + f"a mesma confianca (COPILOT_ALLOW_ALL=true): {other_cp_hooks}") + else: + pre_warns = preflight_agy(task["model"], task["min_quota"], not a.no_quota_probe, rundir) + ctx = base_ctx(repo, task, "delegate") + ctx["run_id"] = os.path.basename(rundir) + if copilot: + ctx.update(ignore_paths=[cp_hook_rel(ctx["run_id"])], report_schema=task["report_schema"], + events_file=os.path.join(rundir, "copilot.stdout")) + write_json(os.path.join(rundir, "ctx.json"), ctx) + write_json(os.path.join(rundir, "task.json"), task) + schema_path = os.path.join(rundir, "report.schema.json") + write_json(schema_path, task["report_schema"]) + prompt = compose_prompt(task, repo) + with open(os.path.join(rundir, "prompt.md"), "w", encoding="utf-8") as fh: + fh.write(prompt) + head = git(repo, "rev-parse", "HEAD").strip() + integ_before, ignored_before = integrity_state(repo, rundir), ignored_state(repo) + started = time.time() + if opencode: + fails, warns, report, run_info = drive_opencode(oc, task, repo, rundir, prompt, ctx, saved) + run_info["wall_seconds"] = round(time.time() - started) + elif copilot: + eprint(f"crew: copilot rodando ({task['model']}, timeout {task['timeout']}s)…") + rc, stdout, stderr, fails = run_copilot(cp, prompt, task["model"], repo, rundir, ctx, task["timeout"], + COPILOT_EXCLUDED_TOOLS, task["max_ai_credits"]) + run_fails, info = evaluate_cp(rc, stdout, stderr) + fails += run_fails + report = extract_report(info.pop("text")) + fails += report_problems(report, task["report_schema"]) + if isinstance(report, dict) and report.get("status") in ("partial", "blocked"): + fails.append(f"o worker se declarou {report['status']!r}") + warns = [] + usage = read_cp_usage(os.path.join(rundir, "usage.json")) + if usage is None: + warns.append("usage.json do copilot ausente ou ilegivel: creditos gastos desconhecidos") + run_info = {"rc": rc, "wall_seconds": round(time.time() - started), **info, "usage": usage} + else: + cmd = ["agy", "-p", prompt, "--model", task["model"], "--output-format", "json", + "--json-schema", schema_path, "--print-timeout", f"{task['timeout']}s", + "--dangerously-skip-permissions"] + created = install_hooks(repo, rundir, ctx) + watcher = BackgroundWatcher(rundir, ctx["run_id"]) + try: + eprint(f"crew: agy rodando ({task['model']}, timeout {task['timeout']}s)…") + rc, stdout, stderr = run_agy(cmd, repo, rundir, task["timeout"], + env={**os.environ, **WORKER_ENV, RUN_ENV: ctx["run_id"]}, + watch=watcher.poll) + finally: + remove_hooks(repo, created) + fails, warns, env = evaluate_run(rc, stdout, stderr, "delegate", task["report_schema"]) + report = env.get("structured_output") if isinstance(env, dict) else None + run_info = {"rc": rc, "wall_seconds": round(time.time() - started), **( + {k: env.get(k) for k in ("status", "duration_seconds", "num_turns", "usage", "conversation_id")} + if isinstance(env, dict) else {})} + run_info["background_killed"] = {str(p): c for p, c in watcher.killed.items()} + if watcher.killed: + warns.append(f"{len(watcher.killed)} processo(s) em segundo plano do worker encerrado(s) " + f"{BG_GRACE_S} s depois de ele terminar com gate verde, para o agy poder sair: " + + "; ".join(f"{p} {c}" for p, c in watcher.killed.items())) + if watcher.errors: + warns.append(f"o vigia de processos em segundo plano falhou: {watcher.errors}") + warns = pre_warns + warns + tampered = diff_state(integ_before, integrity_state(repo, rundir)) + ignore = ctx.get("ignore_paths", []) + if tampered: + fails.append("config, hooks ou flags de indice do git, ou o ctx.json do run, mudaram durante o " + f"run; o veredito abaixo nao e confiavel: {tampered}") + events = guard_events(rundir) + if copilot: + # Any of our hooks answering proves the file loaded; sessionStart alone + # was never observed firing in the lab. + if not any(e.get("event") in ("session_start", "pre", "stop") for e in events): + fails.append("os hooks do crew nao carregaram no copilot (nenhum evento de hook " + "registrado): o run nao teve guard rails; trate o diff como nao confiavel") + elif not any(e.get("event") == "session_start" for e in events): + fails.append(("o plugin de guard do opencode nao carregou" if opencode else + "os hooks do crew nao carregaram") + " (nenhum SessionStart registrado): " + "o run nao teve guard rails; trate o diff como nao confiavel") + if git(repo, "rev-parse", "HEAD").strip() != head: + fails.append("HEAD mudou durante o run (commit ou checkout passou pelo guard)") + # The gate verdict that counts is the one for the tree as it is now. + st = read_json(os.path.join(rundir, "gate-state.json"), {"attempts": 0}) + fp = fingerprint(repo, task["side_effects"], ignore) + if st.get("fingerprint") == fp and st.get("passed") is True: + passed, text, source = True, st["text"], "cached (estado identico ao ultimo gate verde do hook)" + else: + passed, text, _ = run_gate(repo, ctx) + with open(os.path.join(rundir, "gate-final.log"), "w", encoding="utf-8") as fh: + fh.write(text + "\n") + source = f"final (rodado apos o {task['backend']})" + if not passed: + fails.append("gate falhou no estado final da arvore") + cls = classify(changed_paths(repo, ignore), task["scope"], task["side_effects"]) + if cls["out_of_scope"]: + fails.append(f"alteracoes fora do escopo: {cls['out_of_scope']}") + if cls["side_effects"]: + warns.append(f"efeitos colaterais tolerados (revise e reverta se for o caso): {cls['side_effects']}") + denials = [e for e in events if e.get("event") == "pre" and e.get("decision") != "allow"] + if denials: + warns.append(f"{len(denials)} chamada(s) negada(s) pelos guards; veja guard_denials") + ignored = diff_state(ignored_before, ignored_state(repo)) + if ignored: + warns.append("arquivos ignorados pelo git criados ou alterados durante o run (o gate pode ter " + f"escrito alguns; o resto e escrita fora do escopo que o git status nao mostra): {ignored}") + stopped = stop_run_procs(ctx["run_id"]) + if stopped: + warns.append("processos deixados pelo run encerrados (traziam a marca do run): " + + "; ".join(f"{p} {c}" for p, c in stopped.items())) + # By marker, not by cwd: a cwd scan also listed the orchestrating Claude + # session's own `sleep 2` wait loop (false positive, 2026-10-13). + leftover = marked_procs(ctx["run_id"]) + if leftover: + warns.append("processos com a marca do run ainda vivos depois de SIGTERM e SIGKILL: " + + "; ".join(f"{p} {c}" for p, c in leftover.items())) + verdict = { + "ok": not fails, "mode": "delegate", "backend": task["backend"], "run_dir": rundir, + "model": task["model"], + "failures": fails, "warnings": warns, "changed": cls, + "gate": {"passed": passed, "source": source, "hook_attempts": st.get("attempts", 0), + "tail": tail(text, 2500)}, + "guard_denials": [{k: e.get(k) for k in ("tool", "reason", "args")} for e in denials[-20:]], + "stop_events": [e for e in events if e.get("event") == "stop"], + "report": report, + task["backend"]: run_info, + "next": "Nada foi commitado. Revise `git diff HEAD` contra a fonte da verdade antes de aceitar; " + "o gate e necessario, nao suficiente.", + } + write_json(os.path.join(rundir, "verdict.json"), verdict) + print(json.dumps(verdict, ensure_ascii=False, indent=2)) + eprint(f"crew: {'OK' if verdict['ok'] else 'FALHOU'} — {len(fails)} falha(s), " + f"{len(warns)} aviso(s); verdict em {rundir}/verdict.json") + return 0 if verdict["ok"] else 1 + + +def cmd_review(a) -> int: + trap_signals() + repo = repo_root(a.repo or os.getcwd()) + question = " ".join(a.prompt).strip() + if a.prompt_file: + with open(a.prompt_file, encoding="utf-8") as fh: + question = (fh.read() + "\n" + question).strip() + if not question: + raise Refusal("review sem pergunta: passe o texto ou --prompt-file") + schema = DEFAULT_REVIEW_SCHEMA + if a.schema: + schema = read_json(a.schema) + if not (isinstance(schema, dict) and schema.get("type") == "object"): + raise Refusal(f"{a.schema}: schema precisa de raiz type=object") + opencode, copilot = a.backend == "opencode", a.backend == "copilot" + model = a.model or {"opencode": OPENCODE_DEFAULT_MODEL, "copilot": COPILOT_DEFAULT_MODEL}.get( + a.backend, DEFAULT_MODEL) + if copilot: + problem = copilot_model_problem(model, a.copilot_model_override) + if problem: + raise Refusal(problem) + other_cp_hooks = refuse_cp_leftovers(repo) + timeout_s = parse_duration(a.timeout) + diff_txt = "" + if a.diff: + diff_txt = git(repo, "diff", "--no-ext-diff", a.diff) + if not diff_txt.strip(): + raise Refusal(f"git diff {a.diff} vazio") + if len(diff_txt.encode()) > MAX_DIFF_BYTES: + diff_txt = diff_txt.encode()[:MAX_DIFF_BYTES].decode(errors="ignore") + \ + "\n…(diff cortado; leia os arquivos para o resto)…" + rundir = new_rundir(repo, "review") + eprint(f"crew: run dir {rundir}") + if opencode: + saved = snapshot_files(repo, OPENCODE_CONFIG_FILES) # before any opencode process + try: + oc, pre_warns = preflight_opencode(model) + finally: + if restore_files(repo, saved): + eprint("crew: o preflight do opencode reescreveu opencode.json; restaurado") + elif copilot: + cp, pre_warns = preflight_copilot() + if other_cp_hooks: + pre_warns.append(f"hooks do proprio repo em {COPILOT_HOOK_DIR}/ carregam junto: {other_cp_hooks}") + else: + pre_warns = preflight_agy(model, a.min_quota, not a.no_quota_probe, rundir) + prompt = question + if diff_txt: + prompt += f"\n\n## Diff under review (`git diff {a.diff}`)\n```diff\n{diff_txt}\n```" + prompt += ("\n\n---\nYou are a read-only reviewer. Do not modify files and do not run commands; " + f"read files under `{repo}` as needed. Every finding must quote the exact code or text " + "that supports it in `evidence`. Prefer `unsure` over a guess.\n") + if copilot: + prompt += "You have no shell and no editing tools; use only the read and search tools.\n" + prompt += report_rule({"backend": a.backend, "report_schema": schema}) + with open(os.path.join(rundir, "prompt.md"), "w", encoding="utf-8") as fh: + fh.write(prompt) + # opencode has no plan mode: scope [] denies every write, and every shell + # command is denied, both by the guard plugin. + ctx = {"mode": "review", "repo": repo, "scope": [], "side_effects": [], "allow_paths": [], + "secret_globs": DEFAULT_SECRET_GLOBS, + "gate": {"format": [], "checks": [], "forbid": []}, "gate_timeout": 60, + "max_gate_attempts": 0, "gate_commands": [], + "deny_patterns": REVIEW_DENY_ALL if opencode or copilot else DENY_COMMANDS, + "run_id": os.path.basename(rundir)} + if copilot: + ctx["ignore_paths"] = [cp_hook_rel(ctx["run_id"])] + write_json(os.path.join(rundir, "ctx.json"), ctx) + schema_path = os.path.join(rundir, "review.schema.json") + write_json(schema_path, schema) + fp_before, ignored_before = fingerprint(repo, []), ignored_state(repo) + integ_before = integrity_state(repo, rundir) + if opencode: + env_oc = opencode_env(write_oc_guard(rundir), False) + try: + eprint(f"crew: review rodando (opencode, {model})…") + rc, stdout, stderr = run_agy([oc, "run", "--dir", repo, "--format", "json", "-m", model, + oc_message(prompt)], repo, rundir, timeout_s, + name="opencode-1", env=env_oc, grace=0) + finally: + restored = restore_files(repo, saved) + fails, info = evaluate_oc(rc, stdout, stderr) + report = extract_report(info.pop("text")) + fails += report_problems(report, schema) + warns = pre_warns + ([f"o opencode reescreveu {sorted(restored)}; bytes restaurados"] if restored else []) + run_info = {"rc": rc, **info} + elif copilot: + eprint(f"crew: review rodando (copilot, {model}, sem shell nem apply_patch)…") + rc, stdout, stderr, fails = run_copilot(cp, prompt, model, repo, rundir, ctx, timeout_s, + COPILOT_REVIEW_EXCLUDED_TOOLS, COPILOT_MIN_AI_CREDITS) + run_fails, info = evaluate_cp(rc, stdout, stderr) + fails += run_fails + report = extract_report(info.pop("text")) + fails += report_problems(report, schema) + usage = read_cp_usage(os.path.join(rundir, "usage.json")) + warns = pre_warns + ([] if usage else ["usage.json do copilot ausente: creditos gastos desconhecidos"]) + run_info = {"rc": rc, **info, "usage": usage} + else: + cmd = ["agy", "-p", prompt, "--model", model, "--mode", "plan", "--output-format", "json", + "--json-schema", schema_path, "--print-timeout", f"{timeout_s}s"] + created = install_hooks(repo, rundir, ctx) + try: + eprint(f"crew: review rodando ({model}, --mode plan)…") + rc, stdout, stderr = run_agy(cmd, repo, rundir, timeout_s) + finally: + remove_hooks(repo, created) + fails, warns, env = evaluate_run(rc, stdout, stderr, "review", schema) + warns = pre_warns + warns + report = env.get("structured_output") if isinstance(env, dict) else None + run_info = {"rc": rc, **({k: env.get(k) for k in ("status", "duration_seconds", "num_turns", + "usage")} if isinstance(env, dict) else {})} + if (fingerprint(repo, []) != fp_before or ignored_state(repo) != ignored_before + or integrity_state(repo, rundir) != integ_before): + fails.append("a arvore, um arquivo ignorado ou o .git mudou durante um review que devia ser " + "somente leitura") + if copilot: + if not any(e.get("event") in ("session_start", "pre", "stop") for e in guard_events(rundir)): + fails.append("os hooks do crew nao carregaram no copilot (nenhum evento de hook): o " + "review rodou sem nada que negasse escrita") + elif not any(e.get("event") == "session_start" for e in guard_events(rundir)): + if opencode: + fails.append("o plugin de guard do opencode nao carregou (nenhum SessionStart): o review " + "rodou sem nada que o mantivesse somente leitura") + else: + warns.append("hooks nao carregaram (nenhum SessionStart): o review rodou so com --mode plan") + verdict = {"ok": not fails, "mode": "review", "backend": a.backend, "run_dir": rundir, "model": model, + "failures": fails, "warnings": warns, "report": report, a.backend: run_info, + "next": "Segunda opiniao de um modelo mais barato: confira cada evidence no codigo antes de agir."} + write_json(os.path.join(rundir, "verdict.json"), verdict) + print(json.dumps(verdict, ensure_ascii=False, indent=2)) + return 0 if verdict["ok"] else 1 + + +def cmd_gate(a) -> int: + task = load_task(a.task) + repo = repo_root(a.repo or os.getcwd()) + ctx = base_ctx(repo, task, "delegate") + passed, text, _ = run_gate(repo, ctx, baseline=a.baseline) + print(text) + return 0 if passed else 1 + + +# -------------------------------------------------------------------------- +# self-test +# -------------------------------------------------------------------------- + +def self_test() -> int: + n = 0 + + def check(cond, msg): + nonlocal n + n += 1 + if not cond: + raise AssertionError(msg) + + tmp = tempfile.mkdtemp(prefix="crew-selftest-") + try: + repo = os.path.realpath(os.path.join(tmp, "repo")) + os.makedirs(os.path.join(repo, "src")) + g = ["git", "-C", repo, "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"] + subprocess.run(["git", "init", "-q", repo], check=True) + with open(os.path.join(repo, "src", "a.txt"), "w") as fh: + fh.write("one\n") + with open(os.path.join(repo, "side.json"), "w") as fh: + fh.write("{}\n") + subprocess.run(g + ["add", "-A"], check=True) + subprocess.run(g + ["commit", "-q", "-m", "init"], check=True) + + # example task shipped with the skill must validate + example = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "shared", + "task.example.json") + t = load_task(example) + check(t["scope"] and t["gate"]["checks"], "task.example.json carrega") + check("Operating rules" in compose_prompt(t, repo), "prompt composto tem as regras") + + task = normalize_task({"prompt": "x", "scope": ["src/*"], "side_effects": ["side.json"], + "gate": {"format": ["ls -- {files} >/dev/null"], "checks": ["true"], + "forbid": [r"@ts-ignore"]}}) + for bad, why in ((dict(prompt="", scope=["a"], gate={"checks": ["true"]}), "prompt vazio"), + (dict(prompt="x", scope=[], gate={"checks": ["true"]}), "scope vazio"), + (dict(prompt="x", scope=["../a"], gate={"checks": ["true"]}), "scope com .."), + (dict(prompt="x", scope=["a"], gate={}), "gate sem comandos"), + (dict(prompt="x", scope=["a"], gate={"checks": ["t"]}, + report_schema={"type": "string"}), "schema sem raiz object")): + try: + normalize_task(bad) + check(False, f"task invalida aceita: {why}") + except Refusal: + check(True, why) + + ctx = base_ctx(repo, task, "delegate") + pre = lambda name, **args: decide_pre({"toolCall": {"name": name, "args": args}}, ctx) # noqa: E731 + check(pre("view_file", AbsolutePath=f"{repo}/src/a.txt")["decision"] == "allow", "leitura no repo") + check(pre("view_file", AbsolutePath="/etc/passwd")["decision"] == "deny", "leitura fora do repo") + check(pre("list_dir", DirectoryPath="~/.claude")["decision"] == "deny", "home negado") + check(pre("run_command", CommandLine="cat /tmp/x")["decision"] == "deny", "/tmp negado") + check(pre("run_command", CommandLine="ls ..")["decision"] == "deny", ".. negado") + check(pre("run_command", CommandLine="ps aux")["decision"] == "deny", "ps negado") + check(pre("run_command", CommandLine="git commit -m x")["decision"] == "deny", "commit negado") + check(pre("run_command", CommandLine="npm install left-pad")["decision"] == "deny", "install negado") + check(pre("run_command", CommandLine="sed -i s/a/b/ src/a.txt")["decision"] == "deny", "sed -i negado") + check(pre("run_command", CommandLine="echo $HOME")["decision"] == "deny", "$HOME negado") + check(pre("run_command", CommandLine="npm test 2>&1 > /dev/null")["decision"] == "allow", "npm test") + check(pre("run_command", CommandLine="git diff HEAD")["decision"] == "allow", "git diff permitido") + check(pre("run_command", CommandLine="echo $((4 / 2))")["decision"] == "allow", "divisao nao e path") + check(pre("run_command", CommandLine="sed -n 1,5p src/a.txt")["decision"] == "allow", "sed -n") + # secrets: by tool and by shell, .env.example still readable, [] turns it off + check(pre("view_file", AbsolutePath=f"{repo}/.env")["decision"] == "deny", "segredo: view .env") + check(pre("view_file", AbsolutePath=f"{repo}/apps/web/.dev.vars")["decision"] == "deny", + "segredo: .dev.vars") + check(pre("view_file", AbsolutePath=f"{repo}/.env.example")["decision"] == "allow", + "segredo: .env.example liberado") + check(pre("run_command", CommandLine="cat .env")["decision"] == "deny", "segredo: cat .env") + check(pre("run_command", CommandLine="grep DATABASE apps/api/.env.production")["decision"] + == "deny", "segredo: grep em .env.production") + check(pre("run_command", CommandLine="cat certs/server.pem")["decision"] == "deny", "segredo: pem") + check(pre("run_command", CommandLine="node scripts/env-check.js")["decision"] == "allow", + "segredo: nome parecido nao e segredo") + nosec = dict(ctx, secret_globs=[]) + check(decide_pre({"toolCall": {"name": "view_file", "args": {"AbsolutePath": f"{repo}/.env"}}}, + nosec)["decision"] == "allow", "segredo: lista vazia desliga") + # WaitMsBeforeAsync is no longer overwritten: it did not keep the command in the foreground + check(pre("run_command", CommandLine="npm test") == {"decision": "allow"}, "run_command sem overwrite") + check("overwrite" not in pre("view_file", AbsolutePath=f"{repo}/src/a.txt"), "sem overwrite fora do shell") + check(pre("write_to_file", TargetFile=f"{repo}/src/b.txt", CodeContent="/etc/x")["decision"] + == "allow", "escrita no escopo; conteudo nao e escaneado") + check(pre("write_to_file", TargetFile=f"{repo}/side.json")["decision"] == "deny", "fora do escopo") + check(pre("replace_file_content", TargetFile="src/../.git/config")["decision"] == "deny", ".git") + check(pre("write_to_file", TargetFile="/tmp/out.txt")["decision"] == "deny", "escrita fora do repo") + check(pre("invoke_subagent", Task="x")["decision"] == "deny", "subagente negado") + check(pre("finish", files_changed=["/etc/passwd"])["decision"] == "allow", "finish nao e escaneado") + check(pre("grep_search", SearchPath=repo, Query="/api/v1")["decision"] == "allow", "Query ignorada") + # tool allowlist: network, workflows, MCP and unknown tools are denied + for tool in ("read_url_content", "search_web", "run_workflow", "manage_task", "mcp_foo_bar", + "ask_permission", "send_message"): + check(pre(tool, Url="https://example.com/a")["decision"] == "deny", f"{tool} negado") + # relative escapes in non-shell tools, Cwd, cd, and literal forms + for args, why in ((("list_dir", {"DirectoryPath": "../.."}), "list_dir .."), + (("view_file", {"AbsolutePath": "../../etc/passwd"}), "view_file .."), + (("view_file", {"AbsolutePath": "//etc/passwd"}), "view_file //"), + (("grep_search", {"SearchPath": "..", "Query": "x"}), "grep_search .."), + (("find_by_name", {"SearchDirectory": "../.."}), "find_by_name .."), + (("run_command", {"CommandLine": "ls", "Cwd": "/"}), "Cwd /"), + (("run_command", {"CommandLine": "ls", "Cwd": "../../../etc"}), "Cwd .."), + (("run_command", {"CommandLine": "cd ..&&ls"}), "cd ..&&"), + (("run_command", {"CommandLine": "cd / && cat etc/passwd"}), "cd /"), + (("run_command", {"CommandLine": "cd && ls"}), "cd sozinho"), + (("run_command", {"CommandLine": "cat //etc/passwd"}), "//etc"), + (("run_command", {"CommandLine": "cat /*/passwd"}), "glob na raiz"), + (("run_command", {"CommandLine": "cat /home/x"}), "redirect para /home")): + check(pre(args[0], **args[1])["decision"] == "deny", f"escape negado: {why}") + check(pre("run_command", CommandLine="cd src && ls")["decision"] == "allow", "cd dentro do repo") + # .git / .agents through the shell, and git global options + for c in ("printf '[core]\\n\\tfsmonitor = touch X\\n' >> .git/config", + "echo x > .git/hooks/pre-commit", "cat src/../.git/config", "ls .agents", + "echo '{}' > \"$(git rev-parse --git-path crew)/runs/r/ctx.json\"", + "git -c user.name=x commit -m x", "git --no-pager push", "git -C . push", + "g''it push origin HEAD", "\"git\" push", "git gc --prune=now", "git pull", + "git reflog expire --expire=now --all", "git submodule update --init", "git init sub", + "git update-index --assume-unchanged package.json", "git branch -f main HEAD~1", + "git config --list", "rm -f -r node_modules", "find . -name '*.log' -delete", + "ln -s / src/lnk", "nohup sh -c 'sleep 600' >/dev/null 2>&1 &", "setsid sleep 600 &", + "sleep 60 &", "crontab -", "chmod u+s src/a.txt", "echo X=1 > .env", + "cat a >> src/a.txt", "npm run deploy", "pnpm nx run web:publish"): + check(pre("run_command", CommandLine=c)["decision"] == "deny", f"shell negado: {c}") + # read-only commands that used to be false positives + for c in ("grep -rn '/api/v1' src", "sed -n '/foo/,/bar/p' src/a.txt", "git grep -n 'deploy' -- src", + "git log --grep=publish", "npx vitest run -t 'GET /orders'", "git -C . diff", + "git --no-pager log -n 3", "git diff HEAD -- src 2>&1", "grep -n '->' src/a.txt", + "npm test >/dev/null 2>&1", "ls .github .gitignore", "python3 -m unittest -v 2>&1 | tail -5", + "cat src/a.txt && echo ok"): + check(pre("run_command", CommandLine=c)["decision"] == "allow", f"leitura permitida: {c}") + # symlink inside the scope that points out of the repo + os.symlink("/", os.path.join(repo, "src", "lnk")) + check(pre("write_to_file", TargetFile=f"{repo}/src/lnk/home/x")["decision"] == "deny", "symlink") + check(pre("write_to_file", TargetFile="src/lnk/home/x")["decision"] == "deny", "symlink relativo") + os.remove(os.path.join(repo, "src", "lnk")) + dctx = dict(ctx, gate_commands=["docker compose run test", "{files}"]) + dpre = lambda c: decide_pre({"toolCall": {"name": "run_command", "args": {"CommandLine": c}}}, dctx) # noqa: E731 + check(dpre("docker compose run test")["decision"] == "allow", "comando do gate isento") + check(dpre("docker compose run test && git push")["decision"] == "deny", "encadeado nao e isento") + check(dpre("docker ps")["decision"] == "deny", "prefixo vazio nao isenta tudo") + check(dpre("docker compose run test 2>&1 >/dev/null")["decision"] == "allow", "gate com redirect inocuo") + for c in ("docker compose run test\ngit push origin main", "docker compose run test\rgit push", + "docker compose run test <(git push origin HEAD)", "docker compose run test --x $HOME", + "docker compose run test > src/../x", "docker compose run testX"): + check(dpre(c)["decision"] == "deny", f"gate nao isenta: {c!r}") + + # opencode backend: task fields, and its tool calls judged by the same decide_pre + check(task["backend"] == "agy" and task["model"] == DEFAULT_MODEL, "backend padrao e agy") + otask = normalize_task({"prompt": "x", "scope": ["src/*"], "backend": "opencode", + "gate": {"checks": ["true"]}}) + check(otask["model"] == OPENCODE_DEFAULT_MODEL and not otask["disable_claude_code"], + "opencode: modelo padrao e CLAUDE.md carregado") + for bad, why in ((dict(prompt="x", scope=["a"], gate={"checks": ["t"]}, backend="codex"), "backend"), + (dict(prompt="x", scope=["*"], gate={"checks": ["t"]}, backend="opencode"), + "scope cobre opencode.json"), + (dict(prompt="x", scope=["a"], gate={"checks": ["t"]}, disable_claude_code="1"), + "disable_claude_code nao booleano")): + try: + normalize_task(bad) + check(False, f"task invalida aceita: {why}") + except Refusal: + check(True, why) + check("ONE JSON object" in compose_prompt(otask, repo) and "ONE JSON object" not in + compose_prompt(task, repo), "regra do relatorio so no prompt do opencode") + octx = base_ctx(repo, otask, "delegate") + oc = lambda tool, **args: decide_oc({"tool": tool, "args": args}, octx)["decision"] # noqa: E731 + for tool, args, want, why in ( + ("read", {"filePath": f"{repo}/src/a.txt"}, "allow", "read no repo"), + ("read", {"filePath": "/etc/passwd"}, "deny", "read fora do repo"), + ("list", {"path": "../.."}, "deny", "list .."), + ("glob", {"pattern": "/etc/*"}, "deny", "glob absoluto"), + ("glob", {"pattern": "src/**/*.ts"}, "allow", "glob no repo"), + ("grep", {"pattern": "/api/v1", "path": "src"}, "allow", "regex do grep nao e caminho"), + ("write", {"filePath": f"{repo}/src/b.txt", "content": "/etc/x"}, "allow", "write no escopo"), + ("write", {"filePath": f"{repo}/top.txt", "content": "x"}, "deny", "write fora do escopo"), + ("edit", {"filePath": "side.json", "oldString": "a", "newString": "b"}, "deny", "edit fora"), + ("bash", {"command": "echo x > top.txt", "description": "w"}, "deny", "redirect"), + ("bash", {"command": "printf x >> src/a.txt"}, "deny", "redirect mesmo no escopo"), + ("bash", {"command": "cp src/a.txt top.txt"}, "deny", "cp no opencode"), + ("bash", {"command": "cd src && touch x"}, "deny", "touch no opencode"), + ("bash", {"command": "ls", "workdir": "/"}, "deny", "workdir fora"), + ("bash", {"command": "python3 -m unittest 2>&1", "description": "Run /etc tests"}, + "allow", "description nao e caminho"), + ("bash", {"command": "git commit -m x"}, "deny", "commit"), + ("apply_patch", {"patchText": "*** Begin Patch\n*** Update File: src/a.txt\n@@\n-a\n+b\n" + "*** End Patch"}, "allow", "patch no escopo"), + ("apply_patch", {"patchText": "*** Add File: top.txt\n+x\n"}, "deny", "patch fora"), + ("apply_patch", {"patchText": "*** Update File: src/a.txt\n*** Move to: ../x\n"}, "deny", + "patch move para fora"), + ("apply_patch", {"patchText": "garbage"}, "deny", "patch sem alvo"), + ("todowrite", {"todos": []}, "allow", "todowrite inerte"), + ("webfetch", {"url": "https://example.com"}, "deny", "webfetch"), + ("task", {"prompt": "x", "subagent_type": "general"}, "deny", "subagente"), + ("batch", {"tool_calls": []}, "deny", "batch"), + ("brand_new_tool", {}, "deny", "ferramenta desconhecida")): + check(oc(tool, **args) == want, f"opencode {why}: {tool} {args}") + check(decide_oc({"tool": "bash", "args": {"command": "cp a b"}}, ctx)["decision"] == "allow", + "cp continua permitido no agy (politica do agy inalterada)") + rctx = dict(octx, scope=[], deny_patterns=REVIEW_DENY_ALL) + check(decide_oc({"tool": "bash", "args": {"command": "ls"}}, rctx)["decision"] == "deny", + "review opencode nega todo shell") + check(decide_oc({"tool": "write", "args": {"filePath": "src/a.txt"}}, rctx)["decision"] == "deny", + "review opencode nega escrita") + + # copilot backend: model rule, task fields, tool translation to the same decide_pre + ctask = normalize_task({"prompt": "x", "scope": ["src/*"], "backend": "copilot", + "gate": {"checks": ["true"]}}) + check(ctask["model"] == "auto" and ctask["max_ai_credits"] == COPILOT_MIN_AI_CREDITS, + "copilot: modelo auto e backstop de 30 creditos") + for bad, why in ((dict(model="gpt-5.6-luna"), "modelo fixo sem override"), + (dict(model="claude-sonnet-5.5", copilot_model_override="yes"), "override nao booleano"), + (dict(max_ai_credits=10), "max_ai_credits abaixo de 30"), + (dict(max_gate_attempts=9), "mais tentativas que o limite de bloqueios"), + (dict(scope=["*"]), "scope cobre .github/hooks"), + (dict(scope=[".claude/*"]), "scope cobre .claude/settings.json")): + try: + normalize_task({"prompt": "x", "scope": ["src/*"], "backend": "copilot", + "gate": {"checks": ["true"]}, **bad}) + check(False, f"task copilot invalida aceita: {why}") + except Refusal as exc: + check(why != "modelo fixo sem override" or "Free" in str(exc), f"{why}: {exc}") + check(normalize_task({"prompt": "x", "scope": ["src/*"], "backend": "copilot", "gate": {"checks": ["t"]}, + "model": "claude-sonnet-5.5", "copilot_model_override": True})["model"] + == "claude-sonnet-5.5", "override libera outro modelo") + check(copilot_model_problem("auto", False) is None and copilot_model_problem("x", False), + "regra do modelo") + cprompt = compose_prompt(ctask, repo) + check("NO shell" in cprompt and "Run the format and check commands yourself" not in cprompt + and "ONE JSON object" in cprompt and "after gate feedback" in cprompt, + "prompt do copilot: sem shell, gate automatico, relatorio em toda mensagem final") + check("Run the format and check commands yourself" in compose_prompt(task, repo), "prompt do agy intacto") + cctx = base_ctx(repo, ctask, "delegate") + cp = lambda tool, args: decide_cp({"toolName": tool, "toolArgs": args}, cctx)["decision"] # noqa: E731 + for tool, args, want, why in ( + ("view", {"path": f"{repo}/src/a.txt"}, "allow", "view no repo"), + ("view", {"path": "src/a.txt", "view_range": [1, 5]}, "allow", "view relativo"), + ("view", {"path": "/etc/passwd"}, "deny", "view fora do repo"), + ("view", {"path": f"{repo}/.env"}, "deny", "view de segredo"), + ("view", json.dumps({"path": "/etc/passwd"}), "deny", "args como string JSON"), + ("glob", {"pattern": "src/**/*.ts"}, "allow", "glob no repo"), + ("glob", {"pattern": "/etc/*"}, "deny", "glob absoluto"), + ("grep", {"pattern": "/api/v1", "path": "src", "glob": "*.ts"}, "allow", "regex nao e caminho"), + ("grep", {"pattern": "x", "path": "../.."}, "deny", "grep fora"), + ("apply_patch", "*** Begin Patch\n*** Add File: src/b.txt\n+x\n*** End Patch\n", "allow", + "patch no escopo (string crua, como no lab)"), + ("apply_patch", "*** Begin Patch\n*** Add File: NOTES.txt\n+x\n*** End Patch\n", "deny", + "patch fora do escopo"), + ("apply_patch", "*** Add File: ../NOTES.txt\n+x\n", "deny", "patch com .."), + ("apply_patch", "*** Add File: src/../NOTES.txt\n+x\n", "deny", "patch src/.."), + ("apply_patch", "*** Update File: src/a.txt\n*** Move to: /tmp/x\n", "deny", "patch move para fora"), + ("apply_patch", "*** Add File: src/a.txt\n+x\n*** Add File: side.json\n+y\n", "deny", + "patch com um alvo fora"), + ("apply_patch", "garbage", "deny", "patch sem alvo"), + ("apply_patch", "*** Add File: .github/hooks/crew-x.json\n+{}\n", "deny", + "patch no arquivo de hooks"), + ("create", {"path": "src/c.txt", "file_text": "see /etc/passwd"}, "allow", "create no escopo"), + ("create", {"path": "top.txt", "file_text": "x"}, "deny", "create fora do escopo"), + ("edit", {"path": "src/a.txt", "old_str": "/a/", "new_str": "/b/"}, "allow", "edit no escopo"), + ("edit", {"path": "side.json", "old_str": "a", "new_str": "b"}, "deny", "edit fora"), + ("bash", {"command": "ls"}, "deny", "bash sempre negado"), + ("read_bash", {"shellId": "1"}, "deny", "read_bash negado"), + ("task", {"prompt": "x"}, "deny", "subagente"), + ("web_fetch", {"url": "https://example.com"}, "deny", "rede"), + ("github-mcp-server-list_issues", {"owner": "x"}, "deny", "MCP do GitHub"), + ("skill", {"skill": "x"}, "deny", "skill"), + ("report_intent", {"intent": "Editing /etc"}, "allow", "report_intent inerte"), + ("brand_new_tool", {}, "deny", "ferramenta desconhecida")): + check(cp(tool, args) == want, f"copilot {why}: {tool} {args}") + crctx = dict(cctx, scope=[], deny_patterns=REVIEW_DENY_ALL, mode="review") + check(decide_cp({"toolName": "create", "toolArgs": {"path": "src/a.txt"}}, crctx)["decision"] == "deny", + "review copilot nega escrita") + check(decide_cp({"toolName": "view", "toolArgs": {"path": "src/a.txt"}}, crctx)["decision"] == "allow", + "review copilot le") + check(cp_hook_output("cp-pre", {"decision": "allow"}) == "", "allow do copilot nao imprime nada") + dj = json.loads(cp_hook_output("cp-pre", {"decision": "deny", "reason": "r"})) + check(dj == {"permissionDecision": "deny", "permissionDecisionReason": "r"}, "deny no formato do copilot") + check(json.loads(cp_hook_output("cp-stop", {"decision": "stop"})) == {"decision": "allow"}, + "stop vira allow no copilot") + + # copilot events: exit 0 with failed work, shell, subagent, missing result + def cpev(*evs): + return "\n".join(json.dumps(e) for e in evs) + tstart = lambda n: {"type": "tool.execution_start", "data": {"toolName": n, "arguments": "{}"}} # noqa: E731 + msg = lambda c: {"type": "assistant.message", "data": {"content": c, "phase": "final_answer"}} # noqa: E731 + res = lambda c=0: {"type": "result", "sessionId": "s1", "exitCode": c, "usage": {"premiumRequests": 2}} # noqa: E731 + f, info = evaluate_cp(0, cpev(tstart("view"), {"type": "tool.execution_complete", "data": { + "success": False}}, msg('ok {"status": "done"}'), msg(""), res()), "") + check(f == [] and info["session"] == "s1" and info["tools"] == {"view": 1} and info["tool_failures"] == 1 + and info["premium_requests"] == 2 and extract_report(info["text"]) == {"status": "done"}, + f"stream bom do copilot: {f} {info}") + f, _ = evaluate_cp(0, cpev(tstart("bash"), res()), "") + check(any("shell" in x for x in f), "chamada de bash reprova") + f, _ = evaluate_cp(0, cpev(tstart("task"), res()), "") + check(any("subagente" in x for x in f), "chamada de task reprova") + f, _ = evaluate_cp(0, cpev(msg("x")), "") + check(any("result" in x for x in f), "sem result reprova") + f, _ = evaluate_cp(0, cpev(res(1)), "") + check(any("exitCode" in x for x in f), "result com exitCode != 0 reprova") + f, _ = evaluate_cp(1, cpev(res()), "boom") + check(any("rc=1" in x for x in f), "rc != 0 reprova") + f, _ = evaluate_cp(0, "", "") + check(any("nenhum evento" in x for x in f), "stdout vazio reprova") + f, _ = evaluate_cp(0, cpev({"type": "session.error", "data": {"message": "quota"}}, res()), "") + check(any("session.error" in x for x in f), "evento de erro reprova") + upath = os.path.join(tmp, "usage.json") + write_json(upath, {"totalPremiumRequestCost": 2, "totalNanoAiu": 939943800, "aiCreditsStatus": "complete", + "modelMetrics": {"gpt-5.6-luna": {}}, "currentModel": "gpt-5.6-luna"}) + u = read_cp_usage(upath) + check(u["ai_credits"] == 0.9399 and u["served_models"] == ["gpt-5.6-luna"] and u["premium_requests"] == 2, + f"usage do copilot: {u}") + check(read_cp_usage(os.path.join(tmp, "nope.json")) is None, "usage ausente") + cmdl = copilot_cmd("copilot", "-x", "auto", "/r", COPILOT_EXCLUDED_TOOLS, 30) + i = cmdl.index("--excluded-tools") + check(cmdl[i + 1:i + 3] == ["bash", "task"] and "--disallow-temp-dir" in cmdl and cmdl[2] == "Task:\n-x" + and "--allow-all-paths" not in cmdl and "--allow-all" not in cmdl, "linha do copilot") + check(copilot_env("rid")["COPILOT_ALLOW_ALL"] == "true" and copilot_env("rid")[RUN_ENV] == "rid" + and copilot_env("rid")["NX_DAEMON"] == "false", "ambiente do copilot") + + # opencode events: exit 0 is never success + sid = "ses_x" + evs = lambda *e: "\n".join(json.dumps(dict(x, sessionID=sid)) for x in e) # noqa: E731 + fin = lambda r: {"type": "step_finish", "part": {"reason": r, "tokens": {"input": 5, "output": 2}, # noqa: E731 + "cost": 0}} + txt = {"type": "text", "part": {"text": 'Done.\n```json\n{"status": "done", "x": {"y": 1}}\n```'}} + f, info = evaluate_oc(0, evs(fin("tool-calls"), txt, fin("stop")), "") + check(f == [] and info["session"] == sid and info["tokens"]["input"] == 10, f"stream bom: {f}") + check(extract_report(info["text"]) == {"status": "done", "x": {"y": 1}}, "relatorio de bloco json") + f, _ = evaluate_oc(0, evs(fin("tool-calls")), "") + check(any("reason=stop" in x for x in f), "sem stop final reprova") + f, _ = evaluate_oc(0, evs({"type": "error", "error": {"name": "APIError", "data": { + "message": "free tier"}}}), "") + check(any("free tier" in x for x in f), "evento error reprova com exit 0") + f, _ = evaluate_oc(0, "", "") + check(any("nenhum evento" in x for x in f), "stdout vazio reprova") + f, _ = evaluate_oc(1, evs(fin("stop")), "boom") + check(any("rc=1" in x for x in f), "rc != 0 reprova") + check(extract_report('{"a": 1} then {"b": {"c": 2}}') == {"b": {"c": 2}}, "ultimo objeto de topo") + check(extract_report("no json {here") is None, "sem relatorio") + check(report_problems(None, DEFAULT_REPORT_SCHEMA) and report_problems({"status": "done"}, + DEFAULT_REPORT_SCHEMA), "relatorio ausente ou incompleto") + check(oc_message("-rf") == "Task:\n-rf" and oc_message("x") == "x", "mensagem nunca vira flag") + + # opencode.json: exact bytes restored, a created one removed + with open(os.path.join(repo, "opencode.json"), "wb") as fh: + fh.write(b'{\n "share": "disabled"\n}\n') + snap = snapshot_files(repo, OPENCODE_CONFIG_FILES) + with open(os.path.join(repo, "opencode.json"), "wb") as fh: + fh.write(b'{\n "$schema": "https://opencode.ai/config.json",\n "share": "disabled"\n}\n') + with open(os.path.join(repo, "opencode.jsonc"), "w") as fh: + fh.write("{}") + check(restore_files(repo, snap) == {"opencode.json", "opencode.jsonc"}, "restaura os dois") + with open(os.path.join(repo, "opencode.json"), "rb") as fh: + check(fh.read() == b'{\n "share": "disabled"\n}\n', "bytes originais") + check(not os.path.exists(os.path.join(repo, "opencode.jsonc")), "criado pelo run e removido") + check(restore_files(repo, snap) == set(), "restaurar de novo nao muda nada") + os.remove(os.path.join(repo, "opencode.json")) + + # the generated guard forwards to this script and holds no policy of its own + grd = os.path.join(tmp, "g") + os.makedirs(grd) + js = open(write_oc_guard(grd), encoding="utf-8").read() + check(json.dumps(os.path.abspath(__file__)) in js and '"oc-pre"' in js and "__" not in js, + "guard gerado aponta para este script") + envc = json.loads(opencode_env(os.path.join(grd, "opencode-guard.js"), False)["OPENCODE_CONFIG_CONTENT"]) + check(envc["permission"] == {"external_directory": "deny"} and envc["plugin"][0].startswith("file:///"), + "config do run: plugin por file:// e external_directory deny") + rid = f"selftest-{os.getpid()}" + child = subprocess.Popen(["sleep", "60"], env={**os.environ, RUN_ENV: rid}, + start_new_session=True) + other = subprocess.Popen(["sleep", "60"], start_new_session=True) + time.sleep(0.3) + hit = stop_run_procs(rid) + child.wait(timeout=10) + check(child.pid in hit and child.returncode is not None, "processo marcado encerrado") + check(other.poll() is None, "processo sem a marca intocado") + other.kill() + other.wait() + check(stop_run_procs("") == {}, "sem run_id nao encerra nada") + + # BackgroundWatcher: who may be killed, from a synthetic tree (agy = 1) + bw = BackgroundWatcher(tmp, "rid") + bw.baseline = {12} + tree = {10: 1, 11: 10, 12: 1, 13: 12, 14: 1, 15: 1, 16: 1, 17: 14} + info = {10: (f"{bw.hook_tag} stop --state x", True, False), 11: ("npx tsc", True, True), + 12: ("agy-helper", True, False), 13: ("node child-of-baseline", True, False), + 14: ("bash -c nx run web:typecheck", True, False), 15: ("sleep 9", False, False), + 16: ("uv tool uvx --from x kino --mcp", True, False), 17: ("node tsc", True, False)} + check(set(bw.candidates(1, tree, info)) == {14, 17}, + "vigia: so comando marcado do worker; hook, gate, baseline, MCP e sem marca ficam") + tree[1] = 0 + info[1] = ("agy -p", True, False) + check(1 not in bw.candidates(1, tree, info), "vigia: nunca o proprio agy") + + # BackgroundWatcher: done only after a green gate + def bw_events(*evs): + d = tempfile.mkdtemp(dir=tmp) + for e in evs: + append_jsonl(os.path.join(d, "guard.jsonl"), e) + w = BackgroundWatcher(d, "rid") + w._read_events() + return w + green, red = {"event": "gate", "passed": True}, {"event": "gate", "passed": False} + cmd_ev = {"event": "pre", "tool": "run_command", "decision": "allow"} + check(bw_events(cmd_ev, green, {"event": "stop", "decision": "stop"}).finished_at, + "vigia: Stop com gate verde = terminou") + check(bw_events(green, {"event": "pre", "tool": "finish", "decision": "allow"}).finished_at, + "vigia: finish permitido com gate verde = terminou") + check(bw_events(red, {"event": "pre", "tool": "finish", "decision": "allow"}).finished_at is None, + "vigia: finish com gate vermelho nao conta") + check(bw_events(green, red, {"event": "stop", "decision": "stop"}).finished_at is None, + "vigia: so o ultimo gate conta") + check(bw_events(cmd_ev, {"event": "stop", "decision": "continue"}).finished_at is None, + "vigia: Stop com continue nao conta") + + # BackgroundWatcher against real processes: a fake agy with a helper + # started before the first command and a worker command after it + wd = tempfile.mkdtemp(dir=tmp) + flag = os.path.join(wd, "spawn") + fake = subprocess.Popen( + ["bash", "-c", f"sleep 60 & while [ ! -e {shlex.quote(flag)} ]; do sleep 0.05; done; " + "sleep 61 & env -u " + RUN_ENV + " sleep 62 & wait"], + env={**os.environ, RUN_ENV: rid}, start_new_session=True) + try: + w = BackgroundWatcher(wd, rid, grace=0.3) + t0 = time.time() + while time.time() - t0 < 5 and not any("sleep 60" in proc_cmdline(p) + for p in descendants(fake.pid, ppid_map()) + if os.path.exists(f"/proc/{p}")): + time.sleep(0.05) + w.poll(fake.pid) + append_jsonl(os.path.join(wd, "guard.jsonl"), cmd_ev) + open(flag, "w").close() + kids = {} + t0 = time.time() + while time.time() - t0 < 5 and len(kids) < 3: + time.sleep(0.05) + kids = {} + for p in descendants(fake.pid, ppid_map()): + try: + kids[proc_cmdline(p)] = p + except OSError: + pass + w.poll(fake.pid) + check(w.finished_at is None and not w.killed, "vigia: nada antes de o worker terminar") + append_jsonl(os.path.join(wd, "guard.jsonl"), green) + append_jsonl(os.path.join(wd, "guard.jsonl"), {"event": "stop", "decision": "stop"}) + w.poll(fake.pid) + check(not w.killed, "vigia: espera a carencia") + time.sleep(0.4) + w.poll(fake.pid) + alive = {c: os.path.exists(f"/proc/{p}") for c, p in kids.items()} + check(set(w.killed.values()) == {"sleep 61"} and not alive.get("sleep 61") + and alive.get("sleep 60") and alive.get("sleep 62") and fake.poll() is None, + f"vigia: so o comando do worker encerrado; helper, sem marca e o agy vivos ({w.killed}, {alive})") + check(os.path.isfile(os.path.join(wd, "procs.jsonl")), "vigia: arvore registrada em procs.jsonl") + finally: + os.killpg(fake.pid, signal.SIGKILL) # our own fake agy's group + fake.wait() + check("OPENCODE_DISABLE_CLAUDE_CODE" in opencode_env("/x.js", True) + and "OPENCODE_DISABLE_CLAUDE_CODE" not in opencode_env("/x.js", False), "flag do CLAUDE.md") + + # gate: nothing changed -> fail; in-scope change -> pass; forbid; out of scope + ok, text, _ = run_gate(repo, ctx) + check(not ok and "nenhum arquivo do escopo" in text, "gate falha sem mudanca") + ok, text, _ = run_gate(repo, ctx, baseline=True) + check(ok and "pulado" in text, "baseline roda so comandos") + with open(os.path.join(repo, "src", "a.txt"), "a") as fh: + fh.write("two\n") + with open(os.path.join(repo, "src", "new file.txt"), "w") as fh: + fh.write("ok\n") + ok, text, cls = run_gate(repo, ctx) + check(ok, f"gate passa com mudanca no escopo: {text}") + check(sorted(cls["in_scope"]) == ["src/a.txt", "src/new file.txt"], "classifica escopo") + with open(os.path.join(repo, "src", "a.txt"), "a") as fh: + fh.write("x // @ts-ignore\n") + ok, text, _ = run_gate(repo, ctx) + check(not ok and "src/a.txt:3" in text, f"forbid com numero de linha: {text}") + subprocess.run(g + ["checkout", "-q", "--", "src/a.txt"], check=True) + with open(os.path.join(repo, "side.json"), "w") as fh: + fh.write('{"analytics": false}\n') + with open(os.path.join(repo, "other.txt"), "w") as fh: + fh.write("x\n") + ok, text, cls = run_gate(repo, ctx) + check(not ok and "other.txt" in text and cls["side_effects"] == ["side.json"], + "fora do escopo falha; efeito colateral tolerado") + os.remove(os.path.join(repo, "other.txt")) + ok, text, _ = run_gate(repo, dict(ctx, gate=dict(ctx["gate"], checks=['test "$CREW_GATE" = 1']))) + check(ok, "gate exporta CREW_GATE") + ctx2 = dict(ctx, gate=dict(ctx["gate"], checks=["false"])) + ok, text, _ = run_gate(repo, ctx2) + check(not ok and "FALHOU (rc=1)" in text, "check falhando reprova") + + # fingerprint ignores side effects and changes with in-scope edits + fp1 = fingerprint(repo, ["side.json"]) + with open(os.path.join(repo, "side.json"), "w") as fh: + fh.write('{"analytics": true}\n') + check(fingerprint(repo, ["side.json"]) == fp1, "fingerprint ignora efeito colateral") + with open(os.path.join(repo, "src", "new file.txt"), "a") as fh: + fh.write("more\n") + check(fingerprint(repo, ["side.json"]) != fp1, "fingerprint muda com o escopo") + + # hooks end to end through a subprocess: never `{}` for pre, gate cache + rundir = os.path.join(tmp, "run") + os.makedirs(rundir) + write_json(os.path.join(rundir, "ctx.json"), dict(ctx, max_gate_attempts=4)) + + def hook(event, payload): + p = subprocess.run([sys.executable, os.path.abspath(__file__), "hook", event, "--state", rundir], + input=json.dumps(payload), capture_output=True, text=True, timeout=60) + return json.loads(p.stdout) + check(hook("start", {"modelName": "m"}) == {}, "start devolve {}") + check(hook("pre", {"toolCall": {"name": "view_file", "args": {"AbsolutePath": "/etc/x"}}}) + ["decision"] == "deny", "hook pre nega") + check(hook("pre", {"toolCall": {"name": "list_dir", "args": {"DirectoryPath": repo}}}) + == {"decision": "allow"}, "hook pre permite explicitamente") + check(hook("oc-pre", {"tool": "write", "args": {"filePath": "/tmp/x", "content": "x"}})["decision"] + == "deny", "hook oc-pre nega") + check(hook("oc-pre", {"tool": "read", "args": {"filePath": f"{repo}/src/a.txt"}}) + == {"decision": "allow"}, "hook oc-pre permite explicitamente") + r = hook("stop", {"terminationReason": "NO_TOOL_CALL"}) + check(r["decision"] == "continue" and "finish" in r["reason"], + f"stop com gate verde e sem finish pede o relatorio: {r}") + r = hook("pre", {"toolCall": {"name": "finish", "args": {}}}) + check(r["decision"] == "allow", "finish com gate verde") + st = read_json(os.path.join(rundir, "gate-state.json")) + check(st["attempts"] == 2 and st["runs"] == 1, f"cache do gate: {st}") + with open(os.path.join(repo, "outside.txt"), "w") as fh: + fh.write("x\n") + r = hook("stop", {"terminationReason": "TERMINAL_STEP_TYPE"}) + check(r["decision"] == "continue" and "outside.txt" in r["reason"], "stop com gate vermelho continua") + r = hook("stop", {"terminationReason": "MAX_INVOCATIONS"}) + check(r["decision"] == "stop", "outros motivos param sem gate") + r = hook("pre", {"toolCall": {"name": "finish", "args": {}}}) + check(r["decision"] == "allow", "limite de tentativas libera o finish") + st = read_json(os.path.join(rundir, "gate-state.json")) + check(st["runs"] == 3, f"falha nunca vem do cache: {st}") + with open(os.path.join(repo, "src", "nf.txt"), "w") as fh: + fh.write("x\n") + rd_save = rundir + for name in ("nofinish", "finished"): + rundir = os.path.join(tmp, name) + os.makedirs(rundir) + write_json(os.path.join(rundir, "ctx.json"), dict( + ctx, max_gate_attempts=9, gate=dict(ctx["gate"], format=[], checks=["true"]), scope=["*"])) + rundir = os.path.join(tmp, "nofinish") + got = [hook("stop", {"terminationReason": "NO_TOOL_CALL"})["decision"] for _ in range(AGY_REPORT_ASKS + 1)] + check(got == ["continue"] * AGY_REPORT_ASKS + ["stop"], f"pede o finish no maximo {AGY_REPORT_ASKS} vezes: {got}") + rundir = os.path.join(tmp, "finished") + hook("pre", {"toolCall": {"name": "finish", "args": {}}}) + r = hook("stop", {"terminationReason": "TERMINAL_STEP_TYPE"}) + check(r["decision"] == "stop", f"stop com gate verde depois do finish para: {r}") + rundir = rd_save + os.remove(os.path.join(repo, "src", "nf.txt")) + os.remove(os.path.join(rundir, "ctx.json")) + check(hook("pre", {"toolCall": {"name": "view_file", "args": {}}})["decision"] == "deny", + "guard quebrado nega em vez de devolver {}") + check(hook("stop", {})["decision"] == "stop", "stop quebrado deixa parar") + check(hook("oc-pre", {"tool": "read", "args": {}})["decision"] == "deny", "oc-pre quebrado nega") + events = guard_events(rundir) + check(any(e.get("event") == "session_start" for e in events), "marcador de SessionStart") + + # copilot hooks end to end: cp-pre output contract, gate and report on agentStop + os.remove(os.path.join(repo, "outside.txt")) + cprun = os.path.join(tmp, "cprun") + os.makedirs(cprun) + once = os.path.join(tmp, "gate-once") + evfile = os.path.join(cprun, "copilot.stdout") + write_json(os.path.join(cprun, "ctx.json"), dict( + ctx, max_gate_attempts=3, run_id="cprun", ignore_paths=[cp_hook_rel("cprun")], + events_file=evfile, report_schema={"type": "object", "required": ["status"]}, + gate=dict(ctx["gate"], format=[], checks=[f"test -e {shlex.quote(once)} || " + f"{{ touch {shlex.quote(once)}; false; }}"]))) + + def cphook(event, payload): + p = subprocess.run([sys.executable, os.path.abspath(__file__), "hook", event, "--state", cprun], + input=json.dumps(payload), capture_output=True, text=True, timeout=60) + return p.returncode, p.stdout.strip() + rc, out = cphook("cp-pre", {"toolName": "view", "toolArgs": {"path": f"{repo}/src/a.txt"}}) + check(rc == 0 and out == "", f"cp-pre allow: saida vazia ({out!r})") + rc, out = cphook("cp-pre", {"toolName": "apply_patch", "toolArgs": "*** Add File: ../x\n+x\n"}) + check(rc == 0 and json.loads(out)["permissionDecision"] == "deny", "cp-pre deny com motivo") + with open(evfile, "w") as fh: + fh.write(json.dumps(msg("done, no report")) + "\n") + rc, out = cphook("cp-stop", {"stop_hook_active": False}) + check(json.loads(out)["decision"] == "block" and "gate FAILED" in json.loads(out)["reason"], + f"agentStop com gate vermelho bloqueia: {out}") + rc, out = cphook("cp-stop", {"stop_hook_active": True}) + check(json.loads(out)["decision"] == "block" and "report" in json.loads(out)["reason"], + f"gate verde sem relatorio pede o relatorio: {out}") + with open(evfile, "a") as fh: + fh.write(json.dumps(msg('ok {"status": "done"}')) + "\n") + rc, out = cphook("cp-stop", {"stop_hook_active": True}) + check(json.loads(out) == {"decision": "allow"}, f"gate verde com relatorio libera: {out}") + cst = read_json(os.path.join(cprun, "gate-state.json")) + check(cst["runs"] == 2 and cst["attempts"] == 3, f"gate do copilot: verde em cache depois: {cst}") + with open(evfile, "a") as fh: + fh.write(json.dumps(msg("again no report")) + "\n") + outs = [json.loads(cphook("cp-stop", {})[1])["decision"] for _ in range(3)] + check(outs == ["block", "allow", "allow"], f"pedidos de relatorio limitados: {outs}") + os.remove(evfile) + os.remove(os.path.join(cprun, "report-asks.json")) + check(json.loads(cphook("cp-stop", {})[1]) == {"decision": "allow"}, + "sem stream legivel nao pede relatorio (nao prende o worker)") + os.remove(os.path.join(cprun, "ctx.json")) + rc, out = cphook("cp-pre", {"toolName": "view", "toolArgs": {"path": "src/a.txt"}}) + check(json.loads(out)["permissionDecision"] == "deny", "cp-pre quebrado nega") + check(json.loads(cphook("cp-stop", {})[1]) == {"decision": "allow"}, "cp-stop quebrado deixa parar") + cpev_log = guard_events(cprun) + check(any(e.get("event") == "pre" and e.get("decision") == "deny" for e in cpev_log), + "negacao do copilot registrada") + + # copilot hooks file: written for the run, ignored by the scope check, removed, never clobbered + cpctx = dict(ctx, run_id="cprun") + rel, made = install_cp_hooks(repo, cprun, cpctx) + spec = read_json(os.path.join(repo, rel)) + check(rel == ".github/hooks/crew-cprun.json" and set(spec["hooks"]) == + {"sessionStart", "preToolUse", "agentStop"} and "cp-pre" in spec["hooks"]["preToolUse"][0]["bash"] + and os.path.abspath(__file__) in spec["hooks"]["agentStop"][0]["bash"], "hooks do copilot gerados") + check(rel in changed_paths(repo) and rel not in changed_paths(repo, [rel]), "escopo ignora so esse arquivo") + check(fingerprint(repo, [], [rel]) != fingerprint(repo, []), "fingerprint ignora o arquivo de hooks") + try: + install_cp_hooks(repo, cprun, cpctx) + check(False, "sobrescreveu o hooks do copilot") + except Refusal: + check(True, "recusa sobrescrever") + try: + refuse_cp_leftovers(repo) + check(False, "sobra do copilot aceita") + except Refusal as exc: + check("rm " in str(exc), "sobra do copilot recusada com o rm") + remove_cp_hooks(repo, rel, made) + check(not os.path.exists(os.path.join(repo, ".github")), ".github criado pelo run removido") + os.makedirs(os.path.join(repo, ".github", "hooks")) + with open(os.path.join(repo, ".github", "hooks", "mine.json"), "w") as fh: + fh.write("{}") + check(refuse_cp_leftovers(repo) == [".github/hooks/mine.json"], "hooks do repo listados") + rel, made = install_cp_hooks(repo, cprun, cpctx) + remove_cp_hooks(repo, rel, made) + check(made == [] and os.path.exists(os.path.join(repo, ".github", "hooks", "mine.json")), + "diretorio existente e arquivo do usuario intocados") + shutil.rmtree(os.path.join(repo, ".github")) + + # result evaluation: the exit-0 failure modes + sch = DEFAULT_REPORT_SCHEMA + good = {"status": "SUCCESS", "structured_output": {"status": "done", "files_changed": [], + "checks_run": [], "divergences": [], "out_of_scope_needs": []}} + f, w, _ = evaluate_run(0, json.dumps(good), "", "delegate", sch) + check(f == [] and w == [], f"run bom: {f}") + f, _, _ = evaluate_run(0, "", "", "delegate", sch) + check(any("stdout vazio" in x for x in f), "stdout vazio") + f, _, _ = evaluate_run(0, json.dumps(dict(good, structured_output=None)), "", "delegate", sch) + check(any("structured_output nulo" in x for x in f), "schema nao satisfeito") + f, _, _ = evaluate_run(0, json.dumps(good), "[agy] print timeout after 5s with turn", "delegate", sch) + check(any("timeout" in x for x in f), "marcador de timeout") + den = dict(good, denied_actions=[{"action": "command"}]) + f, _, _ = evaluate_run(0, json.dumps(den), "", "delegate", sch) + check(any("denied_actions" in x for x in f), "denied_actions reprova delegate") + f, w, _ = evaluate_run(0, json.dumps(dict(den, structured_output={"verdict": "approve", + "findings": [], "summary": ""})), "", "review", DEFAULT_REVIEW_SCHEMA) + check(f == [] and w, "denied_actions so avisa no review") + f, _, _ = evaluate_run(1, "", "error: invalid model selection", "delegate", sch) + check(any("rc=1" in x for x in f), "rc != 0") + part = dict(good, structured_output=dict(good["structured_output"], status="partial")) + f, _, _ = evaluate_run(0, json.dumps(part), "", "delegate", sch) + check(any("partial" in x for x in f), "worker parcial reprova") + f, _, _ = evaluate_run(0, json.dumps(dict(good, structured_output={"status": "done"})), "", + "delegate", sch) + check(any("obrigatorios" in x for x in f), "campos obrigatorios") + for bad in ("null", "[]", "3"): + f, _, _ = evaluate_run(0, bad, "", "delegate", sch) + check(any("nao um objeto" in x for x in f), f"envelope {bad} reprova") + + # quota and models, against the shapes agy 1.3.4 printed on 2026-10-13 + usage = {"command": {"data": {"groups": [ + {"name": "Gemini Models", "description": "Models within this group: Gemini Flash, Gemini Pro", + "buckets": [{"name": "Weekly", "remaining_fraction": 0.73}, + {"name": "Five Hour", "remaining_fraction": 0.45}]}, + {"name": "Claude and GPT models", "description": "Claude Opus, Claude Sonnet, GPT-OSS", + "buckets": [{"name": "Weekly", "remaining_fraction": 0, "reset_time": "2026-10-15T18:34:47Z"}, + {"name": "Five Hour", "disabled": True, "remaining_fraction": 1}]}]}}} + check(evaluate_quota(usage, "gemini-3.8-flash-low", 0.05) is None, "quota gemini ok") + msg = evaluate_quota(usage, "claude-opus-5-5-high", 0.05) + check(msg and "esgotada" in msg and "2026-10-15" in msg, "quota claude esgotada") + check(evaluate_quota(usage, "gpt-oss-120b-medium", 0.05), "gpt no mesmo grupo esgotado") + check(evaluate_quota({}, "gemini-x", 0.05), "usage ilegivel recusa") + models = parse_models("gemini-3.8-flash-low\tGemini 3.8 Flash (Low)\nFetching available models...\n") + check(models == ["gemini-3.8-flash-low"], "parse de agy models") + + # install/remove hooks leaves nothing behind and refuses to clobber + subprocess.run(g + ["checkout", "-q", "--", "."], check=True) + subprocess.run(g + ["clean", "-q", "-fd"], check=True) + + # what git status cannot see: .git config/hooks/flags, ctx.json, ignored files + with open(os.path.join(repo, ".gitignore"), "w") as fh: + fh.write(".env\nbuild/\n") + subprocess.run(g + ["add", ".gitignore"], check=True) + subprocess.run(g + ["commit", "-q", "-m", "ignore"], check=True) + i0, ig0 = integrity_state(repo, rundir), ignored_state(repo) + os.makedirs(os.path.join(repo, "build")) + with open(os.path.join(repo, "build", "out.js"), "w") as fh: + fh.write("x\n") + check(diff_state(ig0, ignored_state(repo)) == [], "diretorio ignorado novo nao e ruido") + with open(os.path.join(repo, ".env"), "w") as fh: + fh.write("X=1\n") + check(diff_state(ig0, ignored_state(repo)) == [".env"], ".env ignorado detectado") + check(changed_paths(repo) == [], "e o git status de fato nao o ve") + subprocess.run(g + ["update-index", "--assume-unchanged", "side.json"], check=True) + with open(os.path.join(repo, ".git", "config"), "a") as fh: + fh.write("[core]\n\tfsmonitor = touch FSMON_RAN\n") + bad = diff_state(i0, integrity_state(repo, rundir)) + changed_paths(repo) + check(any(b.endswith("config") for b in bad) and "" in bad, + f"config e flag de indice detectados: {bad}") + check(not os.path.exists(os.path.join(repo, "FSMON_RAN")), "fsmonitor plantado nao roda no git do script") + subprocess.run(["git", "-C", repo, "config", "--unset", "core.fsmonitor"], check=True) + subprocess.run(g + ["update-index", "--no-assume-unchanged", "side.json"], check=True) + os.remove(os.path.join(repo, ".env")) + shutil.rmtree(os.path.join(repo, "build")) + + # a timed-out gate command takes its children with it + t0 = time.time() + ok, text, _ = run_gate(repo, dict(ctx, gate_timeout=1, gate=dict(ctx["gate"], format=[], + checks=["sleep 30 & sleep 30; wait"])), baseline=True) + check(not ok and "rc=timeout" in text and time.time() - t0 < 15, "timeout do gate") + created = install_hooks(repo, rundir, ctx) + spec = read_json(os.path.join(repo, HOOK_FILE)) + check(set(spec["crew"]) == {"SessionStart", "PreToolUse", "Stop"}, "hooks.json gerado") + check(changed_paths(repo) == [], "hooks.json nao conta como mudanca") + try: + install_hooks(repo, rundir, ctx) + check(False, "sobrescreveu hooks.json existente") + except Refusal as exc: + check("sobra de um run" in str(exc) and "rm " in str(exc), "reconhece a propria sobra") + remove_hooks(repo, created) + check(not os.path.exists(os.path.join(repo, ".agents")), ".agents removido") + finally: + shutil.rmtree(tmp, ignore_errors=True) + print(f"self-test: {n} checks ok") + return 0 + + +# -------------------------------------------------------------------------- + +def main(argv=None) -> int: + if argv is None: + argv = sys.argv[1:] + if argv == ["--self-test"]: + return self_test() + p = argparse.ArgumentParser(prog="crew.py", description=__doc__.split("\n\n")[0]) + sub = p.add_subparsers(dest="cmd", required=True) + d = sub.add_parser("delegate", help="roda uma task sob guard rails e gate") + d.add_argument("task") + d.add_argument("--model") + d.add_argument("--backend", choices=BACKENDS, help="sobrepoe o campo backend da task") + d.add_argument("--repo") + d.add_argument("--no-quota-probe", action="store_true", help="so agy") + r = sub.add_parser("review", help="segunda opiniao somente leitura (--mode plan)") + r.add_argument("prompt", nargs="*") + r.add_argument("--prompt-file") + r.add_argument("--diff", help="intervalo para `git diff`, ex.: HEAD~1 ou main...HEAD") + r.add_argument("--schema") + r.add_argument("--model") + r.add_argument("--backend", choices=BACKENDS, default="agy") + r.add_argument("--repo") + r.add_argument("--timeout", default=DEFAULT_REVIEW_TIMEOUT) + r.add_argument("--min-quota", type=float, default=DEFAULT_MIN_QUOTA) + r.add_argument("--no-quota-probe", action="store_true") + r.add_argument("--copilot-model-override", action="store_true", + help="so copilot: aceita modelo diferente de auto (plano pago; nao testado)") + gt = sub.add_parser("gate", help="roda o gate de uma task na arvore atual") + gt.add_argument("task") + gt.add_argument("--repo") + gt.add_argument("--baseline", action="store_true", + help="so os comandos de format/checks, sem escopo nem forbid") + h = sub.add_parser("hook", help="handler de hook do agy (instalado por run)") + h.add_argument("event", choices=["pre", "stop", "start", "oc-pre", "cp-pre", "cp-stop"]) + h.add_argument("--state", required=True) + a = p.parse_args(argv) + if a.cmd == "hook": + return hook_main(a.event, a.state) + try: + return {"delegate": cmd_delegate, "review": cmd_review, "gate": cmd_gate}[a.cmd](a) + except Refusal as exc: + eprint(f"crew: recusado: {exc}") + print(json.dumps({"ok": False, "refused": True, "failures": [str(exc)]}, ensure_ascii=False)) + return 2 + except KeyboardInterrupt: + eprint("crew: interrompido; hooks removidos") + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/crew/shared/delegate.md b/plugins/crew/shared/delegate.md new file mode 100644 index 0000000..7cd077a --- /dev/null +++ b/plugins/crew/shared/delegate.md @@ -0,0 +1,204 @@ +# Delegar a um worker: a parte comum aos tres backends + +Lido pelas skills `crew:agy`, `crew:opencode` e `crew:copilot`. Cada uma traz so +o que e do seu CLI (flags, modelos, o que o guard daquele backend faz e nao +faz); tudo o que vale para os tres esta aqui. + +A divisao e fixa: **voce** planeja, escreve a task, define escopo e gate, le o +veredito e revisa o diff. O worker **executa**, headless, uma fatia por vez. +Nunca o contrario: o worker nao decide design, nao commita e nao declara nada +pronto por conta propria. + +Sem guard rails isso nao funciona. Forcado a continuar, o Gemini Flash foi +procurar o checador em `~/.claude`, `/tmp`, na lista de processos e em `../` +em vez de consertar o codigo; "consertou" arquivo que o Nx escreve sozinho; +inventou alias que nao existia; pulou formatacao. O script `scripts/crew.py` e +o guard rail; este texto e o resto, que o script nao consegue fazer por voce. + +## Pergunte antes do primeiro run + +Delegar e revisar mandam o prompt, os arquivos que o worker ler e o diff para +o provedor do backend (Google no agy, o provedor do modelo do Zen no opencode, +GitHub no copilot) e gastam a quota ou os creditos do usuario. Antes do +primeiro `delegate` ou `review` da sessao, pergunte; depois do sim, nao precisa +perguntar de novo a cada fatia. + +## Quando delegar + +- Existe **fonte da verdade concreta**: codigo legado a portar, spec, schema, + um exemplo ja aprovado do mesmo padrao. +- Existe **gate mecanico** que roda em minutos: testes, typecheck, lint, + formatter. Se a unica forma de saber se ficou certo e ler, nao delegue. +- O escopo cabe em **poucos globs** (`src/server/orders/*`). +- Melhor ainda se **repete**: N fatias iguais (servico por servico, rota por + rota). A primeira voce revisa linha a linha; as seguintes ja tem padrao. + +## Quando nao delegar + +- Spike exploratorio, causa de bug desconhecida, decisao de arquitetura ou de + API publica, nomeacao. O worker escolhe a primeira coisa plausivel. +- Mudanca espalhada por muitos diretorios, ou que exige install, rede, + migracao de banco ou deploy: o guard nega, e o worker passa a contornar. +- Quando revisar custa mais do que fazer. Uma mudanca de 10 linhas sai mais + barata feita direto. + +## Antes do primeiro run num projeto + +1. **Arvore limpa.** O script recusa arvore suja: o diff do worker tem que + ser so dele. Commite antes. +2. **Baseline verde.** Rode o gate na arvore limpa so com os comandos: + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" gate TASK.json --baseline + ``` + Gate vermelho antes de comecar e o pior caso: o worker nao tem o que + consertar e vai procurar o gate. +3. O que cada backend exige a mais (arquivo de hooks, login, versao) esta na + skill dele. + +## Escrevendo a task + +Um JSON **fora da arvore** (o scratchpad, ou +`$(git rev-parse --git-path crew)/tasks/`), senao ele mesmo suja a arvore. +Uma task completa de exemplo esta em `${CLAUDE_PLUGIN_ROOT}/shared/task.example.json`. + +| campo | o que e | +|---|---| +| `prompt` ou `prompt_file` | o enunciado (arquivo relativo ao JSON) | +| `scope` | globs relativos a raiz; `*` atravessa `/`. Unica coisa que o worker pode escrever | +| `gate.forbid` | regex contra as linhas **adicionadas**: `any`, `@ts-ignore`, `.skip(` | +| `gate.format` | o ponto do formatter. `{files}` vira os arquivos do escopo alterados | +| `gate.checks` | lint, typecheck, testes estreitos. Todos tem que sair 0 | +| `report_schema` | opcional; o padrao pede `status`, `files_changed`, `checks_run`, `divergences`, `out_of_scope_needs` | +| `side_effects` | globs que ferramentas reescrevem sozinhas (`nx.json`); toleradas, mas listadas no veredito | +| `backend` | `agy` (padrao), `opencode` ou `copilot` | +| `model` | o padrao e a escalada de cada backend estao na skill dele | +| `timeout`, `gate_timeout`, `max_gate_attempts` | `30m`, 900 s por comando, 5 | +| `allow_paths`, `deny_commands` | caminhos extra de leitura, regex extra de comando negado | +| `secret_globs` | ver "Arquivos de segredo" abaixo | + +O prompt: + +- **Nomeie a fonte da verdade e mande ler inteira antes de escrever**, com os + caminhos. Aponte um exemplo ja aprovado do mesmo padrao: copiar padrao um + modelo barato faz bem; inferir padrao, nao. +- **Liste as mudancas obrigatorias e diga "so elas"**: sem refatorar, renomear, + reordenar ou "melhorar". +- **Nomes exatos** dos arquivos a criar. Regras de estilo que o formatter nao + pega. +- **Aponte caminhos em vez de colar arquivos.** O prompt vai num argumento de + linha de comando; o script recusa acima de 100 KB. +- **Nao repita** escopo, gate, proibicoes nem o pedido de divergencias: o + script anexa um bloco "Operating rules" com tudo isso, gerado da task. Leia + o `prompt.md` no run dir se quiser ver o que o worker recebeu. + +O gate: + +- **Coloque o formatter em `format`.** Sem ele no gate o Gemini pula + formatacao, visto. Um `grep` de espaco no fim da linha nao substitui + formatter: no lab ele deixou passar linha em branco sobrando no fim do + arquivo. +- **Comandos rapidos e deterministicos.** Falha nunca e reaproveitada (cada + tentativa roda de novo), mas teste flaky queima tentativas. +- Os comandos rodam com `CREW_GATE=1` no ambiente (e `AGY_WORKER_GATE=1`, o + nome antigo), caso um deles precise saber que e o gate e nao o worker rodando + o mesmo comando. + +## Arquivos de segredo + +`secret_globs` sao nomes de arquivo que o worker nao le nem escreve; o padrao +cobre `.env`, `.env.*`, `.dev.vars`, `*.pem`, `*.key`, `id_rsa*`... +(`.env.example` passa); `[]` desliga. Nao por desconfianca do modelo, mas pelo +destino: o que o worker le vai para o servidor do provedor, e uma senha num +corpus de treino so se conserta trocando a senha. O bloqueio olha o nome; um +caminho montado em tempo de execucao (`cat .e*`, `python3 -c`) passa, e o +worker herda o seu ambiente (`env` e `printenv` sao negados, `python3 -c +'import os; ...'` nao). Nao delegue com segredo em variavel de ambiente. + +## Rodando + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" delegate /caminho/TASK.json [--backend agy|opencode|copilot] +``` + +Pode passar dos 10 minutos do Bash: rode com `run_in_background` e espere a +notificacao. **Enquanto o run durar, nao edite nada nessa arvore** (nem +formatter, nem `git checkout`): o veredito atribui toda mudanca ao worker, e +uma edicao sua conta como dele ou reprova o run por "fora do escopo". Se +precisar continuar trabalhando, delegue num `git worktree` separado. O +veredito sai em stdout e em `/verdict.json`; o `run_dir` e impresso na +primeira linha do stderr. + +**Uma fatia por vez por worktree.** Arvore limpa e um arquivo de hooks por +workspace serializam os runs. Para paralelizar, um `git worktree` por fatia, +cada um com seu estado em `git rev-parse --git-path crew`. Isso **nao foi +testado**: diga ao usuario que e a primeira vez, se for fazer. + +## Lendo o veredito + +Saida 0 = ok, 1 = o run falhou, 2 = recusado antes de rodar (nada rodou, +nenhuma quota gasta). `ok: false` sempre vem com `failures`, cada um dizendo +qual checagem pegou. Exit 0 do CLI **nunca** e lido como sucesso. Falhas +comuns aos tres: + +| falha | por que existe | +|---|---| +| hooks / plugin de guard nao carregaram | o run nao teve guard rail nenhum; trate o diff como nao confiavel | +| relatorio ausente ou incompleto | o worker nao entregou o relatorio pedido | +| HEAD mudou | um commit ou checkout passou pelo guard | +| gate falhou no estado final | o gate e rodado de novo sobre a arvore como ficou, salvo se o ultimo gate verde do hook foi exatamente nesse estado | +| fora do escopo | `git status` depois do run; ve arquivo rastreado e nao rastreado, **nao** arquivo ignorado pelo git | +| config, hooks ou flags do git mudaram | escrita no `.git` que faria o proprio git do script rodar codigo, ou esconderia mudanca do `git status` | +| worker se declarou partial/blocked | o proprio relatorio dele | + +As falhas que so um backend tem estao na skill dele. + +Leia tambem os `warnings`: arquivos ignorados pelo git criados ou alterados +(o gate escreve alguns, como `.eslintcache`; um `.env` novo nao e do gate), +versao do CLI diferente da testada, `guard_denials` (tentativas de sair do +escopo; varias seguidas quase sempre e enunciado ambiguo ou gate impossivel), +`side_effects` (reverta se nao forem desejados) e processos com a marca do run +(`CREW_RUN`) que foram encerrados no fim, ou que sobreviveram. + +Em falha o diff **fica na arvore**, sem commit e sem revert. Decida voce: +aproveitar e terminar a mao, ou reescrever a task e descartar o diff. Nao +re-delegue a mesma task sem mudar nada nela: o mesmo enunciado tende a dar o +mesmo erro. + +## Revisao: obrigatoria depois de todo run, inclusive os ok + +O gate prova o que ele mede, e so isso. Antes de aceitar: + +1. Leia `git diff HEAD` inteiro **lado a lado com a fonte da verdade**, nao + so contra o enunciado. +2. Confira cada item de `divergences` e de `out_of_scope_needs`. Lista vazia + nao e prova de que nao ha divergencia. +3. Procure o que um modelo barato faz e gate nenhum pega, todos ja vistos: + alias ou shim inventado com nome plausivel (`NotFoundException`), + "conserto" de efeito colateral fora do escopo, divergencia de comportamento + nao reportada, `import` dentro de funcao, linha em branco sobrando. +4. Testes portados: estao todos os casos do original? Algum assert ficou mais + fraco? O numero de casos bater nao prova que sao os mesmos casos. +5. `report.checks_run` e autodeclarado. So o gate conta. +6. Commit e seu (ou do usuario), nunca do worker. + +## Segunda opiniao, somente leitura + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" review [--backend agy|opencode|copilot] --diff HEAD~1 --prompt-file /scratchpad/pergunta.md +``` + +A pergunta vai num arquivo fora da arvore: texto livre na linha de comando +quebra com aspas, parenteses ou `;`. O diff vai no prompt; o revisor nao +escreve nada (como cada backend garante isso esta na skill dele). Cada finding +traz `evidence`: confira no codigo antes de agir, porque o numero de linha pode +vir errado. E opiniao, nao veredito. + +## O que os guards sao, e o que nao sao + +Sao um quebra-molas para um modelo cooperativo que se perde, nao um sandbox. +Um comando que monta o caminho em tempo de execucao (`python3 -c`, `node -e`) +passa pelos guards. A checagem de escopo pos-run pega o que ele escrever em +arquivo rastreado ou novo nao ignorado dentro do repo; arquivo ignorado novo ou +alterado vira so aviso; dentro de diretorio ignorado (`node_modules/`, `dist/`) +e fora do repo, nada pega. Worker e gate rodam com `NX_DAEMON=false`, e o que o +run deixar vivo com a marca dele no ambiente e encerrado no fim. diff --git a/plugins/crew/shared/task.example.json b/plugins/crew/shared/task.example.json new file mode 100644 index 0000000..8b4a6bb --- /dev/null +++ b/plugins/crew/shared/task.example.json @@ -0,0 +1,32 @@ +{ + "backend": "agy", + "prompt": "Port src/legacy/orders.service.ts to src/server/orders/orders.service.ts. Read the legacy service and its spec first; the legacy code is the specification: same public methods, same queries, same error messages. Port every test case of src/legacy/orders.service.spec.ts to src/server/orders/orders.service.test.ts with the same describe/it names and assertions. Do not refactor, rename or 'improve' anything.", + "scope": [ + "src/server/orders/*" + ], + "side_effects": [ + "nx.json" + ], + "gate": { + "forbid": [ + "@ts-ignore", + "@ts-expect-error", + "eslint-disable", + "\\b(it|describe|test)\\.(skip|only)\\(", + "\\bas any\\b|: any\\b" + ], + "format": [ + "npx prettier --check {files}" + ], + "checks": [ + "npx eslint --no-warn-ignored {files}", + "npx tsc --noEmit -p .", + "npx vitest run src/server/orders" + ] + }, + "model": "gemini-3.8-flash-high", + "timeout": "30m", + "gate_timeout": 600, + "max_gate_attempts": 5, + "min_quota": 0.05 +} diff --git a/plugins/crew/skills/agy/SKILL.md b/plugins/crew/skills/agy/SKILL.md new file mode 100644 index 0000000..22d2bce --- /dev/null +++ b/plugins/crew/skills/agy/SKILL.md @@ -0,0 +1,94 @@ +--- +name: agy +description: Especificos do backend agy (Antigravity CLI, Gemini) do plugin crew - flags do run, modelo padrao gemini-3.8-flash-high, escalada so para claude-sonnet-5-5-medium e nunca Opus, preflight de quota, hooks em .agents/hooks.json e os comandos que o agy joga para segundo plano. Use quando for delegar uma fatia ou pedir review pelo agy. +--- + +# crew pelo agy + +Antes de tudo, leia `${CLAUDE_PLUGIN_ROOT}/shared/delegate.md` se ainda nao leu +nesta sessao: quando delegar e quando nao, como escrever a task, o gate, como +ler o veredito, a revisao obrigatoria do diff contra a fonte da verdade, os +arquivos de segredo e a pergunta ao usuario antes do primeiro run. Aqui esta +so o que e do agy. O agy e o backend padrao (`"backend": "agy"`). + +## O run + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" delegate /caminho/TASK.json [--model slug] [--no-quota-probe] +``` + +O script roda `agy -p --model --output-format json --json-schema + --print-timeout s --dangerously-skip-permissions` na raiz do +repo. O skip-permissions e necessario (sem ele `run_command` e auto-negado +headless e o worker nao roda teste); os hooks sao o limite. Diga isso ao +usuario na primeira pergunta. O `--sandbox` do agy nao confinou escrita +nenhuma no lab: nao conte com ele. + +## Modelo e escalada + +- Comece no `gemini-3.8-flash-high` (padrao). +- Se o Flash nao der conta de uma fatia (gate vermelho ate o limite, ou diff + que nao sobrevive a revisao), suba para `claude-sonnet-5-5-medium`. +- **Nunca Opus pelo agy.** Os modelos Claude do agy dividem uma quota semanal, + e o Opus a esgota em poucas fatias. +- O modelo e validado contra `agy models` antes de rodar. + +## Quota + +O preflight le `agy -p /usage` (responde sem turno de modelo) e recusa, saida +2, se o grupo do modelo tiver menos que `min_quota` (padrao 0.05) restante, +dizendo quando renova. `--no-quota-probe` pula. Com a quota esgotada, o +`opencode` e a alternativa gratis (skill `crew:opencode`). + +## Antes do primeiro run + +- **Nenhum `.agents/hooks.json` no repo.** O agy le um arquivo de hooks por + workspace; o script escreve o dele no comeco do run, remove no fim (tambem em + SIGTERM/SIGHUP) e recusa se ja existir. Depois de um SIGKILL o proximo run + recusa e diz qual arquivo remover. +- **Os hooks tem que carregar.** Sem `SessionStart` registrado o veredito falha + com "hooks nao carregaram". A causa nunca foi reproduzida: os hooks + dispararam headless ate em pastas fora de `trustedWorkspaces`. Hipoteses, + nenhuma testada: `hooks.json` invalido, hooks desligados nesta versao, + confianca no workspace. Diga isso ao usuario; nao edite `~/.gemini`. +- Testado so no agy **1.3.4**; outra versao vira aviso no veredito. + +## O guard e o gate no agy + +- `PreToolUse` com allowlist de ferramentas: so as de arquivo e o + `run_command`; rede, subagente, workflow e MCP sao negados. +- O gate roda na ferramenta `finish` (a do `--json-schema`) e no `Stop` + (`NO_TOOL_CALL` ou `TERMINAL_STEP_TYPE`), e nega o "terminei" enquanto + falhar, ate `max_gate_attempts`. + +## Comandos em segundo plano + +O `run_command` do agy 1.3.4 manda comando longo (`nx run web:typecheck`) para +uma background task depois de ~4 s, **qualquer que seja** o +`WaitMsBeforeAsync`: reescrever esse argumento num hook nao funciona (o agy +aplicou a reescrita e mandou para segundo plano mesmo assim). Depois que o +worker para, o agy espera ate 30 min por essa task. O script vigia o run: +depois de o worker terminar com gate verde, espera 15 s e encerra os comandos +que o worker deixou rodando (so processos com a marca do run, descendentes do +agy, que nao sejam MCP servers nem os hooks do gate, nunca o proprio agy). O +que foi encerrado aparece em `agy.background_killed` e num aviso. Isso e +esperado; o gate que conta e o do hook, nao o comando do worker. + +## Falhas que so o agy tem + +| falha | por que existe | +|---|---| +| print timeout do agy | o agy sai 0 com saida parcial quando o tempo acaba | +| structured_output nulo | o agy devolve `SUCCESS` e exit 0 sem relatorio | +| `denied_actions` / auto-negada | acao recusada sai 0 | +| hooks nao carregaram | ver acima | + +## Review + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" review --diff HEAD~1 --prompt-file /scratchpad/pergunta.md +``` + +Roda com `--mode plan`, sem skip-permissions, escopo vazio e os mesmos guards +de caminho; confere que a arvore nao mudou. O modo plan tenta gravar um plano +em `~/.gemini/antigravity-cli/brain/`, e o guard nega (fora do repo). diff --git a/plugins/crew/skills/copilot/SKILL.md b/plugins/crew/skills/copilot/SKILL.md new file mode 100644 index 0000000..9f0723b --- /dev/null +++ b/plugins/crew/skills/copilot/SKILL.md @@ -0,0 +1,95 @@ +--- +name: copilot +description: Especificos do backend copilot (GitHub Copilot CLI) do plugin crew - plano Free so com --model auto, cada run pago em AI credits, worker sem shell e com o gate inteiro no agentStop, COPILOT_ALLOW_ALL so no processo do run, e o worker que declara checks que nao pode ter rodado. Use quando for delegar uma fatia ou pedir review pelo Copilot CLI. +--- + +# crew pelo Copilot CLI + +Antes de tudo, leia `${CLAUDE_PLUGIN_ROOT}/shared/delegate.md` se ainda nao leu +nesta sessao: quando delegar e quando nao, como escrever a task, o gate, como +ler o veredito, a revisao obrigatoria do diff contra a fonte da verdade, os +arquivos de segredo e a pergunta ao usuario antes do primeiro run. Aqui esta +so o que e do Copilot CLI. + +## Quando usar + +Raramente, e sabendo que custa: no plano **Free** nao ha quota gratis, cada +run gasta **AI credits** (0,53 num delegate pequeno com uma volta de gate e +0,41 num review, n=1 cada). Serve para uma fatia em que se queira um modelo de +outra familia (o Auto serviu gpt-5.6-luna) e em que **o gate inteiro roda sem +o worker**: ele nao tem shell, entao nao roda teste, formatter nem git. O +enunciado tem que bastar sem executar nada. Requer o `copilot` no PATH e +logado; testado so no **1.0.96** (outra versao vira aviso). + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" delegate /caminho/TASK.json --backend copilot +``` + +## Antes do primeiro run + +Pergunte ao usuario: o codigo vai para a GitHub e cada run consome creditos. +Diga tambem que o run seta `COPILOT_ALLOW_ALL=true` so no processo do copilot, +porque e a unica forma de os hooks do repo carregarem, e que isso carrega +tambem skills, plugins, MCP servers e hooks do proprio repo (o veredito avisa +os hooks que encontrar em `.github/hooks/`). Nao use num checkout em que voce +nao confie. + +## Modelo e creditos + +- So `auto` (validado; outro e recusado). Num plano pago, + `"copilot_model_override": true` libera outro slug, caminho **nunca + testado**: diga isso ao usuario se for usar. +- `max_ai_credits`: padrao e minimo 30 (o CLI recusa menos). E freio contra um + run que dispare, nao orcamento: um run normal gasta menos de 1. +- `copilot.usage` no veredito traz `ai_credits`, `premium_requests` e + `served_models`: **reporte os creditos gastos ao usuario.** + +## Sem shell + +- O run passa `--excluded-tools bash task`. **Nunca exclua `bash` sem + `task`**: excluir so o `bash` levou a 2826 chamadas de subagente `task` e + ~5,5 creditos no lab. O script sempre exclui os dois. +- Chamada de shell ou de `task` no stream de eventos reprova o run. +- O gate inteiro (escopo, `forbid`, `format`, `checks`) roda do nosso lado no + `agentStop`, que devolve `block` com a saida do gate ate + `max_gate_attempts` (**no maximo 8**: o CLI ignora o hook depois de 8 + bloqueios seguidos). Diga no `prompt` o que o worker precisa saber sobre os + comandos, porque ele nao pode roda-los para ver. + +## O worker declara checks que nao rodou + +`report.checks_run` e ainda menos confiavel aqui: no smoke o worker escreveu +`python3 -m unittest ... PASS` para um teste que ele, sem shell, nao tinha +como rodar. So o gate conta. + +## Task + +| campo | o que e | +|---|---| +| `max_gate_attempts` | no maximo 8 | +| `max_ai_credits` | padrao e minimo 30 | +| `copilot_model_override` | `true` para aceitar modelo diferente de `auto` (nao testado) | +| `scope` | nao pode cobrir `.github/hooks/`, `.github/copilot/settings*.json` nem `.claude/settings*.json`: de la o copilot carrega hooks | + +O run escreve `.github/hooks/crew-.json` e o remove no fim. Se um run +morrer por SIGKILL, o proximo recusa e diz qual arquivo remover (tambem os +`agy-worker-*.json` de antes da 0.4.0); enquanto ele existir, um `copilot` +com `COPILOT_ALLOW_ALL=true` nesta pasta carrega os hooks de um run morto. + +## Falhas que so o copilot tem + +| falha | por que existe | +|---|---| +| hooks nao carregaram (nenhum evento de hook) | sem `COPILOT_ALLOW_ALL=true` exato os hooks somem em silencio (lab) | +| chamada de shell observada / subagente observado | `bash` e `task` deviam estar excluidos; se apareceram, a exclusao falhou | +| nenhum evento `result`, ou `exitCode` diferente de 0 | o run foi cortado; o exit 0 do processo nao prova nada | +| arquivo de hooks do run mudou | o guard pode ter sido trocado durante o run | + +## Review + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" review --backend copilot --diff HEAD~1 --prompt-file /scratchpad/pergunta.md +``` + +Sem shell nem `apply_patch` (`--excluded-tools bash task apply_patch`), com o +hook negando toda escrita. Reporte os creditos. diff --git a/plugins/crew/skills/opencode/SKILL.md b/plugins/crew/skills/opencode/SKILL.md new file mode 100644 index 0000000..44961b0 --- /dev/null +++ b/plugins/crew/skills/opencode/SKILL.md @@ -0,0 +1,87 @@ +--- +name: opencode +description: Especificos do backend opencode do plugin crew - modelos gratis do Zen e os termos de dados de cada um, guard do shell mais fraco que o do agy, gate como loop externo que retoma a sessao, o $schema que o opencode grava no opencode.json do cwd. Use quando for delegar uma fatia ou pedir review pelo opencode, ou quando a quota do agy acabou. +--- + +# crew pelo opencode + +Antes de tudo, leia `${CLAUDE_PLUGIN_ROOT}/shared/delegate.md` se ainda nao leu +nesta sessao: quando delegar e quando nao, como escrever a task, o gate, como +ler o veredito, a revisao obrigatoria do diff contra a fonte da verdade, os +arquivos de segredo e a pergunta ao usuario antes do primeiro run. Aqui esta +so o que e do opencode. + +## Quando usar + +O agy continua o primario. Use `"backend": "opencode"` (ou `--backend +opencode`) quando a quota do agy acabou (o preflight do agy recusa com a data +de renovacao) ou para rodar fatias extras sem gastar quota nenhuma. Requer o +`opencode` no PATH ou em `~/.opencode/bin`; testado so no **1.19.0** (outra +versao vira aviso). + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" delegate /caminho/TASK.json --backend opencode [--model slug] +``` + +## Modelos e dados + +- Padrao: `opencode/longcat-2.5-preview-free`, o unico exercitado com o plugin + de guard e o loop externo. O modelo e validado contra `opencode models`. +- longcat, space-bunny e step-5 sao de **retencao zero** pelos docs do Zen. +- **muse-spark treina com o prompt.** So em projeto pessoal, e so se o usuario + aceitar explicitamente. +- No unico bench de qualidade (n=1, porte de um servico CJS com 16 casos), + longcat, space-bunny e muse-spark passaram o gate; o longcat manteve as + assercoes um a um, o space-bunny moveu metade para helpers; o longcat foi o + mais lento. + +## O guard e mais fraco: diga isso ao usuario + +As regras de caminho, escopo e comando sao as mesmas (um plugin do opencode +gerado no run dir manda cada chamada para `crew.py hook oc-pre`, que aplica a +politica do agy), mas: + +- nao ha allowlist de ferramenta no nivel do processo nem modo plan; +- tudo depende de o plugin carregar (sem o `SessionStart` dele, o run falha); +- os modelos do opencode escrevem mais pelo shell, entao o backend nega tambem + `cp`, `mv`, `touch`, `mkdir`, `rm`, `truncate`, `patch`. + +Nao use opencode numa fatia em que um escape custe caro. + +## Gate e relatorio + +- **O gate e um loop externo**: cada tentativa e um `opencode run -s + ` com a saida do gate. `max_gate_attempts` conta runs. +- **O relatorio e texto**: o ultimo objeto JSON da mensagem final. Pode faltar + campo; o loop pede de novo. +- Sucesso vem dos eventos JSON (nenhum `error`, ultimo `step_finish` com + `reason=stop`) mais o gate; exit 0 nao conta. + +## cwd, opencode.json e $schema + +- O opencode grava `"$schema"` no `opencode.json` do cwd, mesmo com `--dir`. + O script guarda os bytes de `opencode.json`/`.jsonc` antes de qualquer + processo do opencode e os restaura depois de cada um (vira aviso); o + preflight roda num diretorio temporario vazio. +- **Nao cubra `opencode.json` no `scope`**: a task e recusada. +- O config do run vai so pelo `OPENCODE_CONFIG_CONTENT` daquele processo + (substitui um que o usuario tenha no ambiente); nada entra na arvore nem em + `~/.config/opencode`. +- O worker carrega o `~/.claude/CLAUDE.md` do usuario, como um subagente + nativo. `"disable_claude_code": true` na task desliga. + +## Falhas que so o opencode tem + +| falha | por que existe | +|---|---| +| plugin de guard do opencode nao carregou | sem `SessionStart` o run nao teve guard rail nenhum | +| evento `error`, ultimo step sem `reason=stop` | o opencode sai 0 com erro de API ou run cortado | +| relatorio ausente | nenhum objeto JSON na mensagem final, mesmo depois de pedido de novo | + +## Review + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/crew.py" review --backend opencode --diff HEAD~1 --prompt-file /scratchpad/pergunta.md +``` + +Sem modo plan: escopo vazio e **todo** comando de shell negado pelo plugin.