From 2f96d63e471e2129167fe30d857b3a948ed4b23f Mon Sep 17 00:00:00 2001 From: 4nrry Date: Tue, 13 Oct 2026 09:47:34 -0300 Subject: [PATCH 1/7] agy-worker: Gemini como worker headless pelo agy, sob guard rails e gate (0.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez com hooks do proprio agy (.agents/hooks.json) instalados so durante o run: PreToolUse nega caminho fora do repo, escrita fora do escopo e comandos de commit/install/deploy; o gate configuravel por projeto (forbid, format com {files}, checks) roda no `finish` do --json-schema e no Stop, e nega o "terminei" enquanto falhar. O veredito nao confia no exit code: no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (lab, n=1 por caso). Modo review somente leitura com --mode plan. Smoke tests (n=1 cada, gemini-3.8-flash-low): o Stop chega com TERMINAL_STEP_TYPE quando ha --json-schema, entao um gate so em NO_TOOL_CALL nao teria rodado; um cache de gate que guardava falha prendeu o worker, que foi procurar o gate (env, git config, /proc; os tres negados) — agora so gate verde e cacheado. Sem workflow script: os runs se serializam por construcao e a revisao fica com a sessao. Sem alegacao de melhoria: nao ha run records. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 4 + README.md | 1 + plugins/agy-worker/.claude-plugin/plugin.json | 11 + plugins/agy-worker/README.md | 135 ++ plugins/agy-worker/commands/delegate.md | 22 + plugins/agy-worker/commands/review.md | 18 + plugins/agy-worker/scripts/agy_worker.py | 1300 +++++++++++++++++ plugins/agy-worker/skills/agy-worker/SKILL.md | 180 +++ .../skills/agy-worker/task.example.json | 31 + 9 files changed, 1702 insertions(+) create mode 100644 plugins/agy-worker/.claude-plugin/plugin.json create mode 100644 plugins/agy-worker/README.md create mode 100644 plugins/agy-worker/commands/delegate.md create mode 100644 plugins/agy-worker/commands/review.md create mode 100755 plugins/agy-worker/scripts/agy_worker.py create mode 100644 plugins/agy-worker/skills/agy-worker/SKILL.md create mode 100644 plugins/agy-worker/skills/agy-worker/task.example.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1b75d83..ed05f92 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,6 +10,10 @@ "name": "agent-fleet", "source": "./plugins/agent-fleet" }, + { + "name": "agy-worker", + "source": "./plugins/agy-worker" + }, { "name": "game-facts", "source": "./plugins/game-facts" diff --git a/README.md b/README.md index 29ae660..1ed6b63 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. | +| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never trusts the exit code — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode. | | [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/agy-worker/.claude-plugin/plugin.json b/plugins/agy-worker/.claude-plugin/plugin.json new file mode 100644 index 0000000..e288aa9 --- /dev/null +++ b/plugins/agy-worker/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "agy-worker", + "displayName": "Agy Worker", + "version": "0.1.0", + "author": { + "name": "4nrry", + "url": "https://github.com/4nrry" + }, + "license": "MIT", + "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (caminho fora do repo, escrita fora do escopo e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca confia no exit code, porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." +} diff --git a/plugins/agy-worker/README.md b/plugins/agy-worker/README.md new file mode 100644 index 0000000..07f0950 --- /dev/null +++ b/plugins/agy-worker/README.md @@ -0,0 +1,135 @@ +# agy-worker + +Usa os modelos da Google como worker barato **sem brigar com o harness do +Claude**: o Claude planeja, escreve a task, define escopo e gate e revisa o +diff; o Antigravity CLI (`agy`) com Gemini executa headless, uma fatia por vez, +sob guard rails que existem so durante o run. + +``` +/plugin install agy-worker@4nrry +``` + +Requer `agy` 1.3.4 autenticado, `git`, `python3` (so stdlib) e Linux (`/proc`, +para listar processos que sobraram). Nenhum daemon, nenhum MCP server e nenhum +hook do Claude Code: os hooks que este plugin instala sao **do agy**, escritos em +`.agents/hooks.json` no comeco do run e removidos no fim. + +## Componentes + +| componente | o que faz | +|---|---| +| `scripts/agy_worker.py` | `delegate`, `review`, `gate`, os handlers de hook do agy e `--self-test`. Um arquivo | +| `skills/agy-worker/` | quando delegar e quando nao, como escrever a task, como ler o veredito, a revisao obrigatoria | +| `skills/agy-worker/task.example.json` | uma task completa; o self-test a valida | +| `commands/delegate.md`, `commands/review.md` | `/agy-worker:delegate` e `/agy-worker:review` | + +## 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` (`*`): nega caminho absoluto fora do repo, `~`, `..`, + `$HOME`; escrita fora dos globs de `scope`; comandos de commit, push, + install, deploy, migracao, rede, processos e edicao pelo shell; e + subagentes. 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 por tudo **menos** o exit code. +5. 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 agy-worker)/runs/`, +fora da arvore. 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 na skill. + +## Smoke tests desta versao + +Tres 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 | + +## 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; um comando que monta o caminho em tempo de + execucao passa pelo guard de caminho (a checagem de escopo pos-run e que pega + a escrita). +- Runs em paralelo, um worktree por fatia: nao testado. +- Comportamento com workspace nao confiavel pelo agy: o script detecta (sem + `SessionStart`, o run falha), mas o caminho de recuperacao nao foi exercitado. +- Modelos Claude ou GPT pelo agy: a quota deles estava zerada no dia; o + preflight recusou, como devia, e nada alem disso foi testado. + +## 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 `agy_worker.py --self-test`: 73 asserções sem chamar o agy, +num repo git temporario. Cobrem as decisoes do guard (caminho, escopo, comandos, +`finish`, gate isento so sem encadeamento), 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. diff --git a/plugins/agy-worker/commands/delegate.md b/plugins/agy-worker/commands/delegate.md new file mode 100644 index 0000000..7b45fe2 --- /dev/null +++ b/plugins/agy-worker/commands/delegate.md @@ -0,0 +1,22 @@ +--- +description: Delega uma fatia bem delimitada ao Gemini pelo agy, sob guard rails e gate, e devolve um veredito JSON +argument-hint: [--model slug] [--no-quota-probe] +--- + +Leia a skill `agy-worker` antes, se ainda nao leu nesta sessao: ela diz se esta +fatia deve ser delegada e como a task tem que estar escrita. + +Rode, com `run_in_background: true` porque o run pode passar de 10 minutos: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate $ARGUMENTS +``` + +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 da skill. Nao commite antes disso, e + nao commite sem o usuario pedir. diff --git a/plugins/agy-worker/commands/review.md b/plugins/agy-worker/commands/review.md new file mode 100644 index 0000000..6824433 --- /dev/null +++ b/plugins/agy-worker/commands/review.md @@ -0,0 +1,18 @@ +--- +description: Segunda opiniao somente leitura do Gemini pelo agy (--mode plan, relatorio com schema) +argument-hint: [--diff HEAD~1] [--model slug] +--- + +Rode: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review $ARGUMENTS +``` + +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/agy-worker/scripts/agy_worker.py b/plugins/agy-worker/scripts/agy_worker.py new file mode 100755 index 0000000..4f2c9bd --- /dev/null +++ b/plugins/agy-worker/scripts/agy_worker.py @@ -0,0 +1,1300 @@ +#!/usr/bin/env python3 +"""agy-worker: run Google's Antigravity CLI (`agy`) 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 (--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) + --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. + +Stdlib only. State lives in `$(git rev-parse --git-path agy-worker)`, 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. +""" + +import argparse +import fnmatch +import hashlib +import json +import os +import re +import shlex +import shutil +import signal +import subprocess +import sys +import tempfile +import time + +HOOK_FILE = ".agents/hooks.json" +STATE_NAME = "agy-worker" +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 +MAX_PROMPT_BYTES = 100_000 # one argv string is capped at 128 KiB on Linux +MAX_DIFF_BYTES = 60_000 +TAIL_CHARS = 6000 +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"} + +# Tools whose effects the hooks cannot see or bound (subagents may not carry +# workspace hooks; schedules outlive the run; images cost quota). +DENY_TOOLS = re.compile(r"^(invoke_subagent|define_subagent|manage_subagents|" + r"schedule|generate_image|browser_.*)$") + +SYSTEM_PATHS = ("/dev/null", "/dev/stdout", "/dev/stderr", "/usr/bin", + "/bin", "/usr/local/bin") + +DENY_COMMANDS = [ + (r"\bgit\s+(-C\s+\S+\s+)?(push|commit|reset|clean|stash|checkout|restore|" + r"rebase|merge|switch|cherry-pick|revert|am|apply|tag|worktree|config|" + r"update-ref|filter-branch|branch\s+-[dDmM])\b", "git que altera historico, arvore ou config"), + (r"\brm\s+(-[a-zA-Z]*[rR]|--recursive)", "rm recursivo"), + (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(deploy|publish)\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"), +] + +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): + proc = subprocess.run(["git", "-C", repo, "-c", "color.ui=false", *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) -> 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") + 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"): + 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})") + 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, + "model": task.get("model", DEFAULT_MODEL), + "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", [])), + "min_quota": float(task.get("min_quota", DEFAULT_MIN_QUOTA)), + } + + +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)) + 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 the format and check commands yourself before declaring done. 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. +""" + + +# -------------------------------------------------------------------------- +# PreToolUse guard (pure) +# -------------------------------------------------------------------------- + +PATH_TOKEN = re.compile(r"""(?:^|[\s=:"'(,\[])(~[A-Za-z0-9._/+@-]*|/[A-Za-z0-9._~/+@-]*)""") + + +def path_tokens(args: dict): + def strings(v): + if isinstance(v, str): + yield v + elif isinstance(v, dict): + for k, x in v.items(): + if k not in CONTENT_KEYS: + yield from strings(x) + elif isinstance(v, list): + for x in v: + yield from strings(x) + for s in strings({k: v for k, v in args.items() if k not in CONTENT_KEYS}): + for m in PATH_TOKEN.finditer(s): + tok = m.group(1) + if tok == "~" or tok.startswith("~/"): + yield tok + elif len(tok) > 1 and (tok[1].isalnum() or tok[1] == "."): + yield tok + + +def path_allowed(tok: str, repo: str, extra) -> bool: + if tok == "~" or tok.startswith("~/"): + tok = os.path.expanduser(tok) + p = os.path.normpath(tok) + for base in (repo, *SYSTEM_PATHS, *extra): + base = os.path.normpath(os.path.expanduser(base)) + if p == base or p.startswith(base.rstrip("/") + "/"): + return True + return False + + +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: anything + chained after it (`npm test && git commit`) goes through the full check.""" + cmd = cmd.strip() + for gc in gate_cmds: + prefix = gc.split("{files}")[0].strip() + if prefix and cmd.startswith(prefix) and not re.search(r"[;&|`]|\$\(", cmd[len(prefix):]): + return True + return False + + +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"] + if name in FINISH_TOOLS: + return {"decision": "allow"} + if DENY_TOOLS.match(name): + return {"decision": "deny", "reason": f"Ferramenta {name} bloqueada neste worker: " + "o efeito dela escapa aos guard rails. Faca o trabalho voce mesmo, dentro do escopo."} + for tok in path_tokens(args): + if not path_allowed(tok, repo, ctx.get("allow_paths", [])): + return {"decision": "deny", "reason": f"Acesso fora do repositorio bloqueado: {tok}. " + f"Trabalhe so dentro de {repo}. O gate esta descrito no enunciado; nao procure por ele."} + target = args.get("TargetFile") + if isinstance(target, str) and target: + abs_t = os.path.normpath(target if os.path.isabs(target) else os.path.join(repo, target)) + rel = os.path.relpath(abs_t, repo) + if rel.startswith("..") or rel.split("/")[0] in (".git", ".agents"): + return {"decision": "deny", "reason": 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 {"decision": "deny", "reason": f"Fora do escopo desta tarefa: {rel}. Permitidos: " + f"{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(): + if not is_gate_command(cmd, ctx.get("gate_commands", [])): + for pat, label in ctx.get("deny_patterns", []): + if re.search(pat, cmd): + return {"decision": "deny", "reason": 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."} + return {"decision": "allow"} + + +# -------------------------------------------------------------------------- +# tree inspection and the gate +# -------------------------------------------------------------------------- + +def changed_paths(repo: str) -> list: + 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 + return [p for p in dict.fromkeys(paths) if p != HOOK_FILE] + + +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) -> str: + h = hashlib.sha256() + tracked = set(git(repo, "ls-files", "-z").split("\0")) + for p in sorted(changed_paths(repo)): + if in_globs(p, side_effects): + continue + h.update(p.encode() + b"\0") + if p in tracked: + h.update(git(repo, "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 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", "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["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) + try: + # AGY_WORKER_GATE lets a command tell the gate run from the + # worker's own run of the same command. + proc = subprocess.run(["bash", "-c", cmd], cwd=repo, capture_output=True, + text=True, timeout=ctx.get("gate_timeout", DEFAULT_GATE_TIMEOUT), + env={**os.environ, "AGY_WORKER_GATE": "1"}) + rc, out = proc.returncode, (proc.stdout + proc.stderr) + except subprocess.TimeoutExpired as exc: + rc = "timeout" + out = ((exc.stdout or b"").decode(errors="replace") if isinstance(exc.stdout, bytes) + else (exc.stdout 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", [])) + 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", [])) + 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 == "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}") + 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 == "pre" else {"decision": "stop"} if event == "stop" else {}) + print(json.dumps(out, ensure_ascii=False)) + return 0 + + +# -------------------------------------------------------------------------- +# 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 = None + if not stdout.strip(): + fails.append("stdout vazio") + else: + try: + env = json.loads(stdout) + 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}") + return fails, warns, env + + +def snapshot_procs(repo: str) -> dict: + """pid -> cmdline for processes whose cwd is inside the repo.""" + found = {} + for pid in os.listdir("/proc"): + if not pid.isdigit(): + continue + try: + cwd = os.readlink(f"/proc/{pid}/cwd") + if cwd == repo or cwd.startswith(repo + "/"): + with open(f"/proc/{pid}/cmdline", "rb") as fh: + found[int(pid)] = fh.read().replace(b"\0", b" ").decode(errors="replace").strip()[:200] + except OSError: + continue + return found + + +def install_hooks(repo: str, rundir: str, ctx: dict) -> bool: + """Write .agents/hooks.json. Returns whether .agents/ was created by us.""" + path = os.path.join(repo, HOOK_FILE) + if os.path.lexists(path): + 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.") + 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 = {"agy-worker": { + "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): + if not shutil.which("agy"): + raise Refusal("agy nao esta no PATH") + 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 + 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)") + + +def run_agy(cmd: list, repo: str, rundir: str, timeout_s: int): + out_p, err_p = os.path.join(rundir, "agy.stdout"), os.path.join(rundir, "agy.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) + try: + rc = proc.wait(timeout=timeout_s + 180) + 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 + + +# -------------------------------------------------------------------------- +# 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"], + "gate_timeout": task["gate_timeout"], "max_gate_attempts": task["max_gate_attempts"], + "gate_commands": task["gate"]["format"] + task["gate"]["checks"], + "deny_patterns": DENY_COMMANDS + [(p, "regra do projeto") for p in task["deny_commands"]], + } + + +def cmd_delegate(a) -> int: + signal.signal(signal.SIGTERM, _sigterm) + task = load_task(a.task) + if a.model: + task["model"] = a.model + repo = repo_root(a.repo or os.getcwd()) + git(repo, "rev-parse", "--verify", "HEAD") + 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 os.path.lexists(os.path.join(repo, HOOK_FILE)): + raise Refusal(f"{HOOK_FILE} ja existe; outra execucao em curso ou hooks seus neste workspace") + rundir = new_rundir(repo, "delegate") + eprint(f"agy-worker: run dir {rundir}") + preflight_agy(task["model"], task["min_quota"], not a.no_quota_probe, rundir) + ctx = base_ctx(repo, task, "delegate") + 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() + before = snapshot_procs(repo) + 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) + started = time.time() + try: + eprint(f"agy-worker: agy rodando ({task['model']}, timeout {task['timeout']}s)…") + rc, stdout, stderr = run_agy(cmd, repo, rundir, task["timeout"]) + finally: + remove_hooks(repo, created) + wall = round(time.time() - started) + fails, warns, env = evaluate_run(rc, stdout, stderr, "delegate", task["report_schema"]) + events = guard_events(rundir) + if not any(e.get("event") == "session_start" for e in events): + fails.append("os hooks do agy-worker 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"]) + 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 = "final (rodado apos o agy)" + if not passed: + fails.append("gate falhou no estado final da arvore") + cls = classify(changed_paths(repo), 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") + leftover = {p: c for p, c in snapshot_procs(repo).items() if p not in before and p != os.getpid()} + if leftover: + warns.append("processos novos com cwd no repo ainda vivos (nao foram encerrados): " + + "; ".join(f"{p} {c}" for p, c in leftover.items())) + verdict = { + "ok": not fails, "mode": "delegate", "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": (env or {}).get("structured_output") if isinstance(env, dict) else None, + "agy": {"rc": rc, "wall_seconds": wall, **({k: env.get(k) for k in ( + "status", "duration_seconds", "num_turns", "usage", "conversation_id")} + if isinstance(env, dict) else {})}, + "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"agy-worker: {'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: + signal.signal(signal.SIGTERM, _sigterm) + 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") + model = a.model or DEFAULT_MODEL + timeout_s = parse_duration(a.timeout) + diff_txt = "" + if a.diff: + diff_txt = git(repo, "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"agy-worker: run dir {rundir}") + 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") + with open(os.path.join(rundir, "prompt.md"), "w", encoding="utf-8") as fh: + fh.write(prompt) + ctx = {"mode": "review", "repo": repo, "scope": [], "side_effects": [], "allow_paths": [], + "gate": {"format": [], "checks": [], "forbid": []}, "gate_timeout": 60, + "max_gate_attempts": 0, "gate_commands": [], "deny_patterns": DENY_COMMANDS} + 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 = fingerprint(repo, []) + 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"agy-worker: 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) + if fingerprint(repo, []) != fp_before: + fails.append("a arvore mudou durante um review que devia ser somente leitura") + if not any(e.get("event") == "session_start" for e in guard_events(rundir)): + warns.append("hooks nao carregaram (nenhum SessionStart): o review rodou so com --mode plan") + verdict = {"ok": not fails, "mode": "review", "run_dir": rundir, "model": model, + "failures": fails, "warnings": warns, + "report": env.get("structured_output") if isinstance(env, dict) else None, + "agy": {"rc": rc, **({k: env.get(k) for k in ("status", "duration_seconds", "num_turns", + "usage")} if isinstance(env, dict) else {})}, + "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="agy-worker-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__)), "..", "skills", + "agy-worker", "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") + 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") + check(pre("read_url_content", Url="https://example.com/a/b")["decision"] == "allow", "URL nao e path") + 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") + + # 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 "$AGY_WORKER_GATE" = 1']))) + check(ok, "gate exporta AGY_WORKER_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") + r = hook("stop", {"terminationReason": "NO_TOOL_CALL"}) + check(r["decision"] == "stop", f"stop com gate verde para: {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}") + 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") + events = guard_events(rundir) + check(any(e.get("event") == "session_start" for e in events), "marcador de SessionStart") + + # 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") + + # 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) + created = install_hooks(repo, rundir, ctx) + spec = read_json(os.path.join(repo, HOOK_FILE)) + check(set(spec["agy-worker"]) == {"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: + check(True, "recusa sobrescrever") + 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="agy_worker.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("--repo") + d.add_argument("--no-quota-probe", action="store_true") + 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("--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") + 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"]) + 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"agy-worker: recusado: {exc}") + print(json.dumps({"ok": False, "refused": True, "failures": [str(exc)]}, ensure_ascii=False)) + return 2 + except KeyboardInterrupt: + eprint("agy-worker: interrompido; hooks removidos") + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/agy-worker/skills/agy-worker/SKILL.md b/plugins/agy-worker/skills/agy-worker/SKILL.md new file mode 100644 index 0000000..4021321 --- /dev/null +++ b/plugins/agy-worker/skills/agy-worker/SKILL.md @@ -0,0 +1,180 @@ +--- +name: agy-worker +description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Use quando a tarefa mencionar agy, Antigravity, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo. +--- + +# Gemini como worker, Claude como dono + +A divisao e fixa: **voce** planeja, escreve a task, define escopo e gate, le o +veredito e revisa o diff. O `agy` com Gemini **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 deste plugin e o +guard rail; esta skill e o resto, que o script nao consegue fazer por voce. + +## 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/agy_worker.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. **Nenhum `.agents/hooks.json` no repo.** O agy le um arquivo de hooks por + workspace; o script nao sobrescreve o seu e recusa. +4. **Os hooks tem que carregar.** O veredito falha com "hooks nao carregaram" + se nenhum `SessionStart` chegou. Nao e o modelo: e o agy nao confiando no + workspace. Peca ao usuario para abrir `agy` interativo uma vez na pasta. + Nao edite `~/.gemini`. + +## Escrevendo a task + +Um JSON **fora da arvore** (o scratchpad, ou +`$(git rev-parse --git-path agy-worker)/tasks/`), senao ele mesmo suja a arvore. +Uma task completa de exemplo esta em `task.example.json`, ao lado desta skill. + +| 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 | +| `model` | padrao `gemini-3.8-flash-high`; validado contra `agy models` antes de rodar | +| `timeout`, `gate_timeout`, `max_gate_attempts` | `30m`, 900 s por comando, 5 | +| `allow_paths`, `deny_commands`, `min_quota` | caminhos extra de leitura, regex extra de comando negado, quota minima (0.05) | + +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 o + Flash 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 `AGY_WORKER_GATE=1` no ambiente, caso um deles precise + saber que e o gate e nao o worker rodando o mesmo comando. + +## Rodando + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate /caminho/TASK.json +``` + +Pode passar dos 10 minutos do Bash: rode com `run_in_background` e espere a +notificacao. 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 `hooks.json` por +workspace serializam os runs. Para paralelizar, um `git worktree` por fatia, +cada um com seu estado em `git rev-parse --git-path agy-worker`. 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: + +| 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 | +| stdout vazio, `rc != 0`, `status != SUCCESS` | o basico, que o exit code sozinho nao diz | +| denied_actions / auto-negada | acao recusada sai 0 | +| hooks nao carregaram | sem `SessionStart` o run nao teve guard rail nenhum | +| 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, que pega escrita feita pelo shell | +| worker se declarou partial/blocked | o proprio relatorio dele | + +Leia tambem os `warnings`: `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 novos com cwd no +repo que ficaram vivos (o script lista e **nao** mata). + +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 o Gemini 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/agy_worker.py" review --diff HEAD~1 "o que revisar" +``` + +Roda com `--mode plan`, sem `--dangerously-skip-permissions`, com os mesmos +guards de caminho e escopo vazio, e confere que a arvore nao mudou. O diff vai +no prompt porque o revisor nao roda comando. Serve como segundo par de olhos +barato sobre um diff seu ou do worker. 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. +O `--sandbox` do agy nao confinou escrita nenhuma no lab. Um comando que monta +o caminho em tempo de execucao passa pelo guard de caminho; quem pega e a +checagem de escopo pos-run. E o worker herda o seu ambiente: `env` e +`printenv` sao negados, mas `python3 -c 'import os; ...'` nao. Nao delegue com +segredo em variavel de ambiente que o worker nao deva ver. diff --git a/plugins/agy-worker/skills/agy-worker/task.example.json b/plugins/agy-worker/skills/agy-worker/task.example.json new file mode 100644 index 0000000..976b601 --- /dev/null +++ b/plugins/agy-worker/skills/agy-worker/task.example.json @@ -0,0 +1,31 @@ +{ + "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 +} From c166e4ebab89322a0eae42b81d81ca5f6d928565 Mon Sep 17 00:00:00 2001 From: 4nrry Date: Tue, 13 Oct 2026 10:04:10 -0300 Subject: [PATCH 2/7] agy-worker: fechar os desvios do guard que a revisao achou - Ferramentas por allowlist (arquivo + run_command + finish); rede, run_workflow, MCP e ferramenta desconhecida sao negados. - Caminhos de argumento resolvidos com realpath (relativo, symlink, Cwd); cd para fora, //etc, /*, <, > e .git/.agents pelo shell negados. - git so com subcomandos de leitura, com opcoes globais e aspas (g''it) tratadas; rm -f -r, find -delete, ln, chmod, nohup, & e redirecionamento para arquivo negados. - Gate isento so sem quebra de linha, $, substituicao ou redirect. - Falsos positivos removidos: grep '/api/v1', git log --grep=publish. - git do script com core.fsmonitor=false e hooksPath=/dev/null; config, hooks, flags de indice do git e ctx.json conferidos antes e depois. - Arquivo ignorado novo ou alterado vira aviso; README e SKILL deixam de dizer que o git status pega escrita em arquivo ignorado. - SIGHUP limpa os hooks; sobra de run morto e reconhecida; timeout do gate mata o grupo; envelope JSON nao-objeto reprova; aviso de versao. - Docs: exit 0 nunca e sucesso (exit != 0 e falha), causa de hooks sem SessionStart desconhecida, consentimento antes do 1o run, nao editar a arvore durante o run, comandos sem $ARGUMENTS cru, "Nao ha run records". Self-test 155 checks; smoke delegate e review reais (n=1) ok. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- plugins/agy-worker/.claude-plugin/plugin.json | 2 +- plugins/agy-worker/README.md | 86 +++- plugins/agy-worker/commands/delegate.md | 7 +- plugins/agy-worker/commands/review.md | 9 +- plugins/agy-worker/scripts/agy_worker.py | 442 ++++++++++++++---- plugins/agy-worker/skills/agy-worker/SKILL.md | 43 +- 7 files changed, 470 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 1ed6b63..d4787d0 100644 --- a/README.md +++ b/README.md @@ -23,7 +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. | -| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never trusts the exit code — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode. | +| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never reads exit 0 as success (a non-zero exit is a failure) — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode. | | [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/agy-worker/.claude-plugin/plugin.json b/plugins/agy-worker/.claude-plugin/plugin.json index e288aa9..9c32c7e 100644 --- a/plugins/agy-worker/.claude-plugin/plugin.json +++ b/plugins/agy-worker/.claude-plugin/plugin.json @@ -7,5 +7,5 @@ "url": "https://github.com/4nrry" }, "license": "MIT", - "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (caminho fora do repo, escrita fora do escopo e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca confia no exit code, porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." + "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (allowlist de ferramentas; caminho fora do repo, .git pelo shell, escrita fora do escopo ou pelo shell e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca le exit 0 como sucesso (exit diferente de 0 e falha), porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." } diff --git a/plugins/agy-worker/README.md b/plugins/agy-worker/README.md index 07f0950..07deb36 100644 --- a/plugins/agy-worker/README.md +++ b/plugins/agy-worker/README.md @@ -9,10 +9,18 @@ sob guard rails que existem so durante o run. /plugin install agy-worker@4nrry ``` -Requer `agy` 1.3.4 autenticado, `git`, `python3` (so stdlib) e Linux (`/proc`, -para listar processos que sobraram). Nenhum daemon, nenhum MCP server e nenhum -hook do Claude Code: os hooks que este plugin instala sao **do agy**, escritos em -`.agents/hooks.json` no comeco do run e removidos no fim. +Requer `agy` autenticado, `git`, `python3` (so stdlib) e Linux (`/proc`, para +listar processos que sobraram). So foi exercitado no agy **1.3.4**; 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. Nenhum daemon, nenhum MCP server e nenhum hook do Claude Code: os +hooks que este plugin instala sao **do agy**, escritos em `.agents/hooks.json` +no comeco do run e removidos no fim (inclusive em SIGTERM e SIGHUP; depois de +um SIGKILL o proximo run recusa e diz qual arquivo remover). + +**O codigo sai da maquina.** Delegar ou pedir review manda o prompt, os +arquivos que o worker ler e o diff para a Google, e gasta a quota do agy. A +skill manda o Claude perguntar antes do primeiro run na sessao. ## Componentes @@ -30,24 +38,36 @@ hook do Claude Code: os hooks que este plugin instala sao **do agy**, escritos e 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` (`*`): nega caminho absoluto fora do repo, `~`, `..`, - `$HOME`; escrita fora dos globs de `scope`; comandos de commit, push, - install, deploy, migracao, rede, processos e edicao pelo shell; e - subagentes. Na ferramenta `finish` (a do `--json-schema`) roda o **gate** - e nega o "terminei" enquanto ele falhar; + - `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 por tudo **menos** o exit code. +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. 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 agy-worker)/runs/`, -fora da arvore. Nada e commitado; em falha, nada e revertido. +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 @@ -80,7 +100,11 @@ no relatorio, o ponto `format` no gate e a revisao obrigatoria na skill. ## Smoke tests desta versao -Tres runs, n=1 cada, `gemini-3.8-flash-low`, repo descartavel com um modulo +**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 | @@ -88,18 +112,35 @@ Python e `unittest`: | 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; um comando que monta o caminho em tempo de - execucao passa pelo guard de caminho (a checagem de escopo pos-run e que pega - a escrita). + 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. - Runs em paralelo, um worktree por fatia: nao testado. -- Comportamento com workspace nao confiavel pelo agy: o script detecta (sem - `SessionStart`, o run falha), mas o caminho de recuperacao nao foi exercitado. +- 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. @@ -125,9 +166,14 @@ propria sessao (delegate, revisao, commit, proxima), nao um fan-out. ## Verificacao -`just check` roda `agy_worker.py --self-test`: 73 asserções sem chamar o agy, -num repo git temporario. Cobrem as decisoes do guard (caminho, escopo, comandos, -`finish`, gate isento so sem encadeamento), o handler quebrado negando em vez de +`just check` roda `agy_worker.py --self-test`: 155 assercoes sem chamar o agy, +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 diff --git a/plugins/agy-worker/commands/delegate.md b/plugins/agy-worker/commands/delegate.md index 7b45fe2..c73e8eb 100644 --- a/plugins/agy-worker/commands/delegate.md +++ b/plugins/agy-worker/commands/delegate.md @@ -9,9 +9,14 @@ fatia deve ser delegada e como a task tem que estar escrita. Rode, com `run_in_background: true` porque o run pode passar de 10 minutos: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate $ARGUMENTS +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate '' [--model slug] [--no-quota-probe] ``` +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 e gasta a quota dele. + Quando terminar: 1. Saida 2 = recusado antes de rodar. Mostre o motivo ao usuario; nada rodou. diff --git a/plugins/agy-worker/commands/review.md b/plugins/agy-worker/commands/review.md index 6824433..d5a6bc7 100644 --- a/plugins/agy-worker/commands/review.md +++ b/plugins/agy-worker/commands/review.md @@ -6,9 +6,16 @@ argument-hint: [--diff HEAD~1] [--model slug] Rode: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review $ARGUMENTS +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review [--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 +(`--diff`, `--model`) vao na linha. Se for o primeiro `delegate` ou `review` da +sessao, pergunte antes ao usuario: o codigo vai para a Google e gasta a quota +dele. + Sem `--diff`, o revisor so le arquivos; passe o intervalo quando a pergunta for sobre uma mudanca. diff --git a/plugins/agy-worker/scripts/agy_worker.py b/plugins/agy-worker/scripts/agy_worker.py index 4f2c9bd..50bf14c 100755 --- a/plugins/agy-worker/scripts/agy_worker.py +++ b/plugins/agy-worker/scripts/agy_worker.py @@ -44,6 +44,7 @@ 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 @@ -63,19 +64,33 @@ "ReplacementChunks", "Instruction", "Description", "toolSummary", "toolAction", "Query", "Content", "Message", "Summary"} -# Tools whose effects the hooks cannot see or bound (subagents may not carry -# workspace hooks; schedules outlive the run; images cost quota). -DENY_TOOLS = re.compile(r"^(invoke_subagent|define_subagent|manage_subagents|" - r"schedule|generate_image|browser_.*)$") +# 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+(-C\s+\S+\s+)?(push|commit|reset|clean|stash|checkout|restore|" - r"rebase|merge|switch|cherry-pick|revert|am|apply|tag|worktree|config|" - r"update-ref|filter-branch|branch\s+-[dDmM])\b", "git que altera historico, arvore ou config"), - (r"\brm\s+(-[a-zA-Z]*[rR]|--recursive)", "rm recursivo"), + (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" @@ -84,7 +99,8 @@ (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(deploy|publish)\b", "deploy ou publish"), + (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" @@ -96,7 +112,7 @@ (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"(^|[\s=:'\"(/<>;&|])\.\.($|[\s/;&|)'\"])", "caminho com .."), (r"\$\{?HOME\b|\bprintenv\b|(^|[\s;|&])env\s*($|[;|&])", "home ou ambiente do usuario"), ] @@ -151,7 +167,10 @@ def eprint(*a) -> None: def git(repo: str, *args: str, check: bool = True, binary: bool = False): - proc = subprocess.run(["git", "-C", repo, "-c", "color.ui=false", *args], + # 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") @@ -333,52 +352,102 @@ def compose_prompt(task: dict, repo: str) -> str: # PreToolUse guard (pure) # -------------------------------------------------------------------------- -PATH_TOKEN = re.compile(r"""(?:^|[\s=:"'(,\[])(~[A-Za-z0-9._/+@-]*|/[A-Za-z0-9._~/+@-]*)""") - - -def path_tokens(args: dict): - def strings(v): - if isinstance(v, str): - yield v - elif isinstance(v, dict): - for k, x in v.items(): - if k not in CONTENT_KEYS: - yield from strings(x) - elif isinstance(v, list): - for x in v: - yield from strings(x) - for s in strings({k: v for k, v in args.items() if k not in CONTENT_KEYS}): - for m in PATH_TOKEN.finditer(s): - tok = m.group(1) +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 - elif len(tok) > 1 and (tok[1].isalnum() or tok[1] == "."): - 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: - if tok == "~" or tok.startswith("~/"): - tok = os.path.expanduser(tok) - p = os.path.normpath(tok) + p = os.path.realpath(os.path.expanduser(tok)) for base in (repo, *SYSTEM_PATHS, *extra): - base = os.path.normpath(os.path.expanduser(base)) + 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: anything - chained after it (`npm test && git commit`) goes through the full check.""" + 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 prefix and cmd.startswith(prefix) and not re.search(r"[;&|`]|\$\(", cmd[len(prefix):]): + 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 deny(reason: str) -> dict: + return {"decision": "deny", "reason": reason} + + 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 {} @@ -387,34 +456,51 @@ def decide_pre(payload: dict, ctx: dict) -> dict: if not isinstance(args, dict): args = {} repo = ctx["repo"] + extra = ctx.get("allow_paths", []) if name in FINISH_TOOLS: return {"decision": "allow"} - if DENY_TOOLS.match(name): - return {"decision": "deny", "reason": f"Ferramenta {name} bloqueada neste worker: " - "o efeito dela escapa aos guard rails. Faca o trabalho voce mesmo, dentro do escopo."} - for tok in path_tokens(args): - if not path_allowed(tok, repo, ctx.get("allow_paths", [])): - return {"decision": "deny", "reason": f"Acesso fora do repositorio bloqueado: {tok}. " - f"Trabalhe so dentro de {repo}. O gate esta descrito no enunciado; nao procure por ele."} + 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}") target = args.get("TargetFile") if isinstance(target, str) and target: - abs_t = os.path.normpath(target if os.path.isabs(target) else os.path.join(repo, target)) - rel = os.path.relpath(abs_t, repo) + rel = os.path.relpath(resolve(target, repo), repo) if rel.startswith("..") or rel.split("/")[0] in (".git", ".agents"): - return {"decision": "deny", "reason": f"Escrita fora da area de trabalho: {target}."} + 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 {"decision": "deny", "reason": f"Fora do escopo desta tarefa: {rel}. Permitidos: " - f"{allowed}. Nao contorne pelo shell; se outro arquivo precisar mudar, diga no relatorio " - "(out_of_scope_needs)."} + 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(): - if not is_gate_command(cmd, ctx.get("gate_commands", [])): - for pat, label in ctx.get("deny_patterns", []): - if re.search(pat, cmd): - return {"decision": "deny", "reason": 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."} + 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}") + if is_gate_command(cmd, ctx.get("gate_commands", [])): + return {"decision": "allow"} + 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 {"decision": "allow"} @@ -460,7 +546,7 @@ def fingerprint(repo: str, side_effects) -> str: continue h.update(p.encode() + b"\0") if p in tracked: - h.update(git(repo, "diff", "HEAD", "--binary", "--", p, binary=True)) + 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: @@ -470,6 +556,52 @@ def fingerprint(repo: str, side_effects) -> str: 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) @@ -482,7 +614,7 @@ def added_lines(repo: str, path: str, tracked: set): return return n = 0 - for line in git(repo, "diff", "HEAD", "-U0", "--", path).splitlines(): + 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)) @@ -520,17 +652,23 @@ def run_gate(repo: str, ctx: dict, baseline: bool = False): say(f"GATE: [{kind}] {cmd}: pulado, nenhum arquivo do escopo") continue cmd = cmd.replace("{files}", quoted) + # AGY_WORKER_GATE lets a command tell the gate run from the + # worker's own run of the same command. 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, "AGY_WORKER_GATE": "1"}) try: - # AGY_WORKER_GATE lets a command tell the gate run from the - # worker's own run of the same command. - proc = subprocess.run(["bash", "-c", cmd], cwd=repo, capture_output=True, - text=True, timeout=ctx.get("gate_timeout", DEFAULT_GATE_TIMEOUT), - env={**os.environ, "AGY_WORKER_GATE": "1"}) - rc, out = proc.returncode, (proc.stdout + proc.stderr) - except subprocess.TimeoutExpired as exc: + 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 = ((exc.stdout or b"").decode(errors="replace") if isinstance(exc.stdout, bytes) - else (exc.stdout or "")) + out = out or "" if rc == 0: say(f"GATE: [{kind}] {cmd}: ok") else: @@ -697,12 +835,12 @@ def evaluate_run(rc, stdout: str, stderr: str, mode: str, schema: dict): if AUTO_DENY_MARKER in stderr: (fails if strict_denials else warns).append( "permissao auto-negada em modo headless: " + tail(stderr.strip(), 300)) - env = None + env, parsed = None, False if not stdout.strip(): fails.append("stdout vazio") else: try: - env = json.loads(stdout) + 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): @@ -721,6 +859,9 @@ def evaluate_run(rc, stdout: str, stderr: str, mode: str, schema: dict): 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 @@ -740,12 +881,24 @@ def snapshot_procs(repo: str) -> dict: return found +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) == ["agy-worker"]: + raise Refusal(f"{path} e sobra de um run do agy-worker 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) - if os.path.lexists(path): - 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.") agents = os.path.dirname(path) created = not os.path.isdir(agents) os.makedirs(agents, exist_ok=True) @@ -778,9 +931,16 @@ def remove_hooks(repo: str, created_dir: bool) -> None: pass -def preflight_agy(model: str, min_quota: float, probe_quota: bool, rundir: str): +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: @@ -788,7 +948,7 @@ def preflight_agy(model: str, min_quota: float, probe_quota: bool, rundir: str): if model not in slugs: raise Refusal(f"modelo {model!r} nao existe nesta conta. Disponiveis: {', '.join(slugs)}") if not probe_quota: - return + 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: @@ -800,6 +960,7 @@ def preflight_agy(model: str, min_quota: float, probe_quota: bool, rundir: str): 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): @@ -853,6 +1014,14 @@ 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) + + # -------------------------------------------------------------------------- # subcommands # -------------------------------------------------------------------------- @@ -868,7 +1037,7 @@ def base_ctx(repo, task, mode): def cmd_delegate(a) -> int: - signal.signal(signal.SIGTERM, _sigterm) + trap_signals() task = load_task(a.task) if a.model: task["model"] = a.model @@ -878,11 +1047,10 @@ def cmd_delegate(a) -> int: if dirty: raise Refusal("arvore suja; commite ou guarde antes (o diff do worker tem que ser so dele): " + ", ".join(dirty[:10])) - if os.path.lexists(os.path.join(repo, HOOK_FILE)): - raise Refusal(f"{HOOK_FILE} ja existe; outra execucao em curso ou hooks seus neste workspace") + refuse_existing_hooks(repo) rundir = new_rundir(repo, "delegate") eprint(f"agy-worker: run dir {rundir}") - preflight_agy(task["model"], task["min_quota"], not a.no_quota_probe, rundir) + pre_warns = preflight_agy(task["model"], task["min_quota"], not a.no_quota_probe, rundir) ctx = base_ctx(repo, task, "delegate") write_json(os.path.join(rundir, "ctx.json"), ctx) write_json(os.path.join(rundir, "task.json"), task) @@ -893,6 +1061,7 @@ def cmd_delegate(a) -> int: fh.write(prompt) head = git(repo, "rev-parse", "HEAD").strip() before = snapshot_procs(repo) + integ_before, ignored_before = integrity_state(repo, rundir), ignored_state(repo) cmd = ["agy", "-p", prompt, "--model", task["model"], "--output-format", "json", "--json-schema", schema_path, "--print-timeout", f"{task['timeout']}s", "--dangerously-skip-permissions"] @@ -905,6 +1074,11 @@ def cmd_delegate(a) -> int: remove_hooks(repo, created) wall = round(time.time() - started) fails, warns, env = evaluate_run(rc, stdout, stderr, "delegate", task["report_schema"]) + warns = pre_warns + warns + tampered = diff_state(integ_before, integrity_state(repo, rundir)) + 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 not any(e.get("event") == "session_start" for e in events): fails.append("os hooks do agy-worker nao carregaram (nenhum SessionStart registrado): " @@ -931,6 +1105,10 @@ def cmd_delegate(a) -> int: 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}") leftover = {p: c for p, c in snapshot_procs(repo).items() if p not in before and p != os.getpid()} if leftover: warns.append("processos novos com cwd no repo ainda vivos (nao foram encerrados): " @@ -957,7 +1135,7 @@ def cmd_delegate(a) -> int: def cmd_review(a) -> int: - signal.signal(signal.SIGTERM, _sigterm) + trap_signals() repo = repo_root(a.repo or os.getcwd()) question = " ".join(a.prompt).strip() if a.prompt_file: @@ -974,7 +1152,7 @@ def cmd_review(a) -> int: timeout_s = parse_duration(a.timeout) diff_txt = "" if a.diff: - diff_txt = git(repo, "diff", 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: @@ -982,7 +1160,7 @@ def cmd_review(a) -> int: "\n…(diff cortado; leia os arquivos para o resto)…" rundir = new_rundir(repo, "review") eprint(f"agy-worker: run dir {rundir}") - preflight_agy(model, a.min_quota, not a.no_quota_probe, rundir) + 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```" @@ -997,7 +1175,8 @@ def cmd_review(a) -> int: 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 = fingerprint(repo, []) + fp_before, ignored_before = fingerprint(repo, []), ignored_state(repo) + integ_before = integrity_state(repo, rundir) 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) @@ -1007,8 +1186,11 @@ def cmd_review(a) -> int: finally: remove_hooks(repo, created) fails, warns, env = evaluate_run(rc, stdout, stderr, "review", schema) - if fingerprint(repo, []) != fp_before: - fails.append("a arvore mudou durante um review que devia ser somente leitura") + warns = pre_warns + warns + 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 not any(e.get("event") == "session_start" for e in guard_events(rundir)): warns.append("hooks nao carregaram (nenhum SessionStart): o review rodou so com --mode plan") verdict = {"ok": not fails, "mode": "review", "run_dir": rundir, "model": model, @@ -1103,12 +1285,62 @@ def check(cond, msg): 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") - check(pre("read_url_content", Url="https://example.com/a/b")["decision"] == "allow", "URL nao e path") + # 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 agy-worker)/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}") # gate: nothing changed -> fail; in-scope change -> pass; forbid; out of scope ok, text, _ = run_gate(repo, ctx) @@ -1213,6 +1445,9 @@ def hook(event, payload): 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": [ @@ -1233,6 +1468,39 @@ def hook(event, payload): # 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["agy-worker"]) == {"SessionStart", "PreToolUse", "Stop"}, "hooks.json gerado") @@ -1240,8 +1508,8 @@ def hook(event, payload): try: install_hooks(repo, rundir, ctx) check(False, "sobrescreveu hooks.json existente") - except Refusal: - check(True, "recusa sobrescrever") + 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: diff --git a/plugins/agy-worker/skills/agy-worker/SKILL.md b/plugins/agy-worker/skills/agy-worker/SKILL.md index 4021321..d8f27cc 100644 --- a/plugins/agy-worker/skills/agy-worker/SKILL.md +++ b/plugins/agy-worker/skills/agy-worker/SKILL.md @@ -1,6 +1,6 @@ --- name: agy-worker -description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Use quando a tarefa mencionar agy, Antigravity, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo. +description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Use quando a tarefa mencionar agy, Antigravity, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo e o usuario quiser tira-las da sessao. --- # Gemini como worker, Claude como dono @@ -16,6 +16,14 @@ em vez de consertar o codigo; "consertou" arquivo que o Nx escreve sozinho; inventou alias que nao existia; pulou formatacao. O script deste plugin e o guard rail; esta skill 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 +a Google, gastam a quota do agy do usuario e rodam com +`--dangerously-skip-permissions` (os hooks sao o limite). 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, @@ -48,9 +56,12 @@ guard rail; esta skill e o resto, que o script nao consegue fazer por voce. 3. **Nenhum `.agents/hooks.json` no repo.** O agy le um arquivo de hooks por workspace; o script nao sobrescreve o seu e recusa. 4. **Os hooks tem que carregar.** O veredito falha com "hooks nao carregaram" - se nenhum `SessionStart` chegou. Nao e o modelo: e o agy nao confiando no - workspace. Peca ao usuario para abrir `agy` interativo uma vez na pasta. - Nao edite `~/.gemini`. + se nenhum `SessionStart` chegou. A causa **nao e conhecida**: nunca + aconteceu nos testes, e os hooks dispararam headless ate em pastas fora de + `trustedWorkspaces`. Hipoteses, nenhuma testada: `hooks.json` invalido, + hooks desligados nesta versao do agy, confianca no workspace. Diga isso ao + usuario; abrir `agy` interativo uma vez na pasta e uma sugestao, nao um + diagnostico. Nao edite `~/.gemini`. ## Escrevendo a task @@ -104,7 +115,10 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate /caminho/TASK.jso ``` Pode passar dos 10 minutos do Bash: rode com `run_in_background` e espere a -notificacao. O veredito sai em stdout e em `/verdict.json`; o +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 `hooks.json` por @@ -127,10 +141,13 @@ qual checagem pegou: | hooks nao carregaram | sem `SessionStart` o run nao teve guard rail nenhum | | 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, que pega escrita feita pelo shell | +| 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 | -Leia tambem os `warnings`: `guard_denials` (tentativas de sair do escopo; +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 agy 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 novos com cwd no repo que ficaram vivos (o script lista e **nao** mata). @@ -160,9 +177,12 @@ O gate prova o que ele mede, e so isso. Antes de aceitar: ## Segunda opiniao, somente leitura ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review --diff HEAD~1 "o que revisar" +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review --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 `;`. + Roda com `--mode plan`, sem `--dangerously-skip-permissions`, com os mesmos guards de caminho e escopo vazio, e confere que a arvore nao mudou. O diff vai no prompt porque o revisor nao roda comando. Serve como segundo par de olhos @@ -174,7 +194,10 @@ veredito. Sao um quebra-molas para um modelo cooperativo que se perde, nao um sandbox. O `--sandbox` do agy nao confinou escrita nenhuma no lab. Um comando que monta -o caminho em tempo de execucao passa pelo guard de caminho; quem pega e a -checagem de escopo pos-run. E o worker herda o seu ambiente: `env` e +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. E o worker herda o seu ambiente: `env` e `printenv` sao negados, mas `python3 -c 'import os; ...'` nao. Nao delegue com segredo em variavel de ambiente que o worker nao deva ver. From 8a1153059a19b8f31eaabb8d2669c3ed6077af57 Mon Sep 17 00:00:00 2001 From: 4nrry Date: Tue, 13 Oct 2026 10:05:17 -0300 Subject: [PATCH 3/7] agy-worker: escalar do Flash para o Sonnet medium, nunca Opus Os modelos Claude do agy dividem uma quota semanal e o Opus a esgota em poucas fatias; o Sonnet medium e a escalada quando o Flash nao der conta. Co-Authored-By: Claude Opus 5.5 --- plugins/agy-worker/skills/agy-worker/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/agy-worker/skills/agy-worker/SKILL.md b/plugins/agy-worker/skills/agy-worker/SKILL.md index d8f27cc..513ebcc 100644 --- a/plugins/agy-worker/skills/agy-worker/SKILL.md +++ b/plugins/agy-worker/skills/agy-worker/SKILL.md @@ -82,6 +82,11 @@ Uma task completa de exemplo esta em `task.example.json`, ao lado desta skill. | `timeout`, `gate_timeout`, `max_gate_attempts` | `30m`, 900 s por comando, 5 | | `allow_paths`, `deny_commands`, `min_quota` | caminhos extra de leitura, regex extra de comando negado, quota minima (0.05) | +Escolha do modelo: comece no `gemini-3.8-flash-high`. 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`. **Nao use Opus pelo agy**: os modelos +Claude do agy dividem uma quota semanal, e o Opus a esgota em poucas fatias. + O prompt: - **Nomeie a fonte da verdade e mande ler inteira antes de escrever**, com os From 5c1f5a338c1b31fe69106a836fe34d63a9a741a5 Mon Sep 17 00:00:00 2001 From: 4nrry Date: Tue, 13 Oct 2026 10:51:57 -0300 Subject: [PATCH 4/7] agy-worker: opencode como segundo backend, com a mesma politica (0.2.0) `"backend": "opencode"` na task (ou --backend) roda o opencode 1.19.0 com os modelos gratis do Zen, para quando a quota do agy acaba. O guard e um plugin do opencode gerado no run dir e carregado so pelo OPENCODE_CONFIG_CONTENT do run; ele manda cada chamada para `hook oc-pre`, que traduz a ferramenta e pergunta ao mesmo decide_pre. O gate vira um loop externo (run, gate, `run -s` com a saida do gate), o relatorio e o ultimo JSON da mensagem final, e os bytes do opencode.json sao restaurados depois de cada processo do opencode. Smoke tests n=1 no README; self-test de 155 para 209 checks. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- plugins/agy-worker/.claude-plugin/plugin.json | 4 +- plugins/agy-worker/README.md | 81 ++- plugins/agy-worker/commands/delegate.md | 14 +- plugins/agy-worker/commands/review.md | 14 +- plugins/agy-worker/scripts/agy_worker.py | 607 ++++++++++++++++-- plugins/agy-worker/skills/agy-worker/SKILL.md | 45 +- .../skills/agy-worker/task.example.json | 1 + 8 files changed, 691 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index d4787d0..cf4239e 100644 --- a/README.md +++ b/README.md @@ -23,7 +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. | -| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never reads exit 0 as success (a non-zero exit is a failure) — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode. | +| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never reads exit 0 as success (a non-zero exit is a failure) — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode, and an optional opencode backend (free Zen models) for when the agy quota runs out: same policy through a generated opencode plugin, gate as an external resume loop, weaker shell guard. | | [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/agy-worker/.claude-plugin/plugin.json b/plugins/agy-worker/.claude-plugin/plugin.json index 9c32c7e..94c6a9a 100644 --- a/plugins/agy-worker/.claude-plugin/plugin.json +++ b/plugins/agy-worker/.claude-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "agy-worker", "displayName": "Agy Worker", - "version": "0.1.0", + "version": "0.2.0", "author": { "name": "4nrry", "url": "https://github.com/4nrry" }, "license": "MIT", - "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (allowlist de ferramentas; caminho fora do repo, .git pelo shell, escrita fora do escopo ou pelo shell e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca le exit 0 como sucesso (exit diferente de 0 e falha), porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." + "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (allowlist de ferramentas; caminho fora do repo, .git pelo shell, escrita fora do escopo ou pelo shell e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca le exit 0 como sucesso (exit diferente de 0 e falha), porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Segundo backend opcional, o opencode (1.19.0) com modelos gratis do Zen, para quando a quota do agy acaba: a mesma politica, via um plugin do opencode gerado no run dir que pergunta ao mesmo script, e o gate num loop externo que retoma a sessao; guard do shell mais fraco que o do agy. Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." } diff --git a/plugins/agy-worker/README.md b/plugins/agy-worker/README.md index 07deb36..fe981c1 100644 --- a/plugins/agy-worker/README.md +++ b/plugins/agy-worker/README.md @@ -18,6 +18,11 @@ hooks que este plugin instala sao **do agy**, escritos em `.agents/hooks.json` no comeco do run e removidos no fim (inclusive em SIGTERM e SIGHUP; depois de um SIGKILL o proximo run recusa e diz qual arquivo remover). +Segundo backend, opcional: o **opencode** (`"backend": "opencode"` na task), +com os modelos gratis do Zen. Mesmo veredito, mesmas regras de caminho, escopo +e comando, guard mais fraco no shell; detalhes em +[Backend opencode](#backend-opencode). + **O codigo sai da maquina.** Delegar ou pedir review manda o prompt, os arquivos que o worker ler e o diff para a Google, e gasta a quota do agy. A skill manda o Claude perguntar antes do primeiro run na sessao. @@ -26,7 +31,7 @@ skill manda o Claude perguntar antes do primeiro run na sessao. | componente | o que faz | |---|---| -| `scripts/agy_worker.py` | `delegate`, `review`, `gate`, os handlers de hook do agy e `--self-test`. Um arquivo | +| `scripts/agy_worker.py` | `delegate`, `review`, `gate`, os handlers de hook do agy, o guard e o loop do opencode, e `--self-test`. Um arquivo | | `skills/agy-worker/` | quando delegar e quando nao, como escrever a task, como ler o veredito, a revisao obrigatoria | | `skills/agy-worker/task.example.json` | uma task completa; o self-test a valida | | `commands/delegate.md`, `commands/review.md` | `/agy-worker:delegate` e `/agy-worker:review` | @@ -144,6 +149,70 @@ Python e `unittest`: - Modelos Claude ou GPT pelo agy: a quota deles estava zerada no dia; o preflight recusou, como devia, e nada alem disso foi testado. +## 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 `agy_worker.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). + ## Por que nao ha workflow script A ideia era herdar do `agent-fleet` um workflow que abre varias delegacoes em @@ -166,7 +235,8 @@ propria sessao (delegate, revisao, commit, proxima), nao um fan-out. ## Verificacao -`just check` roda `agy_worker.py --self-test`: 155 assercoes sem chamar o agy, +`just check` roda `agy_worker.py --self-test`: 209 assercoes sem chamar o agy +nem o opencode, 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 @@ -178,4 +248,9 @@ 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. +`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. diff --git a/plugins/agy-worker/commands/delegate.md b/plugins/agy-worker/commands/delegate.md index c73e8eb..7612718 100644 --- a/plugins/agy-worker/commands/delegate.md +++ b/plugins/agy-worker/commands/delegate.md @@ -1,6 +1,6 @@ --- -description: Delega uma fatia bem delimitada ao Gemini pelo agy, sob guard rails e gate, e devolve um veredito JSON -argument-hint: [--model slug] [--no-quota-probe] +description: Delega uma fatia bem delimitada a um worker (agy com Gemini, ou opencode com modelo gratis do Zen), sob guard rails e gate, e devolve um veredito JSON +argument-hint: [--backend agy|opencode] [--model slug] [--no-quota-probe] --- Leia a skill `agy-worker` antes, se ainda nao leu nesta sessao: ela diz se esta @@ -9,13 +9,19 @@ fatia deve ser delegada e como a task tem que estar escrita. Rode, com `run_in_background: true` porque o run pode passar de 10 minutos: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate '' [--model slug] [--no-quota-probe] +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate '' [--backend agy|opencode] [--model slug] [--no-quota-probe] ``` +`--backend` sobrepoe o campo `backend` da task (padrao `agy`). Use `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 (secao "agy ou opencode" da skill). +`--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 e gasta a quota dele. +usuario: o codigo vai para a Google (agy) ou para o provedor do modelo do Zen +(opencode), e no agy gasta a quota dele. Quando terminar: diff --git a/plugins/agy-worker/commands/review.md b/plugins/agy-worker/commands/review.md index d5a6bc7..70f2c94 100644 --- a/plugins/agy-worker/commands/review.md +++ b/plugins/agy-worker/commands/review.md @@ -1,20 +1,22 @@ --- -description: Segunda opiniao somente leitura do Gemini pelo agy (--mode plan, relatorio com schema) -argument-hint: [--diff HEAD~1] [--model slug] +description: Segunda opiniao somente leitura do Gemini pelo agy (--mode plan, relatorio com schema) ou de um modelo gratis pelo opencode +argument-hint: [--backend agy|opencode] [--diff HEAD~1] [--model slug] --- Rode: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review [--diff HEAD~1] [--model slug] --prompt-file /caminho/pergunta.md +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review [--backend agy|opencode] [--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 -(`--diff`, `--model`) vao na linha. Se for o primeiro `delegate` ou `review` da -sessao, pergunte antes ao usuario: o codigo vai para a Google e gasta a quota -dele. +(`--backend`, `--diff`, `--model`) vao na linha. `--backend opencode` (padrao +`agy`) e para quando a quota do agy acabou: sem modo plan, o plugin de guard +nega toda escrita e todo comando de shell. Se for o primeiro `delegate` ou `review` da +sessao, pergunte antes ao usuario: o codigo vai para a Google (agy) ou para o +provedor do modelo do Zen (opencode), e no agy gasta a quota dele. Sem `--diff`, o revisor so le arquivos; passe o intervalo quando a pergunta for sobre uma mudanca. diff --git a/plugins/agy-worker/scripts/agy_worker.py b/plugins/agy-worker/scripts/agy_worker.py index 50bf14c..bd7650d 100755 --- a/plugins/agy-worker/scripts/agy_worker.py +++ b/plugins/agy-worker/scripts/agy_worker.py @@ -5,9 +5,10 @@ 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 (--mode plan + --json-schema) + 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) --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 @@ -20,6 +21,14 @@ Stdlib only. State lives in `$(git rev-parse --git-path agy-worker)`, 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). """ import argparse @@ -27,6 +36,7 @@ import hashlib import json import os +import pathlib import re import shlex import shutil @@ -48,6 +58,12 @@ MAX_PROMPT_BYTES = 100_000 # one argv string is capped at 128 KiB on Linux MAX_DIFF_BYTES = 60_000 TAIL_CHARS = 6000 +BACKENDS = ("agy", "opencode") +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") TIMEOUT_MARKER = "[agy] print timeout" AUTO_DENY_MARKER = "auto-denied" NO_OUTPUT_MARKER = "run ended with no output" @@ -116,6 +132,31 @@ (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 +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", @@ -234,11 +275,12 @@ def write_json(path: str, obj) -> None: # task file # -------------------------------------------------------------------------- -def load_task(path: str) -> dict: +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"]) @@ -294,6 +336,14 @@ def normalize_task(task: dict) -> dict: 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") try: parse_duration(task.get("timeout", DEFAULT_TIMEOUT)) parse_duration(task.get("gate_timeout", DEFAULT_GATE_TIMEOUT)) @@ -307,7 +357,9 @@ def normalize_task(task: dict) -> dict: "gate": {"format": list(gate.get("format", [])), "checks": list(gate.get("checks", [])), "forbid": list(gate.get("forbid", []))}, "report_schema": schema, - "model": task.get("model", DEFAULT_MODEL), + "backend": backend, + "disable_claude_code": task.get("disable_claude_code", False), + "model": task.get("model", OPENCODE_DEFAULT_MODEL if backend == "opencode" else DEFAULT_MODEL), "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)), @@ -345,7 +397,16 @@ def compose_prompt(task: dict, repo: str) -> str: {steps_txt} {note}Run the format and check commands yourself before declaring done. 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") != "opencode": + return "" + 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=(',', ':'))}\n") # -------------------------------------------------------------------------- @@ -504,6 +565,30 @@ def decide_pre(payload: dict, ctx: dict) -> dict: 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 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 # -------------------------------------------------------------------------- @@ -738,6 +823,15 @@ def hook_main(event: str, rundir: str) -> int: 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 == "pre": name = (payload.get("toolCall") or {}).get("name", "") if name in FINISH_TOOLS and ctx.get("mode") == "delegate": @@ -781,7 +875,7 @@ def hook_main(event: str, rundir: str) -> int: except OSError: pass out = ({"decision": "deny", "reason": f"guard interno falhou ({exc!r}); chamada negada por seguranca"} - if event == "pre" else {"decision": "stop"} if event == "stop" else {}) + if event in ("pre", "oc-pre") else {"decision": "stop"} if event == "stop" else {}) print(json.dumps(out, ensure_ascii=False)) return 0 @@ -963,13 +1057,16 @@ def preflight_agy(model: str, min_quota: float, probe_quota: bool, rundir: str) return warns -def run_agy(cmd: list, repo: str, rundir: str, timeout_s: int): - out_p, err_p = os.path.join(rundir, "agy.stdout"), os.path.join(rundir, "agy.stderr") +def run_agy(cmd: list, repo: str, rundir: str, timeout_s: int, name: str = "agy", env=None, + grace: int = 180): + """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.""" + 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) + start_new_session=True, env=env) try: - rc = proc.wait(timeout=timeout_s + 180) + rc = proc.wait(timeout=timeout_s + grace) 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): @@ -1022,6 +1119,246 @@ def trap_signals() -> None: signal.signal(sig, _sigterm) +# -------------------------------------------------------------------------- +# opencode backend: guard plugin outside the tree, external gate loop +# -------------------------------------------------------------------------- + +# No policy in here: every tool call is forwarded to `agy_worker.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 = """// agy-worker guard for opencode, generated for one run; loaded only through +// OPENCODE_CONFIG_CONTENT of that run. The policy is agy_worker.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, "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="agy-worker-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"]) + 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"agy-worker: 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)} + + # -------------------------------------------------------------------------- # subcommands # -------------------------------------------------------------------------- @@ -1032,25 +1369,34 @@ def base_ctx(repo, task, mode): "allow_paths": task["allow_paths"], "gate": task["gate"], "gate_timeout": task["gate_timeout"], "max_gate_attempts": task["max_gate_attempts"], "gate_commands": task["gate"]["format"] + task["gate"]["checks"], - "deny_patterns": DENY_COMMANDS + [(p, "regra do projeto") for p in task["deny_commands"]], + "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) - if a.model: - task["model"] = a.model + task = load_task(a.task, backend=a.backend, model=a.model) + opencode = task["backend"] == "opencode" repo = repo_root(a.repo or os.getcwd()) git(repo, "rev-parse", "--verify", "HEAD") 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])) - refuse_existing_hooks(repo) + if not opencode: + refuse_existing_hooks(repo) rundir = new_rundir(repo, "delegate") eprint(f"agy-worker: run dir {rundir}") - pre_warns = preflight_agy(task["model"], task["min_quota"], not a.no_quota_probe, 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("agy-worker: o preflight do opencode reescreveu opencode.json; restaurado") + else: + pre_warns = preflight_agy(task["model"], task["min_quota"], not a.no_quota_probe, rundir) ctx = base_ctx(repo, task, "delegate") write_json(os.path.join(rundir, "ctx.json"), ctx) write_json(os.path.join(rundir, "task.json"), task) @@ -1062,18 +1408,25 @@ def cmd_delegate(a) -> int: head = git(repo, "rev-parse", "HEAD").strip() before = snapshot_procs(repo) integ_before, ignored_before = integrity_state(repo, rundir), ignored_state(repo) - 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) started = time.time() - try: - eprint(f"agy-worker: agy rodando ({task['model']}, timeout {task['timeout']}s)…") - rc, stdout, stderr = run_agy(cmd, repo, rundir, task["timeout"]) - finally: - remove_hooks(repo, created) - wall = round(time.time() - started) - fails, warns, env = evaluate_run(rc, stdout, stderr, "delegate", task["report_schema"]) + if opencode: + fails, warns, report, run_info = drive_opencode(oc, task, repo, rundir, prompt, ctx, saved) + run_info["wall_seconds"] = round(time.time() - started) + 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) + try: + eprint(f"agy-worker: agy rodando ({task['model']}, timeout {task['timeout']}s)…") + rc, stdout, stderr = run_agy(cmd, repo, rundir, task["timeout"]) + 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 {})} warns = pre_warns + warns tampered = diff_state(integ_before, integrity_state(repo, rundir)) if tampered: @@ -1081,7 +1434,8 @@ def cmd_delegate(a) -> int: f"run; o veredito abaixo nao e confiavel: {tampered}") events = guard_events(rundir) if not any(e.get("event") == "session_start" for e in events): - fails.append("os hooks do agy-worker nao carregaram (nenhum SessionStart registrado): " + fails.append(("o plugin de guard do opencode nao carregou" if opencode else + "os hooks do agy-worker 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)") @@ -1114,16 +1468,15 @@ def cmd_delegate(a) -> int: warns.append("processos novos com cwd no repo ainda vivos (nao foram encerrados): " + "; ".join(f"{p} {c}" for p, c in leftover.items())) verdict = { - "ok": not fails, "mode": "delegate", "run_dir": rundir, "model": task["model"], + "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": (env or {}).get("structured_output") if isinstance(env, dict) else None, - "agy": {"rc": rc, "wall_seconds": wall, **({k: env.get(k) for k in ( - "status", "duration_seconds", "num_turns", "usage", "conversation_id")} - if isinstance(env, dict) else {})}, + "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.", } @@ -1148,7 +1501,8 @@ def cmd_review(a) -> int: 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") - model = a.model or DEFAULT_MODEL + opencode = a.backend == "opencode" + model = a.model or (OPENCODE_DEFAULT_MODEL if opencode else DEFAULT_MODEL) timeout_s = parse_duration(a.timeout) diff_txt = "" if a.diff: @@ -1160,44 +1514,75 @@ def cmd_review(a) -> int: "\n…(diff cortado; leia os arquivos para o resto)…" rundir = new_rundir(repo, "review") eprint(f"agy-worker: run dir {rundir}") - pre_warns = preflight_agy(model, a.min_quota, not a.no_quota_probe, 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("agy-worker: o preflight do opencode reescreveu opencode.json; restaurado") + 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") + 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": [], "gate": {"format": [], "checks": [], "forbid": []}, "gate_timeout": 60, - "max_gate_attempts": 0, "gate_commands": [], "deny_patterns": DENY_COMMANDS} + "max_gate_attempts": 0, "gate_commands": [], + "deny_patterns": REVIEW_DENY_ALL if opencode else DENY_COMMANDS} 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) - 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"agy-worker: 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 + if opencode: + env_oc = opencode_env(write_oc_guard(rundir), False) + try: + eprint(f"agy-worker: 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} + 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"agy-worker: 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 not any(e.get("event") == "session_start" for e in guard_events(rundir)): - warns.append("hooks nao carregaram (nenhum SessionStart): o review rodou so com --mode plan") - verdict = {"ok": not fails, "mode": "review", "run_dir": rundir, "model": model, - "failures": fails, "warnings": warns, - "report": env.get("structured_output") if isinstance(env, dict) else None, - "agy": {"rc": rc, **({k: env.get(k) for k in ("status", "duration_seconds", "num_turns", - "usage")} if isinstance(env, dict) else {})}, + 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)) @@ -1342,6 +1727,115 @@ def check(cond, msg): "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") + + # 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") + 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") @@ -1396,6 +1890,10 @@ def hook(event, payload): ["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"] == "stop", f"stop com gate verde para: {r}") r = hook("pre", {"toolCall": {"name": "finish", "args": {}}}) @@ -1416,6 +1914,7 @@ def hook(event, payload): 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") @@ -1530,14 +2029,16 @@ def main(argv=None) -> int: 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") + 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) @@ -1548,7 +2049,7 @@ def main(argv=None) -> int: 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"]) + h.add_argument("event", choices=["pre", "stop", "start", "oc-pre"]) h.add_argument("--state", required=True) a = p.parse_args(argv) if a.cmd == "hook": diff --git a/plugins/agy-worker/skills/agy-worker/SKILL.md b/plugins/agy-worker/skills/agy-worker/SKILL.md index 513ebcc..a5a8759 100644 --- a/plugins/agy-worker/skills/agy-worker/SKILL.md +++ b/plugins/agy-worker/skills/agy-worker/SKILL.md @@ -1,6 +1,6 @@ --- name: agy-worker -description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Use quando a tarefa mencionar agy, Antigravity, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo e o usuario quiser tira-las da sessao. +description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Segundo backend opcional: o opencode com modelos gratis do Zen, quando a quota do agy acabou ou para capacidade extra. Use quando a tarefa mencionar agy, Antigravity, opencode, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo e o usuario quiser tira-las da sessao. --- # Gemini como worker, Claude como dono @@ -16,10 +16,34 @@ em vez de consertar o codigo; "consertou" arquivo que o Nx escreve sozinho; inventou alias que nao existia; pulou formatacao. O script deste plugin e o guard rail; esta skill e o resto, que o script nao consegue fazer por voce. +## agy ou opencode + +O **agy e o primario**. Use `"backend": "opencode"` (ou `--backend opencode`) +quando a quota do agy acabou (o preflight recusa com a data de renovacao) ou +para rodar fatias extras sem gastar quota nenhuma. Diga ao usuario o que muda: + +- **O guard do shell e mais fraco.** As regras de caminho, escopo e comando sao + as mesmas (o plugin do opencode pergunta ao mesmo script), mas nao ha + allowlist de ferramenta no processo nem modo plan, e os modelos do opencode + escrevem mais pelo shell. Por isso o backend nega tambem `cp`, `mv`, `touch`, + `mkdir`, `rm`. Nao use opencode numa fatia em que um escape custe caro. +- **O gate e um loop externo**: cada tentativa e um `opencode run -s` na mesma + sessao 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. +- **Dados**: longcat (padrao), space-bunny e step-5 sao de retencao zero pelos + docs do Zen; muse-spark treina com o prompt. So use o muse-spark em projeto + pessoal, e so se o usuario aceitar. +- O worker carrega o `~/.claude/CLAUDE.md` do usuario, como um subagente + nativo. `"disable_claude_code": true` na task desliga. +- O opencode grava `"$schema"` no `opencode.json` da raiz; o script restaura os + bytes e avisa. Nao cubra `opencode.json` no `scope`: a task e recusada. + ## Pergunte antes do primeiro run Delegar e revisar mandam o prompt, os arquivos que o worker ler e o diff para -a Google, gastam a quota do agy do usuario e rodam com +a Google (agy) ou para o provedor do modelo do Zen (opencode), gastam a quota +do agy do usuario e, no agy, rodam com `--dangerously-skip-permissions` (os hooks sao o limite). Antes do primeiro `delegate` ou `review` da sessao, pergunte; depois do sim, nao precisa perguntar de novo a cada fatia. @@ -53,7 +77,7 @@ perguntar de novo a cada fatia. ``` Gate vermelho antes de comecar e o pior caso: o worker nao tem o que consertar e vai procurar o gate. -3. **Nenhum `.agents/hooks.json` no repo.** O agy le um arquivo de hooks por +3. **Nenhum `.agents/hooks.json` no repo** (so agy). O agy le um arquivo de hooks por workspace; o script nao sobrescreve o seu e recusa. 4. **Os hooks tem que carregar.** O veredito falha com "hooks nao carregaram" se nenhum `SessionStart` chegou. A causa **nao e conhecida**: nunca @@ -78,9 +102,11 @@ Uma task completa de exemplo esta em `task.example.json`, ao lado desta skill. | `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 | -| `model` | padrao `gemini-3.8-flash-high`; validado contra `agy models` antes de rodar | +| `backend` | `agy` (padrao) ou `opencode` | +| `model` | padrao `gemini-3.8-flash-high` no agy, `opencode/longcat-2.5-preview-free` no opencode; validado contra `agy models` / `opencode models` antes de rodar | +| `disable_claude_code` | so opencode: `true` para nao carregar o `~/.claude/CLAUDE.md` (padrao `false`) | | `timeout`, `gate_timeout`, `max_gate_attempts` | `30m`, 900 s por comando, 5 | -| `allow_paths`, `deny_commands`, `min_quota` | caminhos extra de leitura, regex extra de comando negado, quota minima (0.05) | +| `allow_paths`, `deny_commands`, `min_quota` | caminhos extra de leitura, regex extra de comando negado, quota minima (0.05, so agy) | Escolha do modelo: comece no `gemini-3.8-flash-high`. Se o Flash nao der conta de uma fatia (gate vermelho ate o limite, ou diff que nao sobrevive a revisao), @@ -116,7 +142,7 @@ O gate: ## Rodando ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate /caminho/TASK.json +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate /caminho/TASK.json [--backend opencode] ``` Pode passar dos 10 minutos do Bash: rode com `run_in_background` e espere a @@ -143,7 +169,9 @@ qual checagem pegou: | structured_output nulo | o agy devolve `SUCCESS` e exit 0 sem relatorio | | stdout vazio, `rc != 0`, `status != SUCCESS` | o basico, que o exit code sozinho nao diz | | denied_actions / auto-negada | acao recusada sai 0 | -| hooks nao carregaram | sem `SessionStart` o run nao teve guard rail nenhum | +| hooks nao carregaram / plugin de guard do opencode nao carregou | sem `SessionStart` o run nao teve guard rail nenhum | +| evento `error`, ultimo step sem `reason=stop` (opencode) | o opencode sai 0 com erro de API ou run cortado | +| relatorio ausente (opencode) | nenhum objeto JSON na mensagem final, mesmo depois de pedido de novo | | 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 | @@ -188,7 +216,8 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review --diff HEAD~1 --pro A pergunta vai num arquivo fora da arvore: texto livre na linha de comando quebra com aspas, parenteses ou `;`. -Roda com `--mode plan`, sem `--dangerously-skip-permissions`, com os mesmos +Com `--backend opencode`, sem modo plan: escopo vazio e todo comando de shell +negado pelo plugin. No agy, roda com `--mode plan`, sem `--dangerously-skip-permissions`, com os mesmos guards de caminho e escopo vazio, e confere que a arvore nao mudou. O diff vai no prompt porque o revisor nao roda comando. Serve como segundo par de olhos barato sobre um diff seu ou do worker. Cada finding traz `evidence`: confira no diff --git a/plugins/agy-worker/skills/agy-worker/task.example.json b/plugins/agy-worker/skills/agy-worker/task.example.json index 976b601..8b4a6bb 100644 --- a/plugins/agy-worker/skills/agy-worker/task.example.json +++ b/plugins/agy-worker/skills/agy-worker/task.example.json @@ -1,4 +1,5 @@ { + "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/*" From 7260cce2b24eb2e7e78a275c7144b32e66d4c58a Mon Sep 17 00:00:00 2001 From: 4nrry Date: Tue, 13 Oct 2026 10:55:02 -0300 Subject: [PATCH 5/7] agy-worker: segredos fora por padrao, shell sem segundo plano, processos do run encerrados - secret_globs (padrao .env*, .dev.vars, *.pem, *.key, id_rsa*...; .env.example passa; [] desliga): negado nas ferramentas de arquivo e nas palavras do comando, nos dois backends. O motivo e o destino do que o worker le, nao desconfianca do modelo. - O hook reescreve WaitMsBeforeAsync do run_command do agy: o modelo manda 5000 e o agy esperou 16 min por um nx typecheck que foi para segundo plano. - Worker e gate rodam com NX_DAEMON=false e com AGY_WORKER_RUN=; no fim, os processos que carregam a marca sao encerrados (o daemon do Nx reparenta para o init e perde a ancestralidade, mas herda o ambiente). Self-test: 223 checks. Co-Authored-By: Claude Opus 5.5 --- plugins/agy-worker/README.md | 8 ++ plugins/agy-worker/scripts/agy_worker.py | 131 +++++++++++++++++- plugins/agy-worker/skills/agy-worker/SKILL.md | 8 ++ 3 files changed, 142 insertions(+), 5 deletions(-) diff --git a/plugins/agy-worker/README.md b/plugins/agy-worker/README.md index fe981c1..c255cdc 100644 --- a/plugins/agy-worker/README.md +++ b/plugins/agy-worker/README.md @@ -133,6 +133,14 @@ Python e `unittest`: (`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 `overwrite` de `WaitMsBeforeAsync` e o encerramento dos processos marcados + vieram de um run real que travou 16 min (n=1); o self-test cobre a logica, e + o primeiro run depois da mudanca e o que vai dizer se o agy aceita o valor. - 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 diff --git a/plugins/agy-worker/scripts/agy_worker.py b/plugins/agy-worker/scripts/agy_worker.py index bd7650d..dc09f65 100755 --- a/plugins/agy-worker/scripts/agy_worker.py +++ b/plugins/agy-worker/scripts/agy_worker.py @@ -58,6 +58,18 @@ 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 = "AGY_WORKER_RUN" +WORKER_ENV = {"NX_DAEMON": "false"} BACKENDS = ("agy", "opencode") OPENCODE_DEFAULT_MODEL = "opencode/longcat-2.5-preview-free" TESTED_OPENCODE_VERSION = "1.19.0" @@ -327,7 +339,7 @@ def normalize_task(task: dict) -> dict: 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"): + 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") @@ -366,6 +378,7 @@ def normalize_task(task: dict) -> dict: "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)), } @@ -505,10 +518,35 @@ def is_gate_command(cmd: str, gate_cmds) -> bool: 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: + """agy's run_command goes asynchronous after WaitMsBeforeAsync (the model + sends 5000), and agy then waits up to 30 min for the orphan before it + exits: an Nx typecheck sat there for 16 min (LOTVS categories run, + 2026-10-13). Overwrite it so the command finishes inside the call.""" + wait = int(ctx.get("gate_timeout", DEFAULT_GATE_TIMEOUT)) * 1000 + return {"decision": "allow", "overwrite": {"WaitMsBeforeAsync": wait}} + + 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 {} @@ -532,6 +570,9 @@ def decide_pre(payload: dict, ctx: dict) -> dict: 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) @@ -551,8 +592,11 @@ def decide_pre(payload: dict, ctx: dict) -> dict: 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 {"decision": "allow"} + 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): @@ -562,6 +606,7 @@ def decide_pre(payload: dict, ctx: dict) -> dict: 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"} @@ -742,7 +787,8 @@ def run_gate(repo: str, ctx: dict, baseline: bool = False): # 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, "AGY_WORKER_GATE": "1"}) + env={**os.environ, **WORKER_ENV, "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 @@ -959,6 +1005,41 @@ def evaluate_run(rc, stdout: str, stderr: str, mode: str, schema: dict): return fails, warns, env +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.""" + 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: + with open(f"/proc/{pid}/environ", "rb") as fh: + if marker not in fh.read().split(b"\0"): + continue + with open(f"/proc/{pid}/cmdline", "rb") as fh: + hits[int(pid)] = fh.read().replace(b"\0", b" ").decode(errors="replace").strip()[:200] + except OSError: + continue + for sig in (signal.SIGTERM, signal.SIGKILL): + alive = [] + for pid in hits: + 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 + return hits + + def snapshot_procs(repo: str) -> dict: """pid -> cmdline for processes whose cwd is inside the repo.""" found = {} @@ -1165,7 +1246,7 @@ def opencode_env(guard: str, disable_claude_code: bool) -> dict: 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, "OPENCODE_DISABLE_AUTOUPDATE": "1", "OPENCODE_DISABLE_SHARE": "1", + 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"}})} @@ -1306,6 +1387,7 @@ def drive_opencode(oc: str, task: dict, repo: str, rundir: str, prompt: str, ctx 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"] @@ -1367,6 +1449,7 @@ 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 []) @@ -1398,6 +1481,7 @@ def cmd_delegate(a) -> int: 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) 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") @@ -1419,7 +1503,8 @@ def cmd_delegate(a) -> int: created = install_hooks(repo, rundir, ctx) try: eprint(f"agy-worker: agy rodando ({task['model']}, timeout {task['timeout']}s)…") - rc, stdout, stderr = run_agy(cmd, repo, rundir, task["timeout"]) + rc, stdout, stderr = run_agy(cmd, repo, rundir, task["timeout"], + env={**os.environ, **WORKER_ENV, RUN_ENV: ctx["run_id"]}) finally: remove_hooks(repo, created) fails, warns, env = evaluate_run(rc, stdout, stderr, "delegate", task["report_schema"]) @@ -1463,6 +1548,10 @@ def cmd_delegate(a) -> int: 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())) leftover = {p: c for p, c in snapshot_procs(repo).items() if p not in before and p != os.getpid()} if leftover: warns.append("processos novos com cwd no repo ainda vivos (nao foram encerrados): " @@ -1535,6 +1624,7 @@ def cmd_review(a) -> int: # 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 else DENY_COMMANDS} @@ -1662,6 +1752,25 @@ def check(cond, msg): 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") + # run_command never goes asynchronous + out = pre("run_command", CommandLine="npm test") + check(out.get("overwrite", {}).get("WaitMsBeforeAsync", 0) >= 60_000, "WaitMsBeforeAsync forcado") + 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") @@ -1833,6 +1942,18 @@ def check(cond, msg): 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") 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") diff --git a/plugins/agy-worker/skills/agy-worker/SKILL.md b/plugins/agy-worker/skills/agy-worker/SKILL.md index a5a8759..867c0f6 100644 --- a/plugins/agy-worker/skills/agy-worker/SKILL.md +++ b/plugins/agy-worker/skills/agy-worker/SKILL.md @@ -107,6 +107,7 @@ Uma task completa de exemplo esta em `task.example.json`, ao lado desta skill. | `disable_claude_code` | so opencode: `true` para nao carregar o `~/.claude/CLAUDE.md` (padrao `false`) | | `timeout`, `gate_timeout`, `max_gate_attempts` | `30m`, 900 s por comando, 5 | | `allow_paths`, `deny_commands`, `min_quota` | caminhos extra de leitura, regex extra de comando negado, quota minima (0.05, so agy) | +| `secret_globs` | nomes de arquivo que o worker nao le nem escreve; padrao `.env`, `.env.*`, `.dev.vars`, `*.pem`, `*.key`, `id_rsa*`... (`.env.example` passa). `[]` desliga | Escolha do modelo: comece no `gemini-3.8-flash-high`. Se o Flash nao der conta de uma fatia (gate vermelho ate o limite, ou diff que nao sobrevive a revisao), @@ -227,6 +228,13 @@ 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. +Os arquivos de segredo ficam de fora por padrao 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 +`run_command` do agy nunca vai para segundo plano (o hook reescreve +`WaitMsBeforeAsync`), e o que o run deixar vivo com a marca dele no ambiente, +como um daemon do Nx, e encerrado no fim; worker e gate rodam com +`NX_DAEMON=false`. O `--sandbox` do agy nao confinou escrita nenhuma no lab. 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 From ea79a203163a024fc19e40a8e3190aef1735b03d Mon Sep 17 00:00:00 2001 From: 4nrry Date: Tue, 13 Oct 2026 11:29:55 -0300 Subject: [PATCH 6/7] agy-worker: GitHub Copilot CLI como terceiro backend, sem shell (0.3.0) `"backend": "copilot"`: hooks do repo gerados por run em .github/hooks/agy-worker-.json, carregados so pelo COPILOT_ALLOW_ALL=true do processo, traduzem cada ferramenta do Copilot para a mesma decide_pre (hook cp-pre) e rodam o gate no agentStop (hook cp-stop), com o mesmo cache e limite de tentativas. bash e task sempre excluidos do modelo; shell ou subagente no stream reprova o run. Modelo fixo em auto (plano Free), com override nao testado para plano pago. O veredito le os eventos JSONL, nunca o exit code, e traz creditos gastos e modelo servido do usage file. Review somente leitura: escopo vazio no hook e apply_patch excluido. Self-test 209 -> 298 assercoes. Smoke n=1: delegate ok com gate forcado a falhar uma vez (0,529 AI credits), review ok (0,411). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- plugins/agy-worker/.claude-plugin/plugin.json | 4 +- plugins/agy-worker/README.md | 87 ++- plugins/agy-worker/commands/delegate.md | 13 +- plugins/agy-worker/commands/review.md | 15 +- plugins/agy-worker/scripts/agy_worker.py | 716 +++++++++++++++++- plugins/agy-worker/skills/agy-worker/SKILL.md | 76 +- 7 files changed, 866 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index cf4239e..6cd9f07 100644 --- a/README.md +++ b/README.md @@ -23,7 +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. | -| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never reads exit 0 as success (a non-zero exit is a failure) — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode, and an optional opencode backend (free Zen models) for when the agy quota runs out: same policy through a generated opencode plugin, gate as an external resume loop, weaker shell guard. | +| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never reads exit 0 as success (a non-zero exit is a failure) — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode, and an optional opencode backend (free Zen models) for when the agy quota runs out: same policy through a generated opencode plugin, gate as an external resume loop, weaker shell guard; and an optional GitHub Copilot CLI backend (Free plan: `--model auto` only, paid in AI credits) with repo hooks generated per run, no shell for the model and the whole gate on `agentStop`. | | [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/agy-worker/.claude-plugin/plugin.json b/plugins/agy-worker/.claude-plugin/plugin.json index 94c6a9a..ec35ff7 100644 --- a/plugins/agy-worker/.claude-plugin/plugin.json +++ b/plugins/agy-worker/.claude-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "agy-worker", "displayName": "Agy Worker", - "version": "0.2.0", + "version": "0.3.0", "author": { "name": "4nrry", "url": "https://github.com/4nrry" }, "license": "MIT", - "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (allowlist de ferramentas; caminho fora do repo, .git pelo shell, escrita fora do escopo ou pelo shell e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca le exit 0 como sucesso (exit diferente de 0 e falha), porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Segundo backend opcional, o opencode (1.19.0) com modelos gratis do Zen, para quando a quota do agy acaba: a mesma politica, via um plugin do opencode gerado no run dir que pergunta ao mesmo script, e o gate num loop externo que retoma a sessao; guard do shell mais fraco que o do agy. Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." + "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (allowlist de ferramentas; caminho fora do repo, .git pelo shell, escrita fora do escopo ou pelo shell e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca le exit 0 como sucesso (exit diferente de 0 e falha), porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Segundo backend opcional, o opencode (1.19.0) com modelos gratis do Zen, para quando a quota do agy acaba: a mesma politica, via um plugin do opencode gerado no run dir que pergunta ao mesmo script, e o gate num loop externo que retoma a sessao; guard do shell mais fraco que o do agy. Terceiro backend opcional, o GitHub Copilot CLI (1.0.96, plano Free: so `--model auto`, pago em AI credits): hooks do repo gerados por run em .github/hooks e carregados so pelo COPILOT_ALLOW_ALL=true daquele processo, a mesma politica, shell e subagentes removidos do modelo e o gate inteiro no agentStop; o veredito traz os creditos gastos e o modelo servido. Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." } diff --git a/plugins/agy-worker/README.md b/plugins/agy-worker/README.md index c255cdc..f87b2e3 100644 --- a/plugins/agy-worker/README.md +++ b/plugins/agy-worker/README.md @@ -23,15 +23,21 @@ com os modelos gratis do Zen. Mesmo veredito, mesmas regras de caminho, escopo e comando, guard mais fraco no shell; detalhes em [Backend opencode](#backend-opencode). +Terceiro backend, opcional: o **GitHub Copilot CLI** (`"backend": "copilot"`), +sem shell nenhum para o modelo e o gate inteiro do nosso lado. No plano Free +so o `--model auto` funciona e cada run gasta AI credits; detalhes em +[Backend copilot](#backend-copilot). + **O codigo sai da maquina.** Delegar ou pedir review manda o prompt, os -arquivos que o worker ler e o diff para a Google, e gasta a quota do agy. A +arquivos que o worker ler e o diff para a Google (ou para o provedor do +backend escolhido), e gasta a quota do agy (ou os creditos do Copilot). A skill manda o Claude perguntar antes do primeiro run na sessao. ## Componentes | componente | o que faz | |---|---| -| `scripts/agy_worker.py` | `delegate`, `review`, `gate`, os handlers de hook do agy, o guard e o loop do opencode, e `--self-test`. Um arquivo | +| `scripts/agy_worker.py` | `delegate`, `review`, `gate`, os handlers de hook do agy, o guard e o loop do opencode, os hooks do Copilot, e `--self-test`. Um arquivo | | `skills/agy-worker/` | quando delegar e quando nao, como escrever a task, como ler o veredito, a revisao obrigatoria | | `skills/agy-worker/task.example.json` | uma task completa; o self-test a valida | | `commands/delegate.md`, `commands/review.md` | `/agy-worker:delegate` e `/agy-worker:review` | @@ -221,6 +227,69 @@ positivo do guard, `apply_patch` (os modelos testados so usaram `write` e `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/agy-worker-.json`, escrito no comeco do run e removido no fim (inclusive SIGTERM/SIGHUP); recusa se ja existir, ou se sobrar um `agy-worker-*.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`, +`AGY_WORKER_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 @@ -243,8 +312,8 @@ propria sessao (delegate, revisao, commit, proxima), nao um fan-out. ## Verificacao -`just check` roda `agy_worker.py --self-test`: 209 assercoes sem chamar o agy -nem o opencode, +`just check` roda `agy_worker.py --self-test`: 298 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 @@ -262,3 +331,13 @@ 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. diff --git a/plugins/agy-worker/commands/delegate.md b/plugins/agy-worker/commands/delegate.md index 7612718..19cea02 100644 --- a/plugins/agy-worker/commands/delegate.md +++ b/plugins/agy-worker/commands/delegate.md @@ -1,6 +1,6 @@ --- -description: Delega uma fatia bem delimitada a um worker (agy com Gemini, ou opencode com modelo gratis do Zen), sob guard rails e gate, e devolve um veredito JSON -argument-hint: [--backend agy|opencode] [--model slug] [--no-quota-probe] +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 a skill `agy-worker` antes, se ainda nao leu nesta sessao: ela diz se esta @@ -9,19 +9,22 @@ fatia deve ser delegada e como a task tem que estar escrita. Rode, com `run_in_background: true` porque o run pode passar de 10 minutos: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate '' [--backend agy|opencode] [--model slug] [--no-quota-probe] +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate '' [--backend agy|opencode|copilot] [--model slug] [--no-quota-probe] ``` `--backend` sobrepoe o campo `backend` da task (padrao `agy`). Use `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 (secao "agy ou opencode" da skill). +`copilot` gasta AI credits a cada run, nao tem shell (o gate roda todo do nosso +lado) e so aceita `--model auto`; leia a secao "Backend copilot" da skill antes. `--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) ou para o provedor do modelo do Zen -(opencode), e no agy gasta a quota dele. +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: diff --git a/plugins/agy-worker/commands/review.md b/plugins/agy-worker/commands/review.md index 70f2c94..2a3dd85 100644 --- a/plugins/agy-worker/commands/review.md +++ b/plugins/agy-worker/commands/review.md @@ -1,12 +1,12 @@ --- -description: Segunda opiniao somente leitura do Gemini pelo agy (--mode plan, relatorio com schema) ou de um modelo gratis pelo opencode -argument-hint: [--backend agy|opencode] [--diff HEAD~1] [--model slug] +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] --- Rode: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review [--backend agy|opencode] [--diff HEAD~1] [--model slug] --prompt-file /caminho/pergunta.md +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.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, @@ -14,9 +14,12 @@ 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. `--backend opencode` (padrao `agy`) e para quando a quota do agy acabou: sem modo plan, o plugin de guard -nega toda escrita e todo comando de shell. Se for o primeiro `delegate` ou `review` da -sessao, pergunte antes ao usuario: o codigo vai para a Google (agy) ou para o -provedor do modelo do Zen (opencode), e no agy gasta a quota dele. +nega toda escrita e todo comando de shell. `--backend copilot` gasta AI +credits (0,41 num review pequeno, n=1), so aceita `--model auto` e roda sem +shell nem `apply_patch`, com o hook negando toda escrita. 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. Sem `--diff`, o revisor so le arquivos; passe o intervalo quando a pergunta for sobre uma mudanca. diff --git a/plugins/agy-worker/scripts/agy_worker.py b/plugins/agy-worker/scripts/agy_worker.py index dc09f65..1c96974 100755 --- a/plugins/agy-worker/scripts/agy_worker.py +++ b/plugins/agy-worker/scripts/agy_worker.py @@ -9,6 +9,7 @@ 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 @@ -29,6 +30,13 @@ 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/agy-worker-.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 @@ -70,12 +78,29 @@ # stopped without touching anything the user started. RUN_ENV = "AGY_WORKER_RUN" WORKER_ENV = {"NX_DAEMON": "false"} -BACKENDS = ("agy", "opencode") +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 = "agy-worker-" +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 +# 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" @@ -167,6 +192,27 @@ "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/agy-worker-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 = { @@ -356,6 +402,24 @@ def normalize_task(task: dict) -> dict: 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)) @@ -371,7 +435,10 @@ def normalize_task(task: dict) -> dict: "report_schema": schema, "backend": backend, "disable_claude_code": task.get("disable_claude_code", False), - "model": task.get("model", OPENCODE_DEFAULT_MODEL if backend == "opencode" else DEFAULT_MODEL), + "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)), @@ -383,6 +450,18 @@ def normalize_task(task: dict) -> dict: } +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 @@ -394,6 +473,12 @@ def compose_prompt(task: dict, repo: str) -> str: 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'])}. " @@ -408,7 +493,7 @@ def compose_prompt(task: dict, repo: str) -> str: {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 the format and check commands yourself before declaring done. The gate is fully described here; do not search the filesystem for it. + {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)}""" @@ -416,10 +501,12 @@ def compose_prompt(task: dict, repo: str) -> str: 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") != "opencode": + 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=(',', ':'))}\n") + f"this JSON schema: {json.dumps(task['report_schema'], separators=(',', ':'))}.{again}\n") # -------------------------------------------------------------------------- @@ -625,6 +712,51 @@ def oc_calls(tool: str, args: dict) -> list: 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): @@ -638,7 +770,9 @@ def decide_oc(payload: dict, ctx: dict) -> dict: # tree inspection and the gate # -------------------------------------------------------------------------- -def changed_paths(repo: str) -> list: +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 @@ -653,7 +787,8 @@ def changed_paths(repo: str) -> list: if i < len(recs) and recs[i]: paths.append(recs[i]) i += 1 - return [p for p in dict.fromkeys(paths) if p != HOOK_FILE] + skip = {HOOK_FILE, *ignore} + return [p for p in dict.fromkeys(paths) if p not in skip] def classify(paths, scope, side_effects) -> dict: @@ -668,10 +803,10 @@ def classify(paths, scope, side_effects) -> dict: return out -def fingerprint(repo: str, side_effects) -> str: +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)): + for p in sorted(changed_paths(repo, ignore)): if in_globs(p, side_effects): continue h.update(p.encode() + b"\0") @@ -757,7 +892,8 @@ 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["scope"], ctx.get("side_effects", [])) + 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"]: @@ -824,7 +960,7 @@ def gate_cached(rundir: str, ctx: dict, trigger: str): 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", [])) + 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: @@ -834,7 +970,7 @@ def gate_cached(rundir: str, ctx: dict, trigger: str): 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", [])) + 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) @@ -878,6 +1014,21 @@ def hook_main(event: str, rundir: str) -> int: 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": @@ -921,11 +1072,81 @@ def hook_main(event: str, rundir: str) -> int: except OSError: pass out = ({"decision": "deny", "reason": f"guard interno falhou ({exc!r}); chamada negada por seguranca"} - if event in ("pre", "oc-pre") else {"decision": "stop"} if event == "stop" else {}) - print(json.dumps(out, ensure_ascii=False)) + 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 # -------------------------------------------------------------------------- @@ -1441,6 +1662,194 @@ def drive_opencode(oc: str, task: dict, repo: str, rundir: str, prompt: str, ctx "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) 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 agy-worker 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/agy-worker-.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="agy-worker-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 # -------------------------------------------------------------------------- @@ -1461,13 +1870,15 @@ 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 not opencode: + if task["backend"] == "agy": refuse_existing_hooks(repo) rundir = new_rundir(repo, "delegate") eprint(f"agy-worker: run dir {rundir}") @@ -1478,10 +1889,18 @@ def cmd_delegate(a) -> int: finally: if restore_files(repo, saved): eprint("agy-worker: 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") @@ -1496,6 +1915,21 @@ def cmd_delegate(a) -> int: 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"agy-worker: 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", @@ -1514,11 +1948,18 @@ def cmd_delegate(a) -> int: if isinstance(env, dict) else {})} 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 not any(e.get("event") == "session_start" for e in events): + 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 agy-worker 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 agy-worker nao carregaram") + " (nenhum SessionStart registrado): " "o run nao teve guard rails; trate o diff como nao confiavel") @@ -1526,17 +1967,17 @@ def cmd_delegate(a) -> int: 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"]) + 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 = "final (rodado apos o agy)" + 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), task["scope"], task["side_effects"]) + 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"]: @@ -1590,8 +2031,14 @@ def cmd_review(a) -> int: 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 = a.backend == "opencode" - model = a.model or (OPENCODE_DEFAULT_MODEL if opencode else DEFAULT_MODEL) + 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: @@ -1610,6 +2057,10 @@ def cmd_review(a) -> int: finally: if restore_files(repo, saved): eprint("agy-worker: 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 @@ -1618,6 +2069,8 @@ def cmd_review(a) -> int: 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) @@ -1627,7 +2080,10 @@ def cmd_review(a) -> int: "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 else DENY_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) @@ -1647,6 +2103,17 @@ def cmd_review(a) -> int: 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"agy-worker: 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"] @@ -1665,7 +2132,11 @@ def cmd_review(a) -> int: 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 not any(e.get("event") == "session_start" for e in guard_events(rundir)): + if copilot: + if not any(e.get("event") in ("session_start", "pre", "stop") for e in guard_events(rundir)): + fails.append("os hooks do agy-worker 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") @@ -1894,6 +2365,120 @@ def check(cond, msg): 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/agy-worker-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 @@ -2039,6 +2624,87 @@ def hook(event, payload): 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/agy-worker-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": [], @@ -2164,13 +2830,15 @@ def main(argv=None) -> int: 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"]) + 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": diff --git a/plugins/agy-worker/skills/agy-worker/SKILL.md b/plugins/agy-worker/skills/agy-worker/SKILL.md index 867c0f6..ee6e2a0 100644 --- a/plugins/agy-worker/skills/agy-worker/SKILL.md +++ b/plugins/agy-worker/skills/agy-worker/SKILL.md @@ -1,6 +1,6 @@ --- name: agy-worker -description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Segundo backend opcional: o opencode com modelos gratis do Zen, quando a quota do agy acabou ou para capacidade extra. Use quando a tarefa mencionar agy, Antigravity, opencode, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo e o usuario quiser tira-las da sessao. +description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Segundo backend opcional: o opencode com modelos gratis do Zen, quando a quota do agy acabou ou para capacidade extra. Terceiro: o GitHub Copilot CLI sem shell, pago em AI credits. Use quando a tarefa mencionar agy, Antigravity, opencode, Copilot CLI, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo e o usuario quiser tira-las da sessao. --- # Gemini como worker, Claude como dono @@ -39,11 +39,14 @@ para rodar fatias extras sem gastar quota nenhuma. Diga ao usuario o que muda: - O opencode grava `"$schema"` no `opencode.json` da raiz; o script restaura os bytes e avisa. Nao cubra `opencode.json` no `scope`: a task e recusada. +O terceiro backend, `copilot`, tem secao propria no fim: [Backend copilot](#backend-copilot). + ## Pergunte antes do primeiro run Delegar e revisar mandam o prompt, os arquivos que o worker ler e o diff para -a Google (agy) ou para o provedor do modelo do Zen (opencode), gastam a quota -do agy do usuario e, no agy, rodam com +a Google (agy), para o provedor do modelo do Zen (opencode) ou para a GitHub +(copilot), gastam a quota do agy ou os AI credits do Copilot do usuario e, no +agy, rodam com `--dangerously-skip-permissions` (os hooks sao o limite). Antes do primeiro `delegate` ou `review` da sessao, pergunte; depois do sim, nao precisa perguntar de novo a cada fatia. @@ -102,8 +105,8 @@ Uma task completa de exemplo esta em `task.example.json`, ao lado desta skill. | `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) ou `opencode` | -| `model` | padrao `gemini-3.8-flash-high` no agy, `opencode/longcat-2.5-preview-free` no opencode; validado contra `agy models` / `opencode models` antes de rodar | +| `backend` | `agy` (padrao), `opencode` ou `copilot` | +| `model` | padrao `gemini-3.8-flash-high` no agy, `opencode/longcat-2.5-preview-free` no opencode, `auto` no copilot; validado contra `agy models` / `opencode models` antes de rodar (copilot: so `auto`, ver a secao dele) | | `disable_claude_code` | so opencode: `true` para nao carregar o `~/.claude/CLAUDE.md` (padrao `false`) | | `timeout`, `gate_timeout`, `max_gate_attempts` | `30m`, 900 s por comando, 5 | | `allow_paths`, `deny_commands`, `min_quota` | caminhos extra de leitura, regex extra de comando negado, quota minima (0.05, so agy) | @@ -243,3 +246,66 @@ aviso; dentro de diretorio ignorado (`node_modules/`, `dist/`) e fora do repo, nada pega. E o worker herda o seu ambiente: `env` e `printenv` sao negados, mas `python3 -c 'import os; ...'` nao. Nao delegue com segredo em variavel de ambiente que o worker nao deva ver. + +## Backend copilot + +Separavel do resto: tudo o que e especifico do GitHub Copilot CLI esta aqui. + +**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 serve 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. + +**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.** 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. + +**Task.** Igual as outras, mais: + +| campo | o que e | +|---|---| +| `max_gate_attempts` | no maximo 8: depois de 8 bloqueios seguidos do `agentStop` o CLI para de obedecer | +| `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_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 gate inteiro (escopo, `forbid`, `format`, `checks`) roda do nosso lado no +`agentStop`; diga no `prompt` o que o worker precisa saber sobre os comandos, +porque ele nao pode roda-los para ver. + +**Rodando.** + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate /caminho/TASK.json --backend copilot +python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review --backend copilot --diff HEAD~1 --prompt-file /scratchpad/pergunta.md +``` + +O run escreve `.github/hooks/agy-worker-.json` e o remove no fim. Se um +run morrer por SIGKILL, o proximo recusa e diz qual arquivo remover; enquanto +ele existir, um `copilot` com `COPILOT_ALLOW_ALL=true` nesta pasta carrega os +hooks de um run morto. + +**Lendo o veredito.** Alem das falhas comuns (gate, escopo, HEAD, relatorio): + +| 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 | + +`copilot.usage` traz `ai_credits`, `premium_requests` e `served_models`: +reporte os creditos gastos ao usuario. `report.checks_run` e ainda menos +confiavel aqui: no smoke o worker escreveu `PASS` para um teste que ele nao +tinha como rodar. So o gate conta. + +**Revisao.** A mesma da secao de revisao acima, sem atalho. From b35c14445e4890f342ceb906bf08511930c281c6 Mon Sep 17 00:00:00 2001 From: 4nrry Date: Tue, 13 Oct 2026 11:55:14 -0300 Subject: [PATCH 7/7] crew: agy-worker renomeado, uma skill por backend, hook de injecao e vigia de segundo plano (0.4.0) - plugins/agy-worker -> plugins/crew; scripts/agy_worker.py -> scripts/crew.py; estado no git dir em crew/ (era agy-worker/); CREW_RUN e CREW_GATE (AGY_WORKER_GATE ainda exportado); sobras de run morto da 0.3.0 reconhecidas. - skills/agy, skills/opencode, skills/copilot (crew:agy, crew:opencode, crew:copilot) so com o que e de cada CLI; a parte comum em shared/delegate.md, com a task de exemplo em shared/. - hooks/inject.sh (UserPromptSubmit): injeta a skill do backend nomeado como palavra inteira no prompt; so aconselha, sai sempre 0. - Removida a reescrita de WaitMsBeforeAsync: o agy 1.3.4 aplica e manda o comando para segundo plano mesmo assim. BackgroundWatcher encerra, 15 s depois de o worker terminar com gate verde, so os descendentes do agy com a marca do run, fora dos hooks, da linha de base e de MCP servers; nunca o agy. Provado no lab (2 runs, n=1 cada). Stop com gate verde e sem finish pede o relatorio, no maximo 2 vezes. - Aviso de processos que sobraram pela marca do run, nao pelo cwd (falso positivo com o laco de espera da sessao do Claude). Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 4 +- README.md | 2 +- plugins/agy-worker/.claude-plugin/plugin.json | 11 - plugins/agy-worker/skills/agy-worker/SKILL.md | 311 ------------ plugins/crew/.claude-plugin/plugin.json | 11 + plugins/{agy-worker => crew}/README.md | 192 +++++-- .../{agy-worker => crew}/commands/delegate.md | 21 +- .../{agy-worker => crew}/commands/review.md | 18 +- plugins/crew/hooks/hooks.json | 14 + plugins/crew/hooks/inject.sh | 39 ++ .../agy_worker.py => crew/scripts/crew.py} | 472 ++++++++++++++---- plugins/crew/shared/delegate.md | 204 ++++++++ .../shared}/task.example.json | 0 plugins/crew/skills/agy/SKILL.md | 94 ++++ plugins/crew/skills/copilot/SKILL.md | 95 ++++ plugins/crew/skills/opencode/SKILL.md | 87 ++++ 16 files changed, 1103 insertions(+), 472 deletions(-) delete mode 100644 plugins/agy-worker/.claude-plugin/plugin.json delete mode 100644 plugins/agy-worker/skills/agy-worker/SKILL.md create mode 100644 plugins/crew/.claude-plugin/plugin.json rename plugins/{agy-worker => crew}/README.md (68%) rename plugins/{agy-worker => crew}/commands/delegate.md (58%) rename plugins/{agy-worker => crew}/commands/review.md (53%) create mode 100644 plugins/crew/hooks/hooks.json create mode 100755 plugins/crew/hooks/inject.sh rename plugins/{agy-worker/scripts/agy_worker.py => crew/scripts/crew.py} (87%) create mode 100644 plugins/crew/shared/delegate.md rename plugins/{agy-worker/skills/agy-worker => crew/shared}/task.example.json (100%) create mode 100644 plugins/crew/skills/agy/SKILL.md create mode 100644 plugins/crew/skills/copilot/SKILL.md create mode 100644 plugins/crew/skills/opencode/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ed05f92..cc9ce26 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,8 +11,8 @@ "source": "./plugins/agent-fleet" }, { - "name": "agy-worker", - "source": "./plugins/agy-worker" + "name": "crew", + "source": "./plugins/crew" }, { "name": "game-facts", diff --git a/README.md b/README.md index 6cd9f07..caa2105 100644 --- a/README.md +++ b/README.md @@ -23,7 +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. | -| [agy-worker](plugins/agy-worker/) | Gemini as a cheap headless worker through the Antigravity CLI (`agy`), without fighting Claude's harness: Claude writes the task, sets scope and gate, and reviews the diff; agy runs one slice under agy's own workspace hooks, installed for the run only. The verdict never reads exit 0 as success (a non-zero exit is a failure) — in agy 1.3.4 timeouts, denied actions and an unmet `--json-schema` all exit 0 (observed, n=1 each). Plus a read-only second-opinion mode, and an optional opencode backend (free Zen models) for when the agy quota runs out: same policy through a generated opencode plugin, gate as an external resume loop, weaker shell guard; and an optional GitHub Copilot CLI backend (Free plan: `--model auto` only, paid in AI credits) with repo hooks generated per run, no shell for the model and the whole gate on `agentStop`. | +| [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/agy-worker/.claude-plugin/plugin.json b/plugins/agy-worker/.claude-plugin/plugin.json deleted file mode 100644 index ec35ff7..0000000 --- a/plugins/agy-worker/.claude-plugin/plugin.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "agy-worker", - "displayName": "Agy Worker", - "version": "0.3.0", - "author": { - "name": "4nrry", - "url": "https://github.com/4nrry" - }, - "license": "MIT", - "description": "Gemini como worker barato pelo Antigravity CLI (`agy`) headless, sem brigar com o harness do Claude: o Claude escreve a task, define escopo e gate e revisa o diff; o agy executa uma fatia por vez sob hooks do proprio agy instalados so durante o run (allowlist de ferramentas; caminho fora do repo, .git pelo shell, escrita fora do escopo ou pelo shell e comandos de commit/install/deploy negados; o gate configuravel por projeto roda no `finish` e no Stop e nega o 'terminei' enquanto falhar). O veredito nunca le exit 0 como sucesso (exit diferente de 0 e falha), porque no agy 1.3.4 timeout, acao negada e schema nao satisfeito saem 0 (observado, n=1 por caso). Mais um modo de segunda opiniao somente leitura (`--mode plan` + `--json-schema`). Segundo backend opcional, o opencode (1.19.0) com modelos gratis do Zen, para quando a quota do agy acaba: a mesma politica, via um plugin do opencode gerado no run dir que pergunta ao mesmo script, e o gate num loop externo que retoma a sessao; guard do shell mais fraco que o do agy. Terceiro backend opcional, o GitHub Copilot CLI (1.0.96, plano Free: so `--model auto`, pago em AI credits): hooks do repo gerados por run em .github/hooks e carregados so pelo COPILOT_ALLOW_ALL=true daquele processo, a mesma politica, shell e subagentes removidos do modelo e o gate inteiro no agentStop; o veredito traz os creditos gastos e o modelo servido. Um script Python stdlib, uma skill, dois comandos; nenhum hook do Claude Code, nenhum MCP. Sem alegacao de melhoria: nao ha run records, e pela regra zero do bench/PROTOCOL.md uma alegacao sem registro e marketing." -} diff --git a/plugins/agy-worker/skills/agy-worker/SKILL.md b/plugins/agy-worker/skills/agy-worker/SKILL.md deleted file mode 100644 index ee6e2a0..0000000 --- a/plugins/agy-worker/skills/agy-worker/SKILL.md +++ /dev/null @@ -1,311 +0,0 @@ ---- -name: agy-worker -description: Delegar uma fatia de codigo bem delimitada e checavel por maquina ao Gemini pelo Antigravity CLI (`agy`) headless, sob guard rails e gate, e pedir segunda opiniao somente leitura. Diz quando delegar (porte mecanico, testes a partir de spec, migracao repetitiva com gate objetivo) e quando nao (spike exploratorio, decisao de design, mudanca sem gate), como escrever a task (prompt, escopo, gate, schema do relatorio), como ler o veredito, e que o diff tem que ser revisado contra a fonte da verdade depois de todo run, porque o gate e necessario e nao suficiente. Segundo backend opcional: o opencode com modelos gratis do Zen, quando a quota do agy acabou ou para capacidade extra. Terceiro: o GitHub Copilot CLI sem shell, pago em AI credits. Use quando a tarefa mencionar agy, Antigravity, opencode, Copilot CLI, Gemini como worker, delegar para um modelo mais barato, poupar quota do Claude, segunda opiniao do Gemini, ou quando houver uma lista de fatias repetitivas com teste objetivo e o usuario quiser tira-las da sessao. ---- - -# Gemini como worker, Claude como dono - -A divisao e fixa: **voce** planeja, escreve a task, define escopo e gate, le o -veredito e revisa o diff. O `agy` com Gemini **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 deste plugin e o -guard rail; esta skill e o resto, que o script nao consegue fazer por voce. - -## agy ou opencode - -O **agy e o primario**. Use `"backend": "opencode"` (ou `--backend opencode`) -quando a quota do agy acabou (o preflight recusa com a data de renovacao) ou -para rodar fatias extras sem gastar quota nenhuma. Diga ao usuario o que muda: - -- **O guard do shell e mais fraco.** As regras de caminho, escopo e comando sao - as mesmas (o plugin do opencode pergunta ao mesmo script), mas nao ha - allowlist de ferramenta no processo nem modo plan, e os modelos do opencode - escrevem mais pelo shell. Por isso o backend nega tambem `cp`, `mv`, `touch`, - `mkdir`, `rm`. Nao use opencode numa fatia em que um escape custe caro. -- **O gate e um loop externo**: cada tentativa e um `opencode run -s` na mesma - sessao 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. -- **Dados**: longcat (padrao), space-bunny e step-5 sao de retencao zero pelos - docs do Zen; muse-spark treina com o prompt. So use o muse-spark em projeto - pessoal, e so se o usuario aceitar. -- O worker carrega o `~/.claude/CLAUDE.md` do usuario, como um subagente - nativo. `"disable_claude_code": true` na task desliga. -- O opencode grava `"$schema"` no `opencode.json` da raiz; o script restaura os - bytes e avisa. Nao cubra `opencode.json` no `scope`: a task e recusada. - -O terceiro backend, `copilot`, tem secao propria no fim: [Backend copilot](#backend-copilot). - -## Pergunte antes do primeiro run - -Delegar e revisar mandam o prompt, os arquivos que o worker ler e o diff para -a Google (agy), para o provedor do modelo do Zen (opencode) ou para a GitHub -(copilot), gastam a quota do agy ou os AI credits do Copilot do usuario e, no -agy, rodam com -`--dangerously-skip-permissions` (os hooks sao o limite). 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/agy_worker.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. **Nenhum `.agents/hooks.json` no repo** (so agy). O agy le um arquivo de hooks por - workspace; o script nao sobrescreve o seu e recusa. -4. **Os hooks tem que carregar.** O veredito falha com "hooks nao carregaram" - se nenhum `SessionStart` chegou. A causa **nao e conhecida**: nunca - aconteceu nos testes, e os hooks dispararam headless ate em pastas fora de - `trustedWorkspaces`. Hipoteses, nenhuma testada: `hooks.json` invalido, - hooks desligados nesta versao do agy, confianca no workspace. Diga isso ao - usuario; abrir `agy` interativo uma vez na pasta e uma sugestao, nao um - diagnostico. Nao edite `~/.gemini`. - -## Escrevendo a task - -Um JSON **fora da arvore** (o scratchpad, ou -`$(git rev-parse --git-path agy-worker)/tasks/`), senao ele mesmo suja a arvore. -Uma task completa de exemplo esta em `task.example.json`, ao lado desta skill. - -| 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` | padrao `gemini-3.8-flash-high` no agy, `opencode/longcat-2.5-preview-free` no opencode, `auto` no copilot; validado contra `agy models` / `opencode models` antes de rodar (copilot: so `auto`, ver a secao dele) | -| `disable_claude_code` | so opencode: `true` para nao carregar o `~/.claude/CLAUDE.md` (padrao `false`) | -| `timeout`, `gate_timeout`, `max_gate_attempts` | `30m`, 900 s por comando, 5 | -| `allow_paths`, `deny_commands`, `min_quota` | caminhos extra de leitura, regex extra de comando negado, quota minima (0.05, so agy) | -| `secret_globs` | nomes de arquivo que o worker nao le nem escreve; padrao `.env`, `.env.*`, `.dev.vars`, `*.pem`, `*.key`, `id_rsa*`... (`.env.example` passa). `[]` desliga | - -Escolha do modelo: comece no `gemini-3.8-flash-high`. 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`. **Nao use Opus pelo agy**: os modelos -Claude do agy dividem uma quota semanal, e o Opus a esgota em poucas fatias. - -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 o - Flash 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 `AGY_WORKER_GATE=1` no ambiente, caso um deles precise - saber que e o gate e nao o worker rodando o mesmo comando. - -## Rodando - -```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate /caminho/TASK.json [--backend opencode] -``` - -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 `hooks.json` por -workspace serializam os runs. Para paralelizar, um `git worktree` por fatia, -cada um com seu estado em `git rev-parse --git-path agy-worker`. 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: - -| 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 | -| stdout vazio, `rc != 0`, `status != SUCCESS` | o basico, que o exit code sozinho nao diz | -| denied_actions / auto-negada | acao recusada sai 0 | -| hooks nao carregaram / plugin de guard do opencode nao carregou | sem `SessionStart` o run nao teve guard rail nenhum | -| evento `error`, ultimo step sem `reason=stop` (opencode) | o opencode sai 0 com erro de API ou run cortado | -| relatorio ausente (opencode) | nenhum objeto JSON na mensagem final, mesmo depois de pedido de novo | -| 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 | - -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 agy 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 novos com cwd no -repo que ficaram vivos (o script lista e **nao** mata). - -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 o Gemini 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/agy_worker.py" review --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 `;`. - -Com `--backend opencode`, sem modo plan: escopo vazio e todo comando de shell -negado pelo plugin. No agy, roda com `--mode plan`, sem `--dangerously-skip-permissions`, com os mesmos -guards de caminho e escopo vazio, e confere que a arvore nao mudou. O diff vai -no prompt porque o revisor nao roda comando. Serve como segundo par de olhos -barato sobre um diff seu ou do worker. 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. -Os arquivos de segredo ficam de fora por padrao 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 -`run_command` do agy nunca vai para segundo plano (o hook reescreve -`WaitMsBeforeAsync`), e o que o run deixar vivo com a marca dele no ambiente, -como um daemon do Nx, e encerrado no fim; worker e gate rodam com -`NX_DAEMON=false`. -O `--sandbox` do agy nao confinou escrita nenhuma no lab. 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. E o worker herda o seu ambiente: `env` e -`printenv` sao negados, mas `python3 -c 'import os; ...'` nao. Nao delegue com -segredo em variavel de ambiente que o worker nao deva ver. - -## Backend copilot - -Separavel do resto: tudo o que e especifico do GitHub Copilot CLI esta aqui. - -**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 serve 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. - -**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.** 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. - -**Task.** Igual as outras, mais: - -| campo | o que e | -|---|---| -| `max_gate_attempts` | no maximo 8: depois de 8 bloqueios seguidos do `agentStop` o CLI para de obedecer | -| `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_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 gate inteiro (escopo, `forbid`, `format`, `checks`) roda do nosso lado no -`agentStop`; diga no `prompt` o que o worker precisa saber sobre os comandos, -porque ele nao pode roda-los para ver. - -**Rodando.** - -```bash -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" delegate /caminho/TASK.json --backend copilot -python3 "${CLAUDE_PLUGIN_ROOT}/scripts/agy_worker.py" review --backend copilot --diff HEAD~1 --prompt-file /scratchpad/pergunta.md -``` - -O run escreve `.github/hooks/agy-worker-.json` e o remove no fim. Se um -run morrer por SIGKILL, o proximo recusa e diz qual arquivo remover; enquanto -ele existir, um `copilot` com `COPILOT_ALLOW_ALL=true` nesta pasta carrega os -hooks de um run morto. - -**Lendo o veredito.** Alem das falhas comuns (gate, escopo, HEAD, relatorio): - -| 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 | - -`copilot.usage` traz `ai_credits`, `premium_requests` e `served_models`: -reporte os creditos gastos ao usuario. `report.checks_run` e ainda menos -confiavel aqui: no smoke o worker escreveu `PASS` para um teste que ele nao -tinha como rodar. So o gate conta. - -**Revisao.** A mesma da secao de revisao acima, sem atalho. 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/agy-worker/README.md b/plugins/crew/README.md similarity index 68% rename from plugins/agy-worker/README.md rename to plugins/crew/README.md index f87b2e3..24b2c92 100644 --- a/plugins/agy-worker/README.md +++ b/plugins/crew/README.md @@ -1,46 +1,65 @@ -# agy-worker +# crew -Usa os modelos da Google como worker barato **sem brigar com o harness do +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 Antigravity CLI (`agy`) com Gemini executa headless, uma fatia por vez, -sob guard rails que existem so durante o run. +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 agy-worker@4nrry +/plugin install crew@4nrry ``` -Requer `agy` autenticado, `git`, `python3` (so stdlib) e Linux (`/proc`, para -listar processos que sobraram). So foi exercitado no agy **1.3.4**; 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. Nenhum daemon, nenhum MCP server e nenhum hook do Claude Code: os -hooks que este plugin instala sao **do agy**, escritos em `.agents/hooks.json` -no comeco do run e removidos no fim (inclusive em SIGTERM e SIGHUP; depois de -um SIGKILL o proximo run recusa e diz qual arquivo remover). - -Segundo backend, opcional: o **opencode** (`"backend": "opencode"` na task), -com os modelos gratis do Zen. Mesmo veredito, mesmas regras de caminho, escopo -e comando, guard mais fraco no shell; detalhes em -[Backend opencode](#backend-opencode). - -Terceiro backend, opcional: o **GitHub Copilot CLI** (`"backend": "copilot"`), -sem shell nenhum para o modelo e o gate inteiro do nosso lado. No plano Free -so o `--model auto` funciona e cada run gasta AI credits; detalhes em -[Backend copilot](#backend-copilot). +| 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 a Google (ou para o provedor do -backend escolhido), e gasta a quota do agy (ou os creditos do Copilot). A -skill manda o Claude perguntar antes do primeiro run na sessao. +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/agy_worker.py` | `delegate`, `review`, `gate`, os handlers de hook do agy, o guard e o loop do opencode, os hooks do Copilot, e `--self-test`. Um arquivo | -| `skills/agy-worker/` | quando delegar e quando nao, como escrever a task, como ler o veredito, a revisao obrigatoria | -| `skills/agy-worker/task.example.json` | uma task completa; o self-test a valida | -| `commands/delegate.md`, `commands/review.md` | `/agy-worker:delegate` e `/agy-worker:review` | +| `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 @@ -71,12 +90,16 @@ skill manda o Claude perguntar antes do primeiro run na sessao. 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. Imprime um veredito JSON: `ok`, `failures`, `warnings`, arquivos por escopo, +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 agy-worker)/runs/`, +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. @@ -107,7 +130,7 @@ 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 na skill. +no relatorio, o ponto `format` no gate e a revisao obrigatoria no documento comum. ## Smoke tests desta versao @@ -144,9 +167,17 @@ Python e `unittest`: (`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 `overwrite` de `WaitMsBeforeAsync` e o encerramento dos processos marcados - vieram de um run real que travou 16 min (n=1); o self-test cobre a logica, e - o primeiro run depois da mudanca e o que vai dizer se o agy aceita o valor. +- 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 @@ -163,6 +194,54 @@ Python e `unittest`: - 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 @@ -172,7 +251,7 @@ 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 `agy_worker.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 | +| 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 | @@ -236,7 +315,7 @@ quota gratis a poupar, cada run custa. | | agy | copilot | |---|---|---| -| guard | `.agents/hooks.json` | `.github/hooks/agy-worker-.json`, escrito no comeco do run e removido no fim (inclusive SIGTERM/SIGHUP); recusa se ja existir, ou se sobrar um `agy-worker-*.json` de run morto; o `git status` pos-run ignora exatamente esse arquivo | +| 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 | @@ -252,7 +331,7 @@ Outras flags de todo run: `--allow-all-tools` (exigida pelo `-p`), `--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`, -`AGY_WORKER_RUN`. O `COPILOT_HOME` continua o do usuario: isola-lo perdeu o +`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 @@ -312,7 +391,7 @@ propria sessao (delegate, revisao, commit, proxima), nao um fan-out. ## Verificacao -`just check` roda `agy_worker.py --self-test`: 298 assercoes sem chamar o agy, +`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 @@ -341,3 +420,36 @@ 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/agy-worker/commands/delegate.md b/plugins/crew/commands/delegate.md similarity index 58% rename from plugins/agy-worker/commands/delegate.md rename to plugins/crew/commands/delegate.md index 19cea02..7453f6e 100644 --- a/plugins/agy-worker/commands/delegate.md +++ b/plugins/crew/commands/delegate.md @@ -3,21 +3,20 @@ description: Delega uma fatia bem delimitada a um worker (agy com Gemini, openco argument-hint: [--backend agy|opencode|copilot] [--model slug] [--no-quota-probe] --- -Leia a skill `agy-worker` antes, se ainda nao leu nesta sessao: ela diz se esta -fatia deve ser delegada e como a task tem que estar escrita. +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/agy_worker.py" delegate '' [--backend agy|opencode|copilot] [--model slug] [--no-quota-probe] +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`). Use `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 (secao "agy ou opencode" da skill). -`copilot` gasta AI credits a cada run, nao tem shell (o gate roda todo do nosso -lado) e so aceita `--model auto`; leia a secao "Backend copilot" da skill antes. -`--no-quota-probe` so vale para o agy. +`--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 @@ -32,5 +31,5 @@ Quando terminar: 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 da skill. Nao commite antes disso, e - nao commite sem o usuario pedir. + verdade, seguindo a secao de revisao do documento comum. Nao commite antes + disso, e nao commite sem o usuario pedir. diff --git a/plugins/agy-worker/commands/review.md b/plugins/crew/commands/review.md similarity index 53% rename from plugins/agy-worker/commands/review.md rename to plugins/crew/commands/review.md index 2a3dd85..0e889f7 100644 --- a/plugins/agy-worker/commands/review.md +++ b/plugins/crew/commands/review.md @@ -3,23 +3,21 @@ description: Segunda opiniao somente leitura do Gemini pelo agy (--mode plan, re argument-hint: [--backend agy|opencode|copilot] [--diff HEAD~1] [--model slug] --- -Rode: +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/agy_worker.py" review [--backend agy|opencode|copilot] [--diff HEAD~1] [--model slug] --prompt-file /caminho/pergunta.md +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. `--backend opencode` (padrao -`agy`) e para quando a quota do agy acabou: sem modo plan, o plugin de guard -nega toda escrita e todo comando de shell. `--backend copilot` gasta AI -credits (0,41 num review pequeno, n=1), so aceita `--model auto` e roda sem -shell nem `apply_patch`, com o hook negando toda escrita. 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. +(`--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. 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/agy-worker/scripts/agy_worker.py b/plugins/crew/scripts/crew.py similarity index 87% rename from plugins/agy-worker/scripts/agy_worker.py rename to plugins/crew/scripts/crew.py index 1c96974..23333f5 100755 --- a/plugins/agy-worker/scripts/agy_worker.py +++ b/plugins/crew/scripts/crew.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""agy-worker: run Google's Antigravity CLI (`agy`) headless as a guarded worker. +"""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: @@ -19,7 +19,12 @@ So nothing here trusts the exit code, and every hook prints an explicit decision even when it crashes. -Stdlib only. State lives in `$(git rev-parse --git-path agy-worker)`, so a run +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. @@ -32,7 +37,7 @@ 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/agy-worker-.json`, +`--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`), @@ -55,7 +60,7 @@ import time HOOK_FILE = ".agents/hooks.json" -STATE_NAME = "agy-worker" +STATE_NAME = "crew" DEFAULT_MODEL = "gemini-3.8-flash-high" DEFAULT_TIMEOUT = "30m" DEFAULT_REVIEW_TIMEOUT = "15m" @@ -76,7 +81,7 @@ # 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 = "AGY_WORKER_RUN" +RUN_ENV = "CREW_RUN" WORKER_ENV = {"NX_DAEMON": "false"} BACKENDS = ("agy", "opencode", "copilot") OPENCODE_DEFAULT_MODEL = "opencode/longcat-2.5-preview-free" @@ -90,10 +95,15 @@ TESTED_COPILOT_VERSION = "1.0.96" COPILOT_DEFAULT_MODEL = "auto" COPILOT_HOOK_DIR = ".github/hooks" -COPILOT_HOOK_PREFIX = "agy-worker-" +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"] @@ -209,7 +219,7 @@ # 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/agy-worker-x.json", ".github/copilot/settings.json", +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 @@ -626,12 +636,13 @@ def is_secret(path: str, globs) -> bool: def allow_cmd(ctx: dict) -> dict: - """agy's run_command goes asynchronous after WaitMsBeforeAsync (the model - sends 5000), and agy then waits up to 30 min for the orphan before it - exits: an Nx typecheck sat there for 16 min (LOTVS categories run, - 2026-10-13). Overwrite it so the command finishes inside the call.""" - wait = int(ctx.get("gate_timeout", DEFAULT_GATE_TIMEOUT)) * 1000 - return {"decision": "allow", "overwrite": {"WaitMsBeforeAsync": wait}} + """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: @@ -918,12 +929,12 @@ def run_gate(repo: str, ctx: dict, baseline: bool = False): say(f"GATE: [{kind}] {cmd}: pulado, nenhum arquivo do escopo") continue cmd = cmd.replace("{files}", quoted) - # AGY_WORKER_GATE lets a command tell the gate run from the - # worker's own run of the same command. Own session, so a timeout + # 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, "AGY_WORKER_GATE": "1", + 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)) @@ -1056,6 +1067,18 @@ def hook_main(event: str, rundir: str) -> int: 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: @@ -1226,10 +1249,52 @@ def evaluate_run(rc, stdout: str, stderr: str, mode: str, schema: dict): return fails, warns, env -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.""" +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(), {} @@ -1237,16 +1302,19 @@ def stop_run_procs(run_id: str) -> dict: if not pid.isdigit() or int(pid) == me: continue try: - with open(f"/proc/{pid}/environ", "rb") as fh: - if marker not in fh.read().split(b"\0"): - continue - with open(f"/proc/{pid}/cmdline", "rb") as fh: - hits[int(pid)] = fh.read().replace(b"\0", b" ").decode(errors="replace").strip()[:200] + 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 hits: + for pid in pids: try: os.kill(pid, sig) alive.append(pid) @@ -1258,23 +1326,137 @@ def stop_run_procs(run_id: str) -> dict: 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 -def snapshot_procs(repo: str) -> dict: - """pid -> cmdline for processes whose cwd is inside the repo.""" - found = {} - for pid in os.listdir("/proc"): - if not pid.isdigit(): - continue +# 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: - cwd = os.readlink(f"/proc/{pid}/cwd") - if cwd == repo or cwd.startswith(repo + "/"): - with open(f"/proc/{pid}/cmdline", "rb") as fh: - found[int(pid)] = fh.read().replace(b"\0", b" ").decode(errors="replace").strip()[:200] + with open(os.path.join(self.rundir, "guard.jsonl"), encoding="utf-8") as fh: + fh.seek(self.offset) + chunk = fh.read() except OSError: - continue - return found + 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: @@ -1282,8 +1464,8 @@ def refuse_existing_hooks(repo: str) -> None: if not os.path.lexists(path): return spec = read_json(path) - if isinstance(spec, dict) and list(spec) == ["agy-worker"]: - raise Refusal(f"{path} e sobra de um run do agy-worker que morreu sem limpar (SIGKILL?), ou " + 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.") @@ -1302,7 +1484,7 @@ def install_hooks(repo: str, rundir: str, ctx: dict) -> bool: 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 = {"agy-worker": { + 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}]}], @@ -1360,15 +1542,26 @@ def preflight_agy(model: str, min_quota: float, probe_quota: bool, rundir: str) def run_agy(cmd: list, repo: str, rundir: str, timeout_s: int, name: str = "agy", env=None, - grace: int = 180): + 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.""" + 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: - rc = proc.wait(timeout=timeout_s + grace) + 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): @@ -1425,12 +1618,12 @@ def trap_signals() -> None: # opencode backend: guard plugin outside the tree, external gate loop # -------------------------------------------------------------------------- -# No policy in here: every tool call is forwarded to `agy_worker.py hook +# 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 = """// agy-worker guard for opencode, generated for one run; loaded only through -// OPENCODE_CONFIG_CONTENT of that run. The policy is agy_worker.py's. +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) { @@ -1483,7 +1676,7 @@ def preflight_opencode(model: str): 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="agy-worker-oc-") as cwd: + 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() @@ -1620,7 +1813,7 @@ def drive_opencode(oc: str, task: dict, repo: str, rundir: str, prompt: str, ctx break cmd = [oc, "run", "--dir", repo, "--format", "json", "-m", task["model"], *(["-s", sid] if sid else []), oc_message(msg)] - eprint(f"agy-worker: opencode run {i}/{n} ({task['model']}{', sessao ' + sid if sid else ''})…") + 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) @@ -1676,17 +1869,17 @@ def refuse_cp_leftovers(repo: str) -> list: 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) and n.endswith(".json")] + 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 agy-worker em {COPILOT_HOOK_DIR}/ ({', '.join(ours)}), de um run " + 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/agy-worker-.json. Returns (relative path, + """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) @@ -1743,7 +1936,7 @@ def preflight_copilot() -> tuple: cp = shutil.which("copilot") if not cp: raise Refusal("copilot nao esta no PATH") - with tempfile.TemporaryDirectory(prefix="agy-worker-cp-") as cwd: + 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) @@ -1881,14 +2074,14 @@ def cmd_delegate(a) -> int: if task["backend"] == "agy": refuse_existing_hooks(repo) rundir = new_rundir(repo, "delegate") - eprint(f"agy-worker: run dir {rundir}") + 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("agy-worker: o preflight do opencode reescreveu opencode.json; restaurado") + eprint("crew: o preflight do opencode reescreveu opencode.json; restaurado") elif copilot: cp, pre_warns = preflight_copilot() if other_cp_hooks: @@ -1909,14 +2102,13 @@ def cmd_delegate(a) -> int: with open(os.path.join(rundir, "prompt.md"), "w", encoding="utf-8") as fh: fh.write(prompt) head = git(repo, "rev-parse", "HEAD").strip() - before = snapshot_procs(repo) 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"agy-worker: copilot rodando ({task['model']}, timeout {task['timeout']}s)…") + 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) @@ -1935,10 +2127,12 @@ def cmd_delegate(a) -> int: "--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"agy-worker: agy rodando ({task['model']}, timeout {task['timeout']}s)…") + 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"]}) + 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"]) @@ -1946,6 +2140,13 @@ def cmd_delegate(a) -> int: 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", []) @@ -1957,11 +2158,11 @@ def cmd_delegate(a) -> int: # 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 agy-worker nao carregaram no copilot (nenhum evento de hook " + 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 agy-worker nao carregaram") + " (nenhum SessionStart registrado): " + "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)") @@ -1993,9 +2194,11 @@ def cmd_delegate(a) -> int: if stopped: warns.append("processos deixados pelo run encerrados (traziam a marca do run): " + "; ".join(f"{p} {c}" for p, c in stopped.items())) - leftover = {p: c for p, c in snapshot_procs(repo).items() if p not in before and p != os.getpid()} + # 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 novos com cwd no repo ainda vivos (nao foram encerrados): " + 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, @@ -2012,7 +2215,7 @@ def cmd_delegate(a) -> int: } write_json(os.path.join(rundir, "verdict.json"), verdict) print(json.dumps(verdict, ensure_ascii=False, indent=2)) - eprint(f"agy-worker: {'OK' if verdict['ok'] else 'FALHOU'} — {len(fails)} falha(s), " + 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 @@ -2049,14 +2252,14 @@ def cmd_review(a) -> int: 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"agy-worker: run dir {rundir}") + 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("agy-worker: o preflight do opencode reescreveu opencode.json; restaurado") + eprint("crew: o preflight do opencode reescreveu opencode.json; restaurado") elif copilot: cp, pre_warns = preflight_copilot() if other_cp_hooks: @@ -2092,7 +2295,7 @@ def cmd_review(a) -> int: if opencode: env_oc = opencode_env(write_oc_guard(rundir), False) try: - eprint(f"agy-worker: review rodando (opencode, {model})…") + 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) @@ -2104,7 +2307,7 @@ def cmd_review(a) -> int: warns = pre_warns + ([f"o opencode reescreveu {sorted(restored)}; bytes restaurados"] if restored else []) run_info = {"rc": rc, **info} elif copilot: - eprint(f"agy-worker: review rodando (copilot, {model}, sem shell nem apply_patch)…") + 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) @@ -2119,7 +2322,7 @@ def cmd_review(a) -> int: "--json-schema", schema_path, "--print-timeout", f"{timeout_s}s"] created = install_hooks(repo, rundir, ctx) try: - eprint(f"agy-worker: review rodando ({model}, --mode plan)…") + eprint(f"crew: review rodando ({model}, --mode plan)…") rc, stdout, stderr = run_agy(cmd, repo, rundir, timeout_s) finally: remove_hooks(repo, created) @@ -2134,7 +2337,7 @@ def cmd_review(a) -> int: "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 agy-worker nao carregaram no copilot (nenhum evento de hook): o " + 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: @@ -2172,7 +2375,7 @@ def check(cond, msg): if not cond: raise AssertionError(msg) - tmp = tempfile.mkdtemp(prefix="agy-worker-selftest-") + tmp = tempfile.mkdtemp(prefix="crew-selftest-") try: repo = os.path.realpath(os.path.join(tmp, "repo")) os.makedirs(os.path.join(repo, "src")) @@ -2186,8 +2389,8 @@ def check(cond, msg): 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__)), "..", "skills", - "agy-worker", "task.example.json") + 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") @@ -2238,9 +2441,8 @@ def check(cond, msg): nosec = dict(ctx, secret_globs=[]) check(decide_pre({"toolCall": {"name": "view_file", "args": {"AbsolutePath": f"{repo}/.env"}}}, nosec)["decision"] == "allow", "segredo: lista vazia desliga") - # run_command never goes asynchronous - out = pre("run_command", CommandLine="npm test") - check(out.get("overwrite", {}).get("WaitMsBeforeAsync", 0) >= 60_000, "WaitMsBeforeAsync forcado") + # 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") @@ -2274,7 +2476,7 @@ def check(cond, msg): # .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 agy-worker)/runs/r/ctx.json\"", + "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", @@ -2414,7 +2616,7 @@ def check(cond, msg): ("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/agy-worker-x.json\n+{}\n", "deny", + ("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"), @@ -2539,6 +2741,86 @@ def cpev(*evs): 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") @@ -2567,8 +2849,8 @@ def cpev(*evs): 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 "$AGY_WORKER_GATE" = 1']))) - check(ok, "gate exporta AGY_WORKER_GATE") + 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") @@ -2601,7 +2883,8 @@ def hook(event, payload): 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"] == "stop", f"stop com gate verde para: {r}") + 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")) @@ -2616,6 +2899,23 @@ def hook(event, payload): 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 {}") @@ -2678,7 +2978,7 @@ def cphook(event, payload): 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/agy-worker-cprun.json" and set(spec["hooks"]) == + 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") @@ -2789,7 +3089,7 @@ def cphook(event, payload): 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["agy-worker"]) == {"SessionStart", "PreToolUse", "Stop"}, "hooks.json gerado") + 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) @@ -2811,7 +3111,7 @@ def main(argv=None) -> int: argv = sys.argv[1:] if argv == ["--self-test"]: return self_test() - p = argparse.ArgumentParser(prog="agy_worker.py", description=__doc__.split("\n\n")[0]) + 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") @@ -2846,11 +3146,11 @@ def main(argv=None) -> int: try: return {"delegate": cmd_delegate, "review": cmd_review, "gate": cmd_gate}[a.cmd](a) except Refusal as exc: - eprint(f"agy-worker: recusado: {exc}") + eprint(f"crew: recusado: {exc}") print(json.dumps({"ok": False, "refused": True, "failures": [str(exc)]}, ensure_ascii=False)) return 2 except KeyboardInterrupt: - eprint("agy-worker: interrompido; hooks removidos") + eprint("crew: interrompido; hooks removidos") return 130 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/agy-worker/skills/agy-worker/task.example.json b/plugins/crew/shared/task.example.json similarity index 100% rename from plugins/agy-worker/skills/agy-worker/task.example.json rename to plugins/crew/shared/task.example.json 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.