From 5373be9263fc204c90569d435faa3eb7690d1bb3 Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi Date: Fri, 23 Oct 2026 17:52:10 +0200 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20[feat]=20host:=20detect=20Goo?= =?UTF-8?q?gle=20Antigravity=20(agy)=20and=20accept=20it=20as=20config=20h?= =?UTF-8?q?ost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - super-board-host.sh answers agy on ANTIGRAVITY_AGENT=1 (observed on agy 1.4.6), after the Codex ids and OPENCODE, before CLAUDECODE; SUPER_BOARD_HOST and config host take agy - setup check accepts host agy; schema, onboard and run-workflow name it (no lane backend yet: the run halts like opencode) - docs/hosts.md: agy row, detection step and env evidence; tests for the sign, the override, the config key and the empty/id-only cases Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/hosts.md | 16 ++++++++-- scripts/super-board-host.sh | 31 ++++++++++++------- scripts/super-board-setup.py | 4 +-- .../super-board/references/config-schema.json | 5 +-- skills/super-board/references/onboard.md | 2 +- skills/super-board/references/run-workflow.md | 9 +++--- tests/test-host.sh | 14 +++++++++ tests/test-setup.sh | 4 +-- 9 files changed, 61 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7735ef43..bd18ab0f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Or as a Claude Code plugin: > [!NOTE] > The plugin ships the skills only. Run `/super-board:super-board onboard` and its πŸ” Checks step adds the guard hooks, scripts and workflows. Needs Claude Code, `gh`, `jq`, bash 3.2+ and Python 3.9+; the installer checks. -Running from Codex or OpenCode? See [supported hosts](docs/hosts.md) for tested versions, sign-in and gaps. +Running from Codex, OpenCode or Google Antigravity? See [supported hosts](docs/hosts.md) for tested versions, sign-in and gaps. Optional post-run learning can delegate an already installed `calibrate` skill after verified selected work completes. It applies only to explicitly permitted existing diff --git a/docs/hosts.md b/docs/hosts.md index 57ba5403..4567f13e 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -8,6 +8,7 @@ super-board runs inside an agent host: the CLI whose session types `/super-board | `claude` | Claude Code 2.1.302 | `/super-board run` (Workflow backend) | `claude` then `/login`; `gh auth login` | None. The reference host. | | `codex` | codex-cli 0.160.0 | `/super-board run` inside Codex runs as `--codex`; from Claude, `/super-board run --codex` | `codex login`; `gh auth login` | No Workflow tool, so every lane is headless `codex exec`. Review runs only with explicit `--codex` (a host-switched plain `run` counts). No Claude usage guard. | | `opencode` | OpenCode v2.0.22 | Not supported yet: the run halts with a host-selection request | `opencode auth login`; `gh auth login` | No OpenCode lane backend yet. `permission.external_directory: deny` is not enforced in v2.0.22 (`patch` still writes outside the repo), so OpenCode runs stay attended-only once a backend lands. | +| `agy` | Google Antigravity CLI 1.4.6 (`agy`) | Not supported yet: the run halts with a host-selection request | interactive `agy` once (headless runs use the cached sign-in); `gh auth login` | No Antigravity lane backend yet. Headless `agy -p` auto-denies every shell command and non-workspace file write that `permissions.allow` in `~/.gemini/antigravity-cli/settings.json` does not cover, and on Windows those rules match exactly unless written as `command(regex:…)`. | Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. @@ -19,9 +20,12 @@ Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. 2. `CODEX_THREAD_ID` or `CODEX_SESSION_ID` β†’ `codex`. 3. `OPENCODE=1` β†’ `opencode`. Checked before `CLAUDECODE`, which leaks into an OpenCode session started from Claude Code. `AGENT=1` is ignored: other tools set it too. -4. `CLAUDECODE` β†’ `claude`. -5. The board config's top-level `host` key, when `--config` is given. -6. Nothing matched: empty stdout, a hint on stderr, exit 65. The run halts and asks for a +4. `ANTIGRAVITY_AGENT=1` β†’ `agy`. Also checked before `CLAUDECODE`, which leaks into an + Antigravity lane started from a Claude session. The other `ANTIGRAVITY_*` variables are + per-conversation ids or session secrets and are not used. +5. `CLAUDECODE` β†’ `claude`. +6. The board config's top-level `host` key, when `--config` is given. +7. Nothing matched: empty stdout, a hint on stderr, exit 65. The run halts and asks for a host. It never guesses Claude. A live env sign beats the saved config, so a Codex lane started from a Claude-configured @@ -35,3 +39,9 @@ Onboard always writes `host` into `.claude/super-board/configs/.json`. - Codex: env of `codex exec -s read-only`, 2026-10-04 (recorded in the script header). - OpenCode: env diff of an `opencode run` shell, 2026-10-19: `OPENCODE=1`, `OPENCODE_SESSION_ID`, `OPENCODE_TERMINAL=1`, `AGENT=1`. +- Antigravity: `cmd /c set` run by `run_command` inside a headless `agy -p` on agy 1.4.6, + Windows 11, 2026-10-23: `ANTIGRAVITY_AGENT=1`, `ANTIGRAVITY_CONVERSATION_ID`, + `ANTIGRAVITY_LS_VERSION=cli-1.4.6`, `ANTIGRAVITY_APP_DATA_DIR`, `ANTIGRAVITY_PROJECT_ID`, + `ANTIGRAVITY_TRAJECTORY_ID`, `ANTIGRAVITY_AGENTAPI_EXE`, `ANTIGRAVITY_LS_ADDRESS`, + `ANTIGRAVITY_CSRF_TOKEN`, `ANTIGRAVITY_SOURCE_METADATA`. No `AGENT`, `GEMINI_*` or + `CLAUDECODE`. The hooks and permissions docs name none of them. diff --git a/scripts/super-board-host.sh b/scripts/super-board-host.sh index 87900a7a..6eb7b50a 100755 --- a/scripts/super-board-host.sh +++ b/scripts/super-board-host.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# super-board-host.sh β€” which agent host is this shell inside: `claude`, `codex` or `opencode`. +# super-board-host.sh β€” which agent host is this shell inside: `claude`, `codex`, `opencode` or `agy`. # # WHY. `/super-board run` launched from a Codex CLI session has no Claude Workflow tool, so the # workflow backend cannot start. The orchestrator checks its own tool list first; this is the @@ -17,16 +17,23 @@ # shells it runs. Observed 2026-10-19 on opencode v2.0.22 (env diff of a `run` shell). # Checked before CLAUDECODE, which leaks into OpenCode started from a Claude session. # Not used: AGENT=1 (a generic name other tools set too). +# - Google Antigravity (the `agy` CLI) sets ANTIGRAVITY_AGENT=1 in every shell its +# run_command tool runs, plus ANTIGRAVITY_CONVERSATION_ID, ANTIGRAVITY_LS_VERSION +# (e.g. cli-1.4.6), ANTIGRAVITY_PROJECT_ID and ANTIGRAVITY_TRAJECTORY_ID. Observed +# 2026-10-23 on agy 1.4.6 (`cmd /c set` inside a headless `agy -p` run on Windows 11). +# Checked before CLAUDECODE, which leaks into an agy lane started from a Claude session. +# Not used: the ids (one per conversation) and ANTIGRAVITY_CSRF_TOKEN (a session secret). # - Claude Code sets CLAUDECODE=1. # -# ORDER. SUPER_BOARD_HOST, Codex ids, OPENCODE, CLAUDECODE, then the config's top-level -# `host` key (--config ), else exit 65. A live env sign beats the saved config. +# ORDER. SUPER_BOARD_HOST, Codex ids, OPENCODE, ANTIGRAVITY_AGENT, CLAUDECODE, then the +# config's top-level `host` key (--config ), else exit 65. A live env sign beats the +# saved config. # # Usage: super-board-host.sh [--config ] -# prints `claude`, `codex` or `opencode`, exit 0 +# prints `claude`, `codex`, `opencode` or `agy`, exit 0 # exit 65 no sign: nothing on stdout, how to choose on stderr -# exit 64 SUPER_BOARD_HOST or config `host` is not one of the three -# SUPER_BOARD_HOST=claude|codex|opencode forces the answer (tests, or a host the check +# exit 64 SUPER_BOARD_HOST or config `host` is not one of the four +# SUPER_BOARD_HOST=claude|codex|opencode|agy forces the answer (tests, or a host the check # misreads). set -euo pipefail @@ -38,23 +45,25 @@ case "${1:-}" in esac case "${SUPER_BOARD_HOST:-}" in - claude|codex|opencode) echo "$SUPER_BOARD_HOST"; exit 0 ;; + claude|codex|opencode|agy) echo "$SUPER_BOARD_HOST"; exit 0 ;; '') ;; - *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex or opencode" >&2; exit 64 ;; + *) echo "super-board-host.sh: SUPER_BOARD_HOST must be claude, codex, opencode or agy" >&2; exit 64 ;; esac if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SESSION_ID:-}" ]; then echo codex elif [ -n "${OPENCODE:-}" ]; then echo opencode +elif [ -n "${ANTIGRAVITY_AGENT:-}" ]; then + echo agy elif [ -n "${CLAUDECODE:-}" ]; then echo claude elif saved=$( [ -f "$config" ] && jq -r '.host // empty' "$config" 2>/dev/null ) && [ -n "$saved" ]; then case "$saved" in - claude|codex|opencode) echo "$saved" ;; - *) echo "super-board-host.sh: config host must be claude, codex or opencode, got '$saved'" >&2; exit 64 ;; + claude|codex|opencode|agy) echo "$saved" ;; + *) echo "super-board-host.sh: config host must be claude, codex, opencode or agy, got '$saved'" >&2; exit 64 ;; esac else - echo "super-board-host.sh: unknown agent host. Pick one: SUPER_BOARD_HOST=claude|codex|opencode, or set \"host\" in the board config" >&2 + echo "super-board-host.sh: unknown agent host. Pick one: SUPER_BOARD_HOST=claude|codex|opencode|agy, or set \"host\" in the board config" >&2 exit 65 fi diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index 1e494250..a92bb837 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -23,7 +23,7 @@ settings.json entries (runs the pack's install.sh) when any is missing or older - removes the folders of skills that no longer exist: super-refine, cleanup-wt, arch-loop - migrates every .claude/super-board/configs/*.json to the v3 keys (migrate-config), and - writes a missing top-level `host` from --host (check flags a host outside claude/codex/opencode) + writes a missing top-level `host` from --host (check flags a host outside claude/codex/opencode/agy) - `git init` when the folder is not a repo - adds Matt Pocock's helper skills with npx when Node is present and they are missing It never installs a system tool or signs in: those come back in `needs` with the exact @@ -59,7 +59,7 @@ import sys COLUMNS = ["Backlog", "Ready", "Building", "QA", "Review", "Blocked", "Done"] -HOSTS = ("claude", "codex", "opencode") # config `host`; super-board-host.sh accepts the same +HOSTS = ("claude", "codex", "opencode", "agy") # config `host`; super-board-host.sh accepts the same LABELS = { "qa": ("0e8a16", "Test what exists; skips Building (super-board)"), "bug": ("d73a4a", "Something is broken; built, tested, reviewed (super-board)"), diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index e4bd4bbb..74dc0e25 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -31,11 +31,12 @@ // only; the dispatcher refuses to run (exit 78) unless set here. "worker_backend": "workflow", - // Agent host this project runs on: "claude" | "codex" | "opencode". Separate from + // Agent host this project runs on: "claude" | "codex" | "opencode" | "agy" (Google + // Antigravity). Separate from // worker_backend. Onboard and setup's fix ALWAYS write it (setup fills a missing key // from --host and never replaces a set one); setup's check flags any other value. // `super-board-host.sh --config ` uses it only when SUPER_BOARD_HOST and - // every env sign (Codex ids, OPENCODE, CLAUDECODE) are absent. See docs/hosts.md. + // every env sign (Codex ids, OPENCODE, ANTIGRAVITY_AGENT, CLAUDECODE) are absent. See docs/hosts.md. "host": "claude", // Optional post-completion adapter; never bundles or installs the external skill. diff --git a/skills/super-board/references/onboard.md b/skills/super-board/references/onboard.md index c91e9022..fd6e8ac8 100644 --- a/skills/super-board/references/onboard.md +++ b/skills/super-board/references/onboard.md @@ -361,7 +361,7 @@ Write it atomically (temp file + rename) after every answer. A halt never loses β”‚ β”‚ {type: repo | repo+url}, repo, base_branch, timezone, columns (the seven), labels, β”‚ β”‚ paths, merge_policy, migrations, collect (incl. custom), notifications {channel: β”‚ β”‚ "session", bot_identity}, worker_backend "workflow", host (always: `bash $SB/super-board-host.sh`; - β”‚ β”‚ on exit 65 ask claude/codex/opencode), verify_commands (non-blank project checks). No `variant`. + β”‚ β”‚ on exit 65 ask claude/codex/opencode/agy), verify_commands (non-blank project checks). No `variant`. β”‚ β”œβ”€ .claude/super-board/active ← β”‚ β”œβ”€ .gitignore += .claude/super-board/active, onboard-answers.json, onboard-staged/, β”‚ β”‚ backup/, inflight/, migrations/, upgrade.json (all under .claude/super-board/) diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index c63f5f08..1b3de189 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -25,8 +25,9 @@ Keep this run choice in each wave and resume command; skip only the Claude usage `--codex=` still apply); it counts as explicit `--codex`, Review included. Say so once, in the first wave report: `πŸ” codex host β€” running as --codex`. Never improvise a Claude workflow wave in Codex. -**Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign) or -`opencode` (no OpenCode backend yet) halts before any claim, even with no Workflow tool, with one line: +**Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign), +`opencode` (no OpenCode backend yet) or `agy` (no Antigravity backend yet) halts before any claim, +even with no Workflow tool, with one line: `β›” unknown host β€” set SUPER_BOARD_HOST=claude|codex and re-run`. Never guess a backend. After the same plan and claim steps, launch this command as a background task: @@ -246,8 +247,8 @@ Repeat until a done condition or halt gate fires: there is no cross-session claim at all, so never run two orchestrators (or /loop re-entries) against the same board without bot_identity. 4. **Launch** β€” With `--codex`, or no Workflow tool, or the host check printing `codex`, use - "Codex runs" above. The host check printing `claude` uses the Workflow tool; exit 65 or - `opencode` never reaches Launch (see "Unknown host halts"). Workflow tool with + "Codex runs" above. The host check printing `claude` uses the Workflow tool; exit 65, + `opencode` or `agy` never reaches Launch (see "Unknown host halts"). Workflow tool with `scriptPath: .claude/workflows/super-board-wave.js` and `args: { configPath, cards, humanApprovesMerge, tier }` (`cards` hydrated after the planner: each carries `lane` and `labels`, and a `qa` card skips the Builder). Runs in the background; the diff --git a/tests/test-host.sh b/tests/test-host.sh index fb323715..fdd7d3f1 100644 --- a/tests/test-host.sh +++ b/tests/test-host.sh @@ -15,6 +15,7 @@ host() { env -i PATH="$PATH" "$@" bash "$S"; } [ "$(host SUPER_BOARD_HOST=codex)" = codex ] || fail 'override forces codex' [ "$(host SUPER_BOARD_HOST=claude CODEX_THREAD_ID=01a1)" = claude ] || fail 'override beats the env' [ "$(host SUPER_BOARD_HOST=opencode)" = opencode ] || fail 'override accepts opencode' +[ "$(host SUPER_BOARD_HOST=agy)" = agy ] || fail 'override accepts agy' rc=0; host SUPER_BOARD_HOST=gemini >/dev/null 2>&1 || rc=$? [ "$rc" = 64 ] || fail "bad override exits 64, got $rc" @@ -31,6 +32,8 @@ nohost nohost CODEX_HOME=/x CODEX_SANDBOX=seatbelt # not host signs nohost CODEX_THREAD_ID= CLAUDECODE= # empty values are not signs nohost AGENT=1 # OpenCode sets it, but so do other tools +nohost ANTIGRAVITY_CONVERSATION_ID=fe3b ANTIGRAVITY_LS_VERSION=cli-1.4.6 # ids are not the sign +nohost ANTIGRAVITY_AGENT= # empty is not a sign # OpenCode v2.0.22 exports OPENCODE=1. It wins over a CLAUDECODE leaked from a parent Claude # session; the override and a Codex id still win over it. @@ -39,15 +42,26 @@ nohost AGENT=1 # OpenCode sets it, but so do oth [ "$(host SUPER_BOARD_HOST=claude OPENCODE=1)" = claude ] || fail 'override beats the OpenCode sign' [ "$(host CODEX_THREAD_ID=01a1 OPENCODE=1)" = codex ] || fail 'a Codex id beats the OpenCode sign' +# Antigravity (agy 1.4.6) exports ANTIGRAVITY_AGENT=1 into the shells its run_command tool +# runs. It wins over a CLAUDECODE leaked from the Claude session that launched the lane; the +# override and a Codex id still win over it. +[ "$(host ANTIGRAVITY_AGENT=1)" = agy ] || fail 'ANTIGRAVITY_AGENT=1 means agy' +[ "$(host ANTIGRAVITY_AGENT=1 CLAUDECODE=1)" = agy ] || fail 'an agy lane under a Claude parent is agy' +[ "$(host SUPER_BOARD_HOST=claude ANTIGRAVITY_AGENT=1)" = claude ] || fail 'override beats the agy sign' +[ "$(host CODEX_THREAD_ID=01a1 ANTIGRAVITY_AGENT=1)" = codex ] || fail 'a Codex id beats the agy sign' + # Config `host` (top-level key): used only when no override and no env sign is present. T=$(mktemp -d); trap 'rm -f "$ERR"; rm -rf "$T"' EXIT printf '{"host":"codex"}' > "$T/codex.json" +printf '{"host":"agy"}' > "$T/agy.json" printf '{"worker_backend":"workflow"}' > "$T/none.json" printf '{"host":"gemini"}' > "$T/bad.json" cfg() { local f=$1; shift; env -i PATH="$PATH" "$@" bash "$S" --config "$f"; } [ "$(cfg "$T/codex.json")" = codex ] || fail 'config host codex prints codex' [ "$(cfg "$T/codex.json" CLAUDECODE=1)" = claude ] || fail 'an env sign beats config host' [ "$(cfg "$T/codex.json" SUPER_BOARD_HOST=opencode)" = opencode ] || fail 'override beats config host' +[ "$(cfg "$T/agy.json")" = agy ] || fail 'config host agy prints agy' +[ "$(cfg "$T/agy.json" CODEX_THREAD_ID=01a1)" = codex ] || fail 'an env sign beats config host agy' for f in none missing; do rc=0; out=$(cfg "$T/$f.json" 2>/dev/null) || rc=$? [ "$rc" = 65 ] && [ -z "$out" ] || fail "config $f without host should exit 65, got $rc '$out'" diff --git a/tests/test-setup.sh b/tests/test-setup.sh index 1aa485f3..6487b750 100644 --- a/tests/test-setup.sh +++ b/tests/test-setup.sh @@ -302,10 +302,10 @@ cmp -s "$GH_STATE" "$WORK/before.json" || fail "dry-run changed board state: $(c [ ! -e "$WORK/dry/.claude" ] || fail "dry-run must not write a backup file" echo "$OUT" | q '.status_backup == null and .restored == 0 and .added_columns != []' || fail "dry-run report wrong: $OUT" -# 13 β€” config `host`: check accepts claude / codex / opencode, flags anything else, and +# 13 β€” config `host`: check accepts claude / codex / opencode / agy, flags anything else, and # fix never overwrites a host that is already set. H="$WORK/bare/.claude/super-board/configs"; mkdir -p "$H" -for h in claude codex opencode; do +for h in claude codex opencode agy; do printf '{"host":"%s"}' "$h" > "$H/b.json" OUT=$(python3 "$SETUP" check --root "$WORK/bare" || true) echo "$OUT" | q '.host_errors == {}' || fail "host $h must be accepted: $OUT" From e622090ad7ede1ace3a932a1f064f0bbc5ab85ca Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi Date: Fri, 23 Oct 2026 19:09:25 +0200 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20[feat]=20install:=20guard=20h?= =?UTF-8?q?ooks=20and=20permission=20rules=20for=20Google=20Antigravity=20?= =?UTF-8?q?(--hosts=20agy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hooks/agy-hook-adapter.py translates Antigravity PreToolUse payloads, runs the unchanged guard-*.py and answers deny/ask/allow; hooks/agy-hooks.json wires it - install.sh --hosts agy: entry merged into .agents/hooks.json (other entries kept, --protect-push with --protect-main and kept on re-install, python on Windows), host detected from config/host.sh/an existing entry, no link folder, adapter not counted as a guard - scripts/super-board-agy-permissions.py writes the lane profile (command/read_file/write_file * + deny rules for dangerous forms and secret paths) or --from-allowlist regex rules; dry run, --apply with backup - tests: test-agy-hook-adapter.sh, test-agy-permissions.sh, test-install.sh scenario 25; CLAUDE.md install contract Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 5 +- hooks/agy-hook-adapter.py | 186 +++++++++++++++++++++++++ hooks/agy-hooks.json | 16 +++ install.sh | 67 ++++++++- scripts/super-board-agy-permissions.py | 151 ++++++++++++++++++++ tests/test-agy-hook-adapter.sh | 65 +++++++++ tests/test-agy-permissions.sh | 62 +++++++++ tests/test-install.sh | 27 +++- 8 files changed, 571 insertions(+), 8 deletions(-) create mode 100644 hooks/agy-hook-adapter.py create mode 100644 hooks/agy-hooks.json create mode 100644 scripts/super-board-agy-permissions.py create mode 100644 tests/test-agy-hook-adapter.sh create mode 100644 tests/test-agy-permissions.sh diff --git a/CLAUDE.md b/CLAUDE.md index bf942a7a..e0bad0d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,13 +41,16 @@ See `skills/super-board/references/rate-limit-etiquette.md` for the full discipl ## Installation contract -`install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] ` copies the pack +`install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode,agy] ` copies the pack once into the target project's shared `.agents/` tree, then links each host folder into it: one link per skill under `skills/`, one per file under `bin/`, `workflows/`, `hooks/`. `.claude/` is always linked; `.codex/` and `.opencode/` only when that folder already exists, a config `host` key names it, `super-board-host.sh` reports it, or `--hosts` asks. A real-directory `.claude/` install is replaced by links on upgrade; a user's own links and anything not super-board's are left alone; where no symlink can be made the file is copied. Settings and run state stay in `.claude/`. +Host `agy` (Google Antigravity) reads `.agents/` itself and gets no links: it gets a +`super-board-guards` entry in `.agents/hooks.json` that runs `hooks/agy-hook-adapter.py`, the +same guards translated, plus a pointer to `super-board-agy-permissions.py` for headless lanes. ``` .agents/ diff --git a/hooks/agy-hook-adapter.py b/hooks/agy-hook-adapter.py new file mode 100644 index 00000000..a17e7eaa --- /dev/null +++ b/hooks/agy-hook-adapter.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Run super-board's guard hooks under Google Antigravity (the agy CLI, 2.0 and the IDE). + +Antigravity hooks live in `.agents/hooks.json` (its bundled docs: agy-customizations/docs/hooks.md) +and speak a different dialect from Claude Code: + + in {"toolCall": {"name": "run_command", "args": {"CommandLine": "..."}}, + "workspacePaths": [...], "conversationId": "...", "stepIdx": 3, ...} + out {"decision": "allow" | "deny" | "ask" | "force_ask", "reason": "..."} + +The guards (guard-*.py next to this file) read Claude's +`{"hook_event_name", "tool_name", "tool_input"}` and answer with +`hookSpecificOutput.permissionDecision = "deny"`. This adapter translates the call, runs the +guards that Claude's settings-snippet.json would run for that tool, and translates the +verdict back. The guards stay the single copy of every rule. + +Verified on agy 1.4.6 (2026-10-23): the hook fires in headless `agy -p` too, BEFORE the +permission check, and a hook that fails to start blocks every matched tool call with its +error as the reason. So the command in hooks.json must resolve: hooks run with the folder +holding hooks.json as cwd (`.agents/`), through `sh -c` on Unix and `cmd /c` on Windows, +where `python3` is a Store alias that prints "Python was not found" -- install.sh writes +`python` there. This adapter sits in `.agents/hooks/` next to the guards. + +Tool mapping. `run_command` carries `CommandLine` (observed); the file tools are mapped by +name and the first non-empty value among the usual path spellings (AbsolutePath / TargetFile +/ file_path / path ...): command/shell/terminal -> Bash, view/read/open -> Read, +grep/search/find -> Grep, write/create -> Write, replace/edit/patch/modify -> Edit. Anything +else passes. + +No objection is not the same as "allow": `allow` would skip Antigravity's own permission +check. The adapter answers + - shell commands: SB_AGY_HOOK_PASS_COMMAND (default "ask": the normal + allowlist flow; headless auto-denies what no rule allows, + see scripts/super-board-agy-permissions.py); + - files inside the workspace: SB_AGY_HOOK_PASS_FILE (default "allow": Antigravity + auto-allows workspace reads and writes anyway); + - files outside it: "ask". +The payload carries no shell cwd, only `workspacePaths`, so relative paths are resolved +against the workspace root: a lane adds its worktree from the repo root, as the brief says. + +PostToolUse returns {} (the event takes no decision); the Pre check already saw the write. +Like every guard, the adapter's own errors never block a tool call: it falls back to the +pass decision above. + +Usage (from .agents/hooks.json, cwd .agents/): + python3 hooks/agy-hook-adapter.py --event PreToolUse [--protect-push] +""" +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +COMMAND_KEYS = ("CommandLine", "commandLine", "command", "Command", "cmd") +PATH_KEYS = ("AbsolutePath", "absolutePath", "TargetFile", "targetFile", "file_path", "filePath", + "FilePath", "path", "Path", "SearchPath", "searchPath", "DirectoryPath") +GLOB_KEYS = ("Includes", "includes", "glob", "Glob", "pattern", "Pattern", "Query", "query") +# Claude's settings-snippet.json matchers, per mapped tool. +GUARDS = { + "Bash": ["guard-worktree-path.py", "guard-delete-outside.py", "guard-secrets.py", "guard-key-literals.py"], + "Read": ["guard-secrets.py"], + "Grep": ["guard-secrets.py"], + "Write": ["guard-key-literals.py"], + "Edit": ["guard-key-literals.py"], +} + + +def first(args, keys): + for key in keys: + value = args.get(key) + if isinstance(value, list): + value = " ".join(str(v) for v in value) + if isinstance(value, str) and value.strip(): + return value + return "" + + +def other_text(args, skip): + """Every string an edit could write: all values except the path ones.""" + parts = [] + + def walk(value): + if isinstance(value, str): + parts.append(value) + elif isinstance(value, list): + for item in value: + walk(item) + elif isinstance(value, dict): + for key, item in value.items(): + if key not in skip: + walk(item) + for key, value in args.items(): + if key not in skip: + walk(value) + return "\n".join(parts) + + +def to_claude(name, args): + """(tool_name, tool_input) in Claude Code terms, or (None, None) to pass.""" + lowered = name.lower() + command = first(args, COMMAND_KEYS) + path = first(args, PATH_KEYS) + if command or re.search(r"command|shell|terminal|exec", lowered): + return "Bash", {"command": command} + if re.search(r"grep|search|find", lowered): + return "Grep", {"path": path, "glob": first(args, GLOB_KEYS)} + if re.search(r"view|read|open", lowered): + return "Read", {"file_path": path} + if re.search(r"write|create", lowered): + return "Write", {"file_path": path, "content": other_text(args, set(PATH_KEYS))} + if re.search(r"replace|edit|patch|modify", lowered): + return "Edit", {"file_path": path, "new_string": other_text(args, set(PATH_KEYS))} + return None, None + + +def inside(path, root): + if not path: + return True + try: + target = Path(path) + target = (target if target.is_absolute() else root / target).resolve() + return target == root or root in target.parents + except (OSError, ValueError): + return False + + +def run_guard(guard, payload, root): + script = HERE / guard + if not script.exists(): + return None + env = dict(os.environ, CLAUDE_PROJECT_DIR=str(root)) + proc = subprocess.run([sys.executable, str(script)], input=json.dumps(payload), text=True, + capture_output=True, cwd=str(root), env=env, timeout=25) + if proc.returncode == 2: # Claude's "block" exit, reason on stderr + return proc.stderr.strip() or f"blocked by {guard}" + try: + out = json.loads(proc.stdout or "{}").get("hookSpecificOutput") or {} + except ValueError: + return None + if out.get("permissionDecision") == "deny": + return out.get("permissionDecisionReason") or f"blocked by {guard}" + return None + + +def main(): + argv = sys.argv[1:] + event = argv[argv.index("--event") + 1] if "--event" in argv else "PreToolUse" + if event != "PreToolUse": + print("{}") + return + pass_command = os.environ.get("SB_AGY_HOOK_PASS_COMMAND", "ask") + pass_file = os.environ.get("SB_AGY_HOOK_PASS_FILE", "allow") + decision = {"decision": pass_command} + try: + payload = json.loads(sys.stdin.read() or "{}") + call = payload.get("toolCall") or {} + args = call.get("args") or {} + if isinstance(args, str): + args = json.loads(args) + roots = payload.get("workspacePaths") or [] + root = Path(roots[0] if roots else os.getcwd()).resolve() + tool, tool_input = to_claude(str(call.get("name") or ""), args if isinstance(args, dict) else {}) + if tool is None: + print(json.dumps(decision)) + return + if tool != "Bash": + decision = {"decision": pass_file if inside(tool_input.get("file_path") or tool_input.get("path"), root) else "ask"} + guards = list(GUARDS[tool]) + if tool == "Bash" and "--protect-push" in argv: + guards.append("guard-protected-push.py") + claude_payload = {"hook_event_name": "PreToolUse", "tool_name": tool, "tool_input": tool_input, + "cwd": str(root)} + for guard in guards: + reason = run_guard(guard, claude_payload, root) + if reason: + decision = {"decision": "deny", "reason": reason} + break + except Exception: + pass + print(json.dumps(decision)) + + +if __name__ == "__main__": + main() diff --git a/hooks/agy-hooks.json b/hooks/agy-hooks.json new file mode 100644 index 00000000..f606e57a --- /dev/null +++ b/hooks/agy-hooks.json @@ -0,0 +1,16 @@ +{ + "super-board-guards": { + "PreToolUse": [ + { + "matcher": "run_command|view_file|.*(command|shell|terminal|read|grep|search|find|write|create|replace|edit|patch).*", + "hooks": [ + { + "type": "command", + "command": "python3 hooks/agy-hook-adapter.py --event PreToolUse", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/install.sh b/install.sh index fd899e6e..286e86f8 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ # merges the hook settings into .claude/settings.json (backed up first, never duplicated). # # Usage: -# ./install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode] [target-project-dir] +# ./install.sh [--no-hooks] [--protect-main] [--hosts claude,codex,opencode,agy] [target-project-dir] # Defaults to the current working directory. --no-hooks skips the guard hooks # and leaves settings.json untouched. --protect-main also wires the opt-in # guard that blocks direct and force pushes to main/master/base_branch; with @@ -21,6 +21,14 @@ # link cannot be made (Windows without symlink rights) the file is copied. # Settings and run state (.claude/settings.json, .claude/super-board/) stay put. # +# Google Antigravity (host `agy`) reads .agents/ itself, so it gets no folder of +# links. It gets the guard hooks instead: a "super-board-guards" entry in +# .agents/hooks.json that runs hooks/agy-hook-adapter.py (the same guard-*.py, +# translated); other entries in that file are kept, --protect-main adds the push +# guard. Asked with --hosts agy, a config "host" key, super-board-host.sh, or an +# existing entry. Headless lanes also need permission rules: +# .claude/bin/super-board-agy-permissions.py writes them (the install names it). +# # Output is grouped with emojis, one line per group of files (failures still name # the file). Environment: SUPER_BOARD_QUIET_NEXT=1 prints only the "πŸ”§ installing" # group β€” get.sh sets it and prints its own header and summary around it. @@ -68,8 +76,8 @@ HOSTS="claude" add_host() { case " $HOSTS " in *" $1 "*) ;; *) HOSTS="$HOSTS $1" ;; esac; } for h in $(echo "$ASKED_HOSTS" | tr ',' ' '); do case "$h" in - claude|codex|opencode) add_host "$h" ;; - *) echo "unknown host: $h (use claude, codex or opencode)" >&2; exit 64 ;; + claude|codex|opencode|agy) add_host "$h" ;; + *) echo "unknown host: $h (use claude, codex, opencode or agy)" >&2; exit 64 ;; esac done for h in codex opencode; do [ ! -d "$TARGET/.$h" ] || add_host "$h"; done @@ -80,11 +88,18 @@ for p in sorted(glob.glob(os.path.join(sys.argv[1], "*.json"))): h = json.load(open(p)).get("host") except Exception: continue - if h in ("codex", "opencode"): + if h in ("codex", "opencode", "agy"): print(h) PY ); do add_host "$h"; done -[ "$(bash "$REPO_ROOT/scripts/super-board-host.sh" 2>/dev/null || true)" != codex ] || add_host codex +case "$(bash "$REPO_ROOT/scripts/super-board-host.sh" 2>/dev/null || true)" in + codex) add_host codex ;; + agy) add_host agy ;; +esac +# An Antigravity entry written by an earlier install keeps the host on re-install. +if [ -f "$TARGET/.agents/hooks.json" ] && grep -q '"super-board-guards"' "$TARGET/.agents/hooks.json" 2>/dev/null; then + add_host agy +fi QUIET="${SUPER_BOARD_QUIET_NEXT:-}" PACK_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/VERSION" 2>/dev/null || echo '?')" @@ -259,6 +274,7 @@ GROUPS_="skills bin workflows" [ -z "$HOOK_FILES" ] || GROUPS_="$GROUPS_ hooks" LINKED="" for host in $HOSTS; do + [ "$host" != agy ] || continue # Antigravity reads .agents/ directly: nothing to link LINKED="${LINKED:+$LINKED, }.$host" for group in $GROUPS_; do mkdir -p "$TARGET/.$host/$group" @@ -295,7 +311,8 @@ merge_settings() { } if [ "$HOOKS" -eq 1 ]; then - set -- $HOOK_FILES; n=$# + # The Antigravity adapter is plumbing that runs the guards, not a guard itself. + n=0; for h in $HOOK_FILES; do [ "$h" = agy-hook-adapter.py ] || n=$((n + 1)); done SNIPPETS="$REPO_ROOT/hooks/settings-snippet.json" if [ "$PROTECT" -eq 1 ]; then SNIPPETS="$SNIPPETS $REPO_ROOT/hooks/settings-protect-main.json"; fi # shellcheck disable=SC2086 @@ -308,6 +325,44 @@ else echo " ⏭️ guard hooks skipped (--no-hooks)" fi +# Antigravity: the guards reach agy lanes through .agents/hooks.json. Its hooks run +# with .agents/ as cwd (so the command is relative to it) via `cmd /c` on Windows, +# where `python3` is a Store alias that prints "Python was not found": write `python`. +case " $HOSTS " in *" agy "*) + if [ -n "$HOOK_FILES" ]; then + PY_CMD=python3 + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) PY_CMD=python ;; esac + python3 - "$REPO_ROOT/hooks/agy-hooks.json" "$TARGET/.agents/hooks.json" "$PROTECT" "$PY_CMD" <<'PY' +import json, sys +snippet, path, protect, py = json.load(open(sys.argv[1])), sys.argv[2], sys.argv[3] == "1", sys.argv[4] +try: + current = json.load(open(path)) + if not isinstance(current, dict): + raise ValueError +except FileNotFoundError: + current = {} +except ValueError: + sys.exit(f" ! {path} is not valid JSON; left untouched") +entry = snippet["super-board-guards"] +# --protect-main adds the push guard; a later install without the flag keeps it. +old = current.get("super-board-guards") or {} +had_push = any("--protect-push" in h.get("command", "") for g in old.get("PreToolUse", []) for h in g.get("hooks", [])) +for group in entry["PreToolUse"]: + for hook in group["hooks"]: + hook["command"] = hook["command"].replace("python3 ", py + " ", 1) + if (protect or had_push) and "--protect-push" not in hook["command"]: + hook["command"] += " --protect-push" +current["super-board-guards"] = entry +with open(path, "w") as f: + json.dump(current, f, indent=2) + f.write("\n") +PY + echo " πŸͺ Antigravity guards β†’ .agents/hooks.json (via hooks/agy-hook-adapter.py)" + fi + echo " πŸ‘‰ Antigravity: headless lanes need permission rules once β€” python3 .claude/bin/super-board-agy-permissions.py --apply" + ;; +esac + # AGENTS.md: a re-install refreshes the managed super-board block and touches # nothing outside its markers. No block yet β†’ onboard writes it (with consent). if [ -f "$TARGET/AGENTS.md" ] && grep -q '^; grade prior R-ids fixed/not fixed/no longer applies, bounce any not fixed and edit the same report in place. Form hypotheses from the issue AC and diff before reading the Builder narrative. +Rerun the Tester's tests. When truth_gate triggers, apply super-review's fixed Adversarial mode brief inline in two distinct passes (code-grounder and historian); collect evidence and aggregate confidence against truth_threshold. Over-engineering is Should fix, never Blocker alone and never lowers confidence. Missing evidence fails closed. +Merge ONLY through the unchanged .claude/bin/super-board-merge-gate.sh, pinning the exact head you reviewed at review pass using --expect-head "$HEAD" --config --pr --subject --body-file <msg.md>. Never run bare gh pr merge or bypass the gate. Follow run.md Merge protocol and route every gate exit exactly: 2/5 rebase pass, 3 Blocked, 4 stay Review, 6 fresh review, 7/8 human gate with pinned approval-request; 9 verification-readiness means halt and preserve card/worktree/approval/claims until config repair and explicit restart, never a Build bounce or rebase retry. Respect human_approves_merge and merge_policy. Confirm MERGED <sha>, fetch base and prove merge-base --is-ancestor <sha> origin/<base> before closing issue or moving Done. +''' + +counts = {key: {'type': 'integer'} for key in ['fixed', 'notFixed', 'noLongerApplies']} +properties = { + 'status': {'type': 'string', 'enum': ['advanced', 'bounced', 'blocked', 'human-gate', 'failed', 'halted']}, + 'column': {'type': 'string'}, 'detail': {'type': 'string'}, + 'prUrl': {'type': ['string', 'null']}, 'branch': {'type': ['string', 'null']}, + 'priorFindings': {'anyOf': [{'type': 'null'}, {'type': 'object', 'properties': counts, + 'required': list(counts), 'additionalProperties': False}]}, +} +schema = {'type': 'object', 'properties': properties, 'required': list(properties), 'additionalProperties': False} +JSON_TYPES = {'object': dict, 'array': list, 'string': str, 'null': type(None)} +def conforms(value, rule): + # The subset of JSON Schema the lane schema uses: type, enum, anyOf, properties/required/additionalProperties. + if 'anyOf' in rule: + return any(conforms(value, option) for option in rule['anyOf']) + kinds = rule['type'] if isinstance(rule['type'], list) else [rule['type']] + if not any(type(value) is int if kind == 'integer' else isinstance(value, JSON_TYPES[kind]) for kind in kinds): + return False + if 'enum' in rule and value not in rule['enum']: + return False + if isinstance(value, dict): + if set(value) - set(rule['properties']) or not set(rule['required']) <= set(value): + return False + return all(conforms(value[key], rule['properties'][key]) for key in value) + return True + +# The Antigravity CLI. SB_AGY_BIN overrides it with a full command line (tests: an +# interpreter plus a stub); shutil.which resolves agy.exe / agy.cmd on Windows. +AGY = shlex.split(os.environ.get('SB_AGY_BIN', '')) or [shutil.which('agy') or 'agy'] +inflight = root / '.claude/super-board/inflight' +inflight.mkdir(parents=True, exist_ok=True) +brief = inflight / f"agy-{card['number']}-{args.lane}.prompt.md" +last = inflight / f"agy-{card['number']}-{args.lane}.last.json" +child = None +def interrupted(signum, frame): + if child is not None: + child.terminate() + try: + child.wait(timeout=2) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + result('halted', 'lane interrupted; preserve claims and worktrees') + sys.exit(128 + signum) +signal.signal(signal.SIGTERM, interrupted) +signal.signal(signal.SIGINT, interrupted) + +def keep_raw(returncode, stdout, stderr): + try: + last.write_text(json.dumps({'exit': returncode, 'stdout': stdout, 'stderr': stderr}, indent=1)) + except OSError: + pass + +with tempfile.TemporaryDirectory(prefix='super-board-agy-') as temporary: + schema_path = Path(temporary) / 'schema.json' + schema_path.write_text(json.dumps(schema)) + brief.write_text(prompt, encoding='utf-8') + pointer = (f"Read {brief.relative_to(root).as_posix()} and follow it exactly; it is your whole task. " + "Return only the JSON the brief asks for, matching the schema.") + command = AGY + ['-p', pointer, '--output-format', 'json', '--json-schema', str(schema_path), '--print-timeout', timeout] + if model and model.strip(): + command += ['--model', model] + if effort: + command += ['--effort', effort] + try: + child = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace') + stdout, stderr = child.communicate() + sys.stderr.write(stderr) + require_verification_ready() + lines = [line for line in stdout.splitlines() if line.strip()] + try: + run = json.loads(lines[-1]) if lines else None + except ValueError: + run = None + error = (run or {}).get('error') if isinstance(run, dict) else None + if child.returncode or not isinstance(run, dict) or run.get('status') != 'SUCCESS': + keep_raw(child.returncode, stdout, stderr) + text = f"{error or ''} {stderr}" + if 'RESOURCE_EXHAUSTED' in text or 'quota' in text.lower(): + result('halted', f"agy quota exhausted: {(error or 'RESOURCE_EXHAUSTED').strip()[:160]}; resume when it resets (raw run: {last.relative_to(root).as_posix()})") + sys.exit(79) + status = (run or {}).get('status') if isinstance(run, dict) else None + raise ValueError(f"agy exited {child.returncode}, run status {status or '?'}{': ' + error[:160] if error else ''}") + parsed = run.get('structured_output') + if not conforms(parsed, schema): + keep_raw(child.returncode, stdout, stderr) + raise ValueError('lane result does not match the lane schema') + denied = run.get('denied_actions') or [] + if denied: + names = ', '.join(sorted({str(d.get('display_name') or d.get('action') or '?') for d in denied if isinstance(d, dict)})) + parsed['detail'] += (f' Β· ⚠️ {len(denied)} tool call(s) auto-denied by agy permissions ({names}): ' + 'run .claude/bin/super-board-agy-permissions.py --apply') + print(json.dumps(parsed)) + sys.exit(10 if parsed['status'] == 'halted' and parsed['detail'].startswith('skills-readiness:') + else 9 if parsed['status'] == 'halted' and parsed['detail'].startswith('verification-readiness:') + else 79 if parsed['status'] == 'halted' else 0) + except (OSError, ValueError) as error: + result('failed', f'Antigravity returned no valid lane result: {error} (raw run: {last.relative_to(root).as_posix()})') + sys.exit(1) + finally: + try: + brief.unlink() + except OSError: + pass +PY diff --git a/scripts/super-board-agy-wave.sh b/scripts/super-board-agy-wave.sh new file mode 100644 index 00000000..038b533d --- /dev/null +++ b/scripts/super-board-agy-wave.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Dispatch an explicitly selected Antigravity wave. Thin entry point: the wave +# engine is shared with Codex (super-board-codex-wave.sh), which picks +# super-board-agy-lane.sh as the lane launcher when it sees --agy. One engine keeps +# one PID marker, one cancellation path and one stop protocol. +# Usage: --config f --cards '<JSON array>' [--agy[=model]] [--tier low|medium|high] [--output f] +# --agy is added when absent, so this script always runs an Antigravity wave. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +for arg in "$@"; do + case "$arg" in + --codex|--codex=*) echo "super-board-agy-wave.sh: --codex belongs to super-board-codex-wave.sh" >&2; exit 64 ;; + --agy|--agy=*) exec bash "$HERE/super-board-codex-wave.sh" "$@" ;; + esac +done +exec bash "$HERE/super-board-codex-wave.sh" "$@" --agy diff --git a/scripts/super-board-codex-wave.sh b/scripts/super-board-codex-wave.sh index 0c55b6e0..ae8c458e 100755 --- a/scripts/super-board-codex-wave.sh +++ b/scripts/super-board-codex-wave.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash -# Dispatch an explicitly selected Codex wave; stdout/--output matches the -# Claude workflow's {cards:[summary...], halted} reconciliation contract. -# Usage: --config f --cards '<JSON array>' --codex[=model] [--tier low|medium|high] [--output f] -# Unless one model is pinned (--codex=<model> or codex.model), a cheap router -# (ROUTER_MODEL) grades each Ready card low/medium/high; the lane launcher maps -# tier x grade to a model through its CODEX_LADDERS table. +# Dispatch an explicitly selected headless wave (Codex or Antigravity); stdout/--output +# matches the Claude workflow's {cards:[summary...], halted} reconciliation contract. +# Usage: --config f --cards '<JSON array>' (--codex[=model] | --agy[=model]) +# [--tier low|medium|high] [--output f] +# The host flag picks the lane launcher: super-board-codex-lane.sh or +# super-board-agy-lane.sh (super-board-agy-wave.sh is the entry point that adds --agy). +# Unless one model is pinned (--<host>=<model> or <host>.model), a cheap router grades +# each Ready card low/medium/high; the lane launcher maps tier x grade to a model +# (CODEX_LADDERS) or to a model + effort (AGY_EFFORT / agy.ladder). set -euo pipefail exec python3 - "${BASH_SOURCE[0]}" "$@" <<'PY' import argparse @@ -13,6 +16,8 @@ from concurrent.futures import ThreadPoolExecutor import json import os from pathlib import Path +import shlex +import shutil import signal import subprocess import sys @@ -20,18 +25,38 @@ import tempfile import threading import time -parser = argparse.ArgumentParser(description='Dispatch one explicitly opted-in Codex wave.') +parser = argparse.ArgumentParser(description='Dispatch one explicitly opted-in Codex or Antigravity wave.') parser.add_argument('--config', required=True) parser.add_argument('--cards', required=True, help='planner card array JSON or JSON file') parser.add_argument('--output') parser.add_argument('--tier', default='medium', choices=['low', 'medium', 'high']) raw = sys.argv[2:] -codex = [arg for arg in raw if arg == '--codex' or arg.startswith('--codex=')] -if len(codex) != 1 or (codex[0].startswith('--codex=') and not codex[0].partition('=')[2].strip()): - parser.error('exactly one explicit --codex[=<non-empty-model>] is required') +codex = [arg for arg in raw if arg in ('--codex', '--agy') or arg.startswith(('--codex=', '--agy='))] +if len(codex) != 1 or ('=' in codex[0] and not codex[0].partition('=')[2].strip()): + parser.error('exactly one explicit --codex[=<non-empty-model>] or --agy[=<non-empty-model>] is required') +HOST = codex[0][2:].partition('=')[0] # 'codex' | 'agy' args = parser.parse_args([arg for arg in raw if arg not in codex]) output_path = Path(args.output).resolve() if args.output else None -launcher = Path(sys.argv[1]).resolve().with_name('super-board-codex-lane.sh') +launcher = Path(sys.argv[1]).resolve().with_name(f'super-board-{HOST}-lane.sh') +# The Antigravity CLI: SB_AGY_BIN overrides it with a full command line (tests); +# shutil.which resolves agy.exe / agy.cmd on Windows. +AGY = shlex.split(os.environ.get('SB_AGY_BIN', '')) or [shutil.which('agy') or 'agy'] +# The PATH bash. On Windows a bare 'bash' resolves to System32ash.exe (WSL) before PATH, +# which would run the lane launcher in another filesystem (exit 127 from here). +BASH = shutil.which('bash') or 'bash' +# Lanes run in their own process group so cancellation reaches the CLI and its shells: +# POSIX start_new_session + killpg; Windows a new process group + `taskkill /T`. +GROUP = ({'start_new_session': True} if hasattr(os, 'killpg') + else {'creationflags': getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)}) +def kill_group(process, hard=False): + try: + if hasattr(os, 'killpg'): + os.killpg(process.pid, signal.SIGKILL if hard else signal.SIGTERM) + else: + subprocess.run(['taskkill', '/PID', str(process.pid), '/T', '/F'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except (ProcessLookupError, OSError): + pass try: config_path = Path(args.config).resolve() config = json.loads(config_path.read_text()) @@ -55,8 +80,14 @@ try: raise ValueError('max_workers must be a non-negative integer') workers = workers or max(1, min(16, (os.cpu_count() or 2) - 2)) root = Path(subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()).resolve() - configured = (config.get('codex') or {}).get('model') - pinned = codex[0].startswith('--codex=') or (isinstance(configured, str) and configured.strip()) + host_config = config.get(HOST) or {} + if not isinstance(host_config, dict): + raise ValueError(f'{HOST} must be an object') + configured = host_config.get('model') + pinned = '=' in codex[0] or (isinstance(configured, str) and configured.strip()) + router_model = host_config.get('router_model') if HOST == 'agy' else None + if router_model is not None and not isinstance(router_model, str): + raise ValueError('agy.router_model must be a string') except (OSError, ValueError, subprocess.CalledProcessError) as error: parser.error(str(error)) readiness = launcher.with_name('super-board-verification-ready.py') @@ -75,7 +106,7 @@ try: resolver = importlib.util.module_from_spec(spec) spec.loader.exec_module(resolver) resolver.hydrate(cards, root) - skills = resolver.report(root, 'codex', cards, home=os.environ.get('SUPER_BOARD_SKILLS_HOME'), + skills = resolver.report(root, HOST, cards, home=os.environ.get('SUPER_BOARD_SKILLS_HOME'), catalog=os.environ.get('SUPER_BOARD_SKILLS_CATALOG')) except SystemExit as error: code, detail = error.code, 'required GitHub reads halted while hydrating skills; preserve claims and worktrees' @@ -106,18 +137,12 @@ def cancel(signum, frame): with mutex: processes = list(active) for process in processes: - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - pass - # Process groups include the launcher, Codex, and shell descendants. Kill + kill_group(process) + # Process groups include the launcher, the CLI, and shell descendants. Kill # lingering descendants too, even when their launcher already exited. time.sleep(2) for process in processes: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass + kill_group(process, hard=True) signal.signal(signal.SIGTERM, cancel) signal.signal(signal.SIGINT, cancel) @@ -125,10 +150,11 @@ try: with marker.open('x') as stream: stream.write(owner + '\n') except FileExistsError: - parser.error('Codex wave PID marker exists; reconcile the previous wave before starting another') + parser.error('headless wave PID marker exists; reconcile the previous wave before starting another') # The router only grades scope; it writes nothing. A Codex-only run grades with -# the cheapest Codex model rather than waking Claude. +# the cheapest Codex model rather than waking Claude; an Antigravity run grades +# with agy.router_model (else the CLI default) at --effort low. ROUTER_MODEL = 'gpt-6-luna' GRADE_SCHEMA = {'type': 'object', 'additionalProperties': False, 'required': ['complexity'], 'properties': {'complexity': {'type': 'string', 'enum': ['low', 'medium', 'high']}}} @@ -144,32 +170,43 @@ def grade(card): f"--kind comments -- api repos/{{owner}}/{{repo}}/issues/{card['number']}/comments --paginate --slurp. " 'Grade complexity (low|medium|high) by the scope of change required. Read only: change no file, ' 'label, comment or board state. Exit 79 from the read helper means stop and return nothing.\n') - with tempfile.TemporaryDirectory(prefix='super-board-codex-grade-') as temporary: + with tempfile.TemporaryDirectory(prefix=f'super-board-{HOST}-grade-') as temporary: schema_path, output = Path(temporary) / 'schema.json', Path(temporary) / 'grade.json' schema_path.write_text(json.dumps(GRADE_SCHEMA)) - command = ['codex', 'exec', '-m', ROUTER_MODEL, '-C', str(root), '-c', 'approval_policy="never"', - '-c', 'default_permissions="super-board-grade"', - '-c', 'permissions.super-board-grade.filesystem={":root"="read", ":slash_tmp"="write", ":tmpdir"="write"}', - '-c', 'permissions.super-board-grade.network.enabled=true', - '--output-schema', str(schema_path), '--output-last-message', str(output), '-'] + if HOST == 'agy': + # agy takes the prompt as an argument and prints one JSON line; the grade is its structured_output. + command = AGY + ['-p', prompt, '--output-format', 'json', '--json-schema', str(schema_path), + '--print-timeout', '10m', '--effort', 'low'] + (['--model', router_model] if router_model else []) + else: + command = ['codex', 'exec', '-m', ROUTER_MODEL, '-C', str(root), '-c', 'approval_policy="never"', + '-c', 'default_permissions="super-board-grade"', + '-c', 'permissions.super-board-grade.filesystem={":root"="read", ":slash_tmp"="write", ":tmpdir"="write"}', + '-c', 'permissions.super-board-grade.network.enabled=true', + '--output-schema', str(schema_path), '--output-last-message', str(output), '-'] with mutex: if halted.is_set(): return None, True try: - process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, - text=True, start_new_session=True) + process = subprocess.Popen(command, stdin=subprocess.PIPE, + stdout=subprocess.PIPE if HOST == 'agy' else subprocess.DEVNULL, + text=True, **GROUP) except OSError: return None, False active.add(process) try: - process.communicate(prompt) + stdout, _ = process.communicate(None if HOST == 'agy' else prompt) finally: with mutex: active.discard(process) if process.returncode == 79: return None, True try: - value = json.loads(output.read_text()).get('complexity') + if HOST == 'agy': + lines = [line for line in (stdout or '').splitlines() if line.strip()] + run = json.loads(lines[-1]) if lines else {} + value = (run.get('structured_output') or {}).get('complexity') if run.get('status') == 'SUCCESS' else None + else: + value = json.loads(output.read_text()).get('complexity') except (OSError, ValueError, AttributeError): return None, False return (value if value in ['low', 'medium', 'high'] else None), False @@ -192,7 +229,7 @@ def run_card(card): if halted.is_set(): history.append(dict(lane='none', status='halted', column=current['status'], detail='run halted; preserve claims and worktrees')) break - boundary = resolver.report(root, 'codex', [current], lane=lane, + boundary = resolver.report(root, HOST, [current], lane=lane, home=os.environ.get('SUPER_BOARD_SKILLS_HOME'), catalog=os.environ.get('SUPER_BOARD_SKILLS_CATALOG')) if not boundary['ready']: skills_halted.set() @@ -203,18 +240,15 @@ def run_card(card): process = None lock = inflight / str(card['number']) try: - process = subprocess.Popen(['bash', str(launcher), '--config', str(config_path), + process = subprocess.Popen([BASH, str(launcher), '--config', str(config_path), '--card', json.dumps(current), '--lane', lane, codex[0], '--tier', args.tier] + (['--complexity', complexity] if complexity else []), - stdout=subprocess.PIPE, text=True, start_new_session=True) + stdout=subprocess.PIPE, text=True, **GROUP) active.add(process) lock.write_text(f'PID={process.pid}\nLANE={lane}\nSTARTED={time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}\n') except OSError as error: if process is not None: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass + kill_group(process, hard=True) process.wait() active.discard(process) history.append(dict(lane=lane, status='failed', column=current['status'], diff --git a/scripts/super-board-skills-ready.py b/scripts/super-board-skills-ready.py index 87ce1a9c..8340ce8c 100755 --- a/scripts/super-board-skills-ready.py +++ b/scripts/super-board-skills-ready.py @@ -265,7 +265,7 @@ def __init__(self, root, host, home=None, catalog=None): self.roots = [self.root / '.claude/skills', self.home / '.claude/skills'] self.claude_plugins() else: - # Codex discovery stops at this independent repository's root. + # Codex and Antigravity discovery stops at this independent repository's root. process = subprocess.run(['git', '-C', str(self.root), 'rev-parse', '--show-toplevel'], text=True, capture_output=True) boundary = Path(process.stdout.strip()).resolve() if process.returncode == 0 else self.root @@ -276,6 +276,13 @@ def __init__(self, root, host, home=None, catalog=None): if parent == boundary: break self.roots = [parent / '.agents/skills' for parent in scoped] + if host == 'agy': + # Google Antigravity reads .agents/skills/ (cwd up to the repository root; its bundled + # docs: agy-customizations/SKILL.md) and ~/.agents/skills. Its lanes also read + # .claude/skills/<name>/SKILL.md on instruction (super-board-agy-lane.sh), so that + # project surface counts too. No Codex config.toml semantics apply. + self.roots += [self.root / '.claude/skills', self.home / '.agents/skills'] + elif host == 'codex': codex_home = (self.home / '.codex') if home else Path(os.environ.get('CODEX_HOME', self.home / '.codex')) self.roots += [self.home / '.agents/skills', codex_home / 'skills'] try: @@ -454,7 +461,7 @@ def error(self, message): def main(argv=None): parser = UsageParser(description=__doc__) parser.add_argument('--root', required=True) - parser.add_argument('--host', required=True, choices=['claude', 'codex']) + parser.add_argument('--host', required=True, choices=['claude', 'codex', 'agy']) selection = parser.add_mutually_exclusive_group(required=True) selection.add_argument('--cards') selection.add_argument('--profile', choices=['setup']) diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index 74dc0e25..3f9aa225 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -69,6 +69,28 @@ "model": null }, + // Optional Google Antigravity lane settings. Used by explicit `run --agy` and usage + // fallback "agy"; never selects the backend or grants Review/merge ownership on a + // fallback. See scripts/super-board-agy-lane.sh. + "agy": { + // Model slug passed to `agy -p --model` for every card (list them: `agy models`). A + // slug that already names an effort (gemini-3.8-flash-low) gets no --effort: agy + // refuses the pair. `--agy=<model>` wins; absent/null leaves the ladder below, then + // the CLI default. + "model": null, + // Optional run tier x card grade -> model slug, e.g. + // {"medium": {"low": "gemini-3.8-flash", "medium": "gemini-3.8-flash", "high": "gemini-4-argon"}}. + // A missing cell uses the CLI default model. + "ladder": null, + // Pins `--effort` (low|medium|high|xhigh|max) for every lane. Absent: review high, + // otherwise low/medium/high by tier x grade (AGY_EFFORT in the lane launcher). + "effort": null, + // Per-lane `--print-timeout`. agy's own default waits forever; a build lane needs a cap. + "timeout": "120m", + // Router that grades Ready cards (read only, --effort low). Absent: the CLI default model. + "router_model": null + }, + "target": { // "repo" | "repo+url". A board always drives a repo; `url` is the live or // preview site the Tester may also hit. Testing a live site with no repo is @@ -280,6 +302,7 @@ // (super-board-codex-lane.sh without --codex). Review stays on // Claude: cards that reach Review wait there for the next Claude // wave. The launcher refuses a fallback lane without this value. + // "agy" β€” the same on Google Antigravity (super-board-agy-lane.sh without --agy). "usage_fallback": "none", // Required for Board dispatch and Board-controlled merges: at least one non-blank diff --git a/tests/fixtures/fake_agy.py b/tests/fixtures/fake_agy.py new file mode 100644 index 00000000..bbe8ef94 --- /dev/null +++ b/tests/fixtures/fake_agy.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# Stubbed `agy -p` for the offline Antigravity lane/wave suites; behaviour set by SB_AGY_TEST_* env vars. +# Prints the one JSON line agy 1.4.6 prints: {conversation_id, status, response, structured_output, ...}. +import json, os, re, sys, time +from pathlib import Path +args = sys.argv[1:] +def opt(name, default=None): + return args[args.index(name) + 1] if name in args else default +prompt = opt('-p') or '' +log = os.environ['SB_AGY_TEST_LOG'] +if 'Grade complexity' in prompt: + with open(log, 'a') as stream: + stream.write(json.dumps(dict(args=args, number=int(re.search(r'issue #(\d+)', prompt).group(1)), lane='grade', active=1)) + '\n') + grade = os.environ.get('SB_AGY_TEST_GRADE', 'medium') + status = os.environ.get('SB_AGY_TEST_GRADE_STATUS', 'SUCCESS') + print(json.dumps(dict(conversation_id='g', status=status, response='', structured_output=dict(complexity=grade)))) + sys.exit(int(os.environ.get('SB_AGY_TEST_GRADE_EXIT', '0'))) +# The pointer prompt names the brief file; the brief carries the card and the lane. +brief = Path(re.search(r'^Read (\S+) and follow it', prompt).group(1)).read_text(encoding='utf-8') +card = json.loads(re.search(r'^Card: (.+)$', brief, re.M).group(1)) +lane = re.search(r'Run super-(build|qa|review)', brief).group(1) +counter = Path(log + '.active') +active = int(counter.read_text()) + 1 if counter.exists() else 1 +counter.write_text(str(active)) +with open(log, 'a') as stream: + stream.write(json.dumps(dict(args=args, prompt=brief, number=card['number'], lane=lane, active=active, + schema=json.load(open(opt('--json-schema'))) if opt('--json-schema') else None)) + '\n') +if os.environ.get('SB_AGY_TEST_SLEEP'): + time.sleep(60) +time.sleep(float(os.environ.get('SB_AGY_TEST_DELAY', '0'))) +status = os.environ.get('SB_AGY_TEST_STATUS', 'advanced') +column = {'build': 'QA', 'qa': 'Review', 'review': 'Done'}[lane] if status == 'advanced' else card['status'] +run = dict(conversation_id='t', status=os.environ.get('SB_AGY_TEST_RUN_STATUS', 'SUCCESS'), response='', + duration_seconds=1.0, num_turns=1, usage=dict(input_tokens=1, output_tokens=1, thinking_tokens=0, cache_read_tokens=0, total_tokens=2)) +if os.environ.get('SB_AGY_TEST_ERROR'): + run['error'] = os.environ['SB_AGY_TEST_ERROR'] +if os.environ.get('SB_AGY_TEST_DENIED'): + run['denied_actions'] = [dict(action='command', display_name='RunCommand')] * int(os.environ['SB_AGY_TEST_DENIED']) +if os.environ.get('SB_AGY_TEST_NO_OUTPUT'): + pass +elif os.environ.get('SB_AGY_TEST_RESULT'): + run['structured_output'] = json.loads(os.environ['SB_AGY_TEST_RESULT']) +else: + run['structured_output'] = dict(status=status, column=column, detail=os.environ.get('SB_AGY_TEST_DETAIL', 'stub lane result'), + prUrl=None, branch=None, priorFindings=None) +print('agy log noise must not appear in the result line', file=sys.stderr) +if os.environ.get('SB_AGY_TEST_STDERR'): + print(os.environ['SB_AGY_TEST_STDERR'], file=sys.stderr) +print(json.dumps(run)) +counter.write_text(str(int(counter.read_text()) - 1)) +sys.exit(int(os.environ.get('SB_AGY_TEST_EXIT', '0'))) diff --git a/tests/test-agy-lane.sh b/tests/test-agy-lane.sh new file mode 100644 index 00000000..30cb03f9 --- /dev/null +++ b/tests/test-agy-lane.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Stubbed Antigravity only (tests/fixtures/fake_agy.py through SB_AGY_BIN): model and effort +# precedence and ladders, the effort-in-slug rule, review consent, structured failures (no +# structured_output, run status ERROR, quota exhausted, denied tool calls), wave routing, +# router, halts and the workflow's summary keys. No network, no agy binary. +set -euo pipefail +KIT=$(cd "$(dirname "$0")/.." && pwd) +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/repo" "$T/bare" +git init -q "$T/repo" +git init -q "$T/bare" +python3 "$KIT/tests/fixtures/install_worker_skills.py" "$T/repo" +export SUPER_BOARD_SKILLS_HOME="$T/home"; mkdir -p "$SUPER_BOARD_SKILLS_HOME" +cat > "$T/bin/gh" <<'PYGH' +#!/usr/bin/env python3 +import json,sys +args=sys.argv[1:] +if args[:2]==['issue','view']: + print(json.dumps({'labels':[]} if args[args.index('--json')+1] == 'labels' else {'number':int(args[2]),'title':'fixture','body':''})) +else: + sys.exit('unexpected gh fixture call: '+str(args)) +PYGH +chmod +x "$T/bin/gh" +export PATH="$T/bin:$PATH" +export SB_AGY_TEST_LOG="$T/calls.jsonl" +export SB_AGY_BIN="python3 $KIT/tests/fixtures/fake_agy.py" +cd "$T/repo" +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +CARD='{"number":1,"status":"Ready","title":"example"}' +lane() { bash "$KIT/scripts/super-board-agy-lane.sh" --config "$T/config.json" --card "$CARD" --lane "$@" 2>"$T/stderr"; } +wave() { bash "$KIT/scripts/super-board-agy-wave.sh" --config "$T/config.json" --cards "$CARDS" "$@" 2>"$T/stderr"; } +fail() { echo "FAIL: $*" >&2; cat "$T/stderr" >&2 2>/dev/null || true; exit 1; } +last_opt() { tail -1 "$SB_AGY_TEST_LOG" | jq -r --arg o "$1" '.args | if index($o) then .[index($o)+1] else "none" end'; } + +# Review needs the owner's explicit --agy; the brief carries the merge protocol and the agy host. +RC=0; lane review >"$T/out" || RC=$? +[ "$RC" -eq 78 ] && [ ! -e "$SB_AGY_TEST_LOG" ] || fail 'fallback review must refuse before agy' +lane review --agy >"$T/out" +jq -e '.status == "advanced" and .column == "Done"' "$T/out" >/dev/null || fail 'explicit review allowed' +grep -q -- '--expect-head' "$SB_AGY_TEST_LOG" || fail 'review must pin reviewed head to merge gate' +grep -q 'Never run bare gh pr merge' "$SB_AGY_TEST_LOG" || fail 'review must keep merge protocol' +grep -q -- '--host agy' "$SB_AGY_TEST_LOG" || fail 'the brief must recheck skills for host agy' +grep -q 'invoke_subagent' "$SB_AGY_TEST_LOG" || fail 'the brief must forbid Antigravity sub-agents' +[ "$(last_opt --model)" = none ] || fail 'no model uses the agy default' +[ "$(last_opt --effort)" = high ] || fail 'review always runs at effort high' +[ "$(last_opt --print-timeout)" = 120m ] || fail 'default timeout is 120m' +[ "$(last_opt --output-format)" = json ] || fail 'agy must print json' +tail -1 "$SB_AGY_TEST_LOG" | jq -e '.schema.properties.status.enum | index("advanced")' >/dev/null || fail 'the lane schema must reach --json-schema' +[ ! -e .claude/super-board/inflight/agy-1-review.prompt.md ] || fail 'the brief file must be removed after the run' + +# Model precedence: flag > agy.model > ladder > CLI default; fallback needs usage_fallback "agy". +lane build --agy=flag-model >"$T/out" +[ "$(last_opt --model)" = flag-model ] || fail 'flag model must reach --model' +echo '{"agy":{"model":"config-model"},"verify_commands":["git diff --check"]}' > "$T/config.json" +lane qa --agy >"$T/out" +[ "$(last_opt --model)" = config-model ] || fail 'config model must reach --model' +lane qa --agy=flag-model >"$T/out" +[ "$(last_opt --model)" = flag-model ] || fail 'flag must beat config' +RC=0; lane qa >"$T/out" || RC=$? +[ "$RC" -eq 78 ] || fail 'fallback without usage_fallback "agy" must refuse' +echo '{"agy":{"model":"config-model"},"usage_fallback":"agy","verify_commands":["git diff --check"]}' > "$T/config.json" +lane qa >"$T/out" +[ "$(last_opt --model)" = config-model ] || fail 'build/qa fallback uses config model' +RC=0; lane review >"$T/out" || RC=$? +[ "$RC" -eq 78 ] || fail 'config model cannot authorize fallback review' +RC=0; lane build --agy= >"$T/out" || RC=$? +[ "$RC" -eq 64 ] || fail 'empty explicit model must refuse' + +# Effort ladder: run tier x card grade; an ungraded card takes the medium cell; agy.effort pins. +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +while read -r TIER GRADE WANT; do + lane build --agy --tier "$TIER" ${GRADE:+--complexity "$GRADE"} >"$T/out" + [ "$(last_opt --effort)" = "$WANT" ] || fail "effort $TIER/$GRADE must be $WANT, got $(last_opt --effort)" +done <<'CELLS' +medium low low +medium medium medium +medium high high +low low low +low medium low +low high medium +high low medium +high medium high +high high high +CELLS +lane qa --agy --tier medium >"$T/out" +[ "$(last_opt --effort)" = medium ] || fail 'ungraded card takes the medium cell' +lane build --agy >"$T/out" +[ "$(last_opt --effort)" = none ] || fail 'no tier means no --effort (CLI default)' +echo '{"agy":{"effort":"xhigh"},"verify_commands":["git diff --check"]}' > "$T/config.json" +lane build --agy --tier low --complexity low >"$T/out" +[ "$(last_opt --effort)" = xhigh ] || fail 'agy.effort pins the effort' +echo '{"agy":{"effort":"silly"},"verify_commands":["git diff --check"]}' > "$T/config.json" +RC=0; lane build --agy >"$T/out" || RC=$? +[ "$RC" -eq 64 ] || fail 'an unknown agy.effort must refuse' + +# A slug that names its effort gets no --effort (agy refuses the pair); agy.ladder picks by cell. +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +lane build --agy=gemini-3.8-flash-low --tier high --complexity high >"$T/out" +[ "$(last_opt --model)" = gemini-3.8-flash-low ] && [ "$(last_opt --effort)" = none ] || fail 'an effort suffix in the slug must suppress --effort' +echo '{"agy":{"ladder":{"medium":{"high":"big-model"}},"timeout":"30m"},"verify_commands":["git diff --check"]}' > "$T/config.json" +lane build --agy --tier medium --complexity high >"$T/out" +[ "$(last_opt --model)" = big-model ] || fail 'agy.ladder cell must pick the model' +[ "$(last_opt --print-timeout)" = 30m ] || fail 'agy.timeout must reach --print-timeout' +lane build --agy --tier medium --complexity low >"$T/out" +[ "$(last_opt --model)" = none ] || fail 'a missing ladder cell leaves the CLI default' + +# Structured failures: fail closed, keep the raw run; quota is a halt; denials are reported. +echo '{"verify_commands":["git diff --check"]}' > "$T/config.json" +RC=0; SB_AGY_TEST_NO_OUTPUT=1 lane build --agy >"$T/out" || RC=$? +[ "$RC" -eq 1 ] && jq -e '.status == "failed" and (.detail | contains("raw run"))' "$T/out" >/dev/null || fail 'no structured_output must fail and name the raw run' +[ -f .claude/super-board/inflight/agy-1-build.last.json ] || fail 'the raw run must be kept on failure' +jq -e '.exit == 0 and (.stdout | contains("conversation_id"))' .claude/super-board/inflight/agy-1-build.last.json >/dev/null || fail 'the raw run must hold exit, stdout and stderr' +rm -f .claude/super-board/inflight/agy-1-build.last.json +RC=0; SB_AGY_TEST_RUN_STATUS=ERROR SB_AGY_TEST_ERROR='Individual quota reached. Resets in 20h' SB_AGY_TEST_EXIT=3 lane build --agy >"$T/out" || RC=$? +[ "$RC" -eq 79 ] && jq -e '.status == "halted" and (.detail | contains("quota"))' "$T/out" >/dev/null || fail 'quota exhausted must halt with 79' +RC=0; SB_AGY_TEST_RUN_STATUS=ERROR SB_AGY_TEST_ERROR='Internal error' SB_AGY_TEST_EXIT=1 lane build --agy >"$T/out" || RC=$? +[ "$RC" -eq 1 ] && jq -e '.status == "failed" and (.detail | contains("Internal error"))' "$T/out" >/dev/null || fail 'a run status ERROR must fail with its error' +RC=0; SB_AGY_TEST_EXIT=2 lane build --agy >"$T/out" || RC=$? +[ "$RC" -eq 1 ] && jq -e '.status == "failed"' "$T/out" >/dev/null || fail 'nonzero exit never trusts success JSON' +SB_AGY_TEST_DENIED=2 lane build --agy >"$T/out" +jq -e '.status == "advanced" and (.detail | contains("2 tool call(s) auto-denied")) and (.detail | contains("super-board-agy-permissions.py"))' "$T/out" >/dev/null || fail 'denied tool calls must be named in detail' +[ ! -e .claude/super-board/inflight/agy-1-build.last.json ] || fail 'a valid result keeps no raw run' + +# The whole result is checked against the lane schema, not only status/column/detail. +while IFS= read -r BAD; do + RC=0; SB_AGY_TEST_RESULT="$BAD" lane build --agy >"$T/out" || RC=$? + [ "$RC" -eq 1 ] && jq -e '.status == "failed" and .column == "Ready"' "$T/out" >/dev/null || fail "malformed result must fail: $BAD" +done <<'BAD' +{"status":"advanced","column":"QA","detail":"d","prUrl":7,"branch":null,"priorFindings":null} +{"status":"advanced","column":"QA","detail":"d","prUrl":null,"branch":null,"priorFindings":null,"extra":1} +{"status":"advanced","column":"QA","detail":"d","prUrl":null,"branch":null,"priorFindings":{"fixed":1,"noLongerApplies":0}} +{"status":"done","column":"QA","detail":"d","prUrl":null,"branch":null,"priorFindings":null} +["status","advanced"] +BAD +rm -f .claude/super-board/inflight/agy-1-build.last.json +while IFS= read -r GOOD; do + SB_AGY_TEST_RESULT="$GOOD" lane build --agy >"$T/out" || fail "valid result must exit 0: $GOOD" + [ "$(cat "$T/out")" = "$(jq -c . <<<"$GOOD" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin)))')" ] || fail "valid result must print unchanged: $GOOD" +done <<'GOOD' +{"status":"advanced","column":"QA","detail":"d","prUrl":null,"branch":null,"priorFindings":null} +{"status":"advanced","column":"QA","detail":"d","prUrl":"https://github.com/o/r/pull/2","branch":"sb/1","priorFindings":{"fixed":2,"notFixed":0,"noLongerApplies":1}} +GOOD + +# Wave: routing, model propagation, serialisation, clean exit; the wrapper refuses --codex. +CARDS='[{"number":1,"status":"Ready"},{"number":2,"status":"Ready","labels":["qa"]},{"number":3,"status":"QA"},{"number":4,"status":"Review"}]' +echo '{"max_workers":1,"agy":{"model":"config-model"},"verify_commands":["git diff --check"]}' > "$T/config.json" +: > "$SB_AGY_TEST_LOG" +wave --agy=wave-model --output "$T/wave.json" > "$T/out" +cmp "$T/out" "$T/wave.json" || fail 'file and stdout wave result differ' +jq -e '.halted == false and (.cards | length) == 4 and .cards[0].lanesRun == "build:advanced β†’ qa:advanced β†’ review:advanced" and .cards[1].lanesRun == "qa:advanced β†’ review:advanced" and .cards[2].lastLane == "review" and .cards[3].column == "Done"' "$T/out" >/dev/null || fail 'wave routing/summary' +[ "$(last_opt --model)" = wave-model ] || fail 'wave propagates explicit model' +jq -s -e 'all(.lane != "grade")' "$SB_AGY_TEST_LOG" >/dev/null || fail 'a pinned model skips the router' +jq -s -e 'all(.active == 1)' "$SB_AGY_TEST_LOG" >/dev/null || fail 'max_workers=1 must serialize cards' +[ ! -e .claude/super-board/codex-wave.pid ] && [ -z "$(ls -A .claude/super-board/inflight)" ] || fail 'completed wave leaves no PID/worker locks' +RC=0; bash "$KIT/scripts/super-board-agy-wave.sh" --config "$T/config.json" --cards "$CARDS" --codex >"$T/out" 2>"$T/stderr" || RC=$? +[ "$RC" -eq 64 ] || fail 'the agy wave wrapper must refuse --codex' +RC=0; bash "$KIT/scripts/super-board-codex-wave.sh" --config "$T/config.json" --cards "$CARDS" --agy --codex >"$T/out" 2>"$T/stderr" || RC=$? +[ "$RC" -ne 0 ] || fail 'two host flags must refuse' + +# Router: Ready cards are graded with agy.router_model at effort low when nothing pins a model; +# the grade sets the effort cell; a failed grade falls back to medium. +echo '{"max_workers":1,"agy":{"router_model":"cheap-model"},"verify_commands":["git diff --check"]}' > "$T/config.json" +CARDS='[{"number":1,"status":"Ready","title":"hard"},{"number":2,"status":"Review"}]' +: > "$SB_AGY_TEST_LOG" +SB_AGY_TEST_GRADE=high wave --agy >"$T/out" +python3 - "$SB_AGY_TEST_LOG" <<'PY' +import json, pathlib, sys +calls = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()] +opt = lambda call, name: call['args'][call['args'].index(name) + 1] if name in call['args'] else 'none' +grades = [call for call in calls if call['lane'] == 'grade'] +assert [call['number'] for call in grades] == [1], grades +assert opt(grades[0], '--model') == 'cheap-model' and opt(grades[0], '--effort') == 'low', grades[0]['args'] +card1 = [opt(call, '--effort') for call in calls if call['number'] == 1 and call['lane'] != 'grade'] +assert card1 == ['high', 'high', 'high'], card1 # medium tier x high grade on build and qa, review always high +card2 = [opt(call, '--effort') for call in calls if call['number'] == 2] +assert card2 == ['high'], card2 # review lane +assert all(opt(call, '--model') == 'none' for call in calls if call['lane'] != 'grade'), 'router model must not leak into lanes' +PY +: > "$SB_AGY_TEST_LOG" +SB_AGY_TEST_GRADE_STATUS=ERROR wave --agy >"$T/out" +jq -s -e '[.[] | select(.number == 1 and .lane == "build") | .args | .[index("--effort")+1]] == ["medium"]' "$SB_AGY_TEST_LOG" >/dev/null || fail 'a failed grade falls back to the medium cell' + +# Halts: a halted lane stops dispatch (79); a structured readiness halt keeps its lock (9). +CARDS='[{"number":1,"status":"Ready"},{"number":2,"status":"Review"}]' +echo '{"max_workers":1,"agy":{"model":"m"},"verify_commands":["git diff --check"]}' > "$T/config.json" +: > "$SB_AGY_TEST_LOG" +RC=0; SB_AGY_TEST_STATUS=halted wave --agy >"$T/out" || RC=$? +[ "$RC" -eq 79 ] && [ "$(wc -l < "$SB_AGY_TEST_LOG" | tr -d ' ')" -eq 1 ] || fail 'halt stops all new lane dispatch' +jq -e '.halted and (.cards | length) == 2' "$T/out" >/dev/null || fail 'halt summary preserves all cards' +: > "$SB_AGY_TEST_LOG" +RC=0; SB_AGY_TEST_STATUS=halted SB_AGY_TEST_DETAIL='verification-readiness: checks missing' wave --agy >"$T/out" || RC=$? +[ "$RC" -eq 9 ] && [ -f .claude/super-board/inflight/1 ] || fail 'structured readiness halt preserves lane state with code 9' +rm -f .claude/super-board/inflight/1 +: > "$SB_AGY_TEST_LOG" +RC=0; SB_AGY_TEST_RUN_STATUS=ERROR SB_AGY_TEST_ERROR='RESOURCE_EXHAUSTED' SB_AGY_TEST_EXIT=3 wave --agy >"$T/out" || RC=$? +[ "$RC" -eq 79 ] && jq -e '.halted and (.cards[0].detail | contains("quota"))' "$T/out" >/dev/null || fail 'quota exhausted halts the wave' + +# The wave hands back the workflow's summary keys (the one source: workflows/super-board-wave.js). +python3 - "$KIT/workflows/super-board-wave.js" > "$T/keys.json" <<'PY' +import json, re, sys +text = open(sys.argv[1]).read() +block = re.search(r'const summary = .*?return \{\n(.*?)\n\s*\}\n', text, re.S) +assert block, 'summary object not found in workflows/super-board-wave.js' +print(json.dumps(sorted(re.findall(r'^\s*(\w+):', block.group(1), re.M)))) +PY +python3 - "$T/keys.json" "$T/out" <<'PY' || fail 'agy wave summary drifts from the workflow keys' +import json, sys +keys, result = json.load(open(sys.argv[1])), json.load(open(sys.argv[2])) +assert sorted(result) == ['cards', 'halted'] +for card in result['cards']: + assert sorted(card) == keys, (sorted(card), keys) +PY +cd "$T/bare" +RC=0; wave --agy >"$T/out" || RC=$? +[ "$RC" -eq 10 ] || fail "skills-readiness halt exits 10, got $RC" +echo 'PASS: test-agy-lane.sh (model/effort/ladders/router/review/results/wave/halt)' diff --git a/tests/test_agy_lane_schema.py b/tests/test_agy_lane_schema.py new file mode 100644 index 00000000..d0480f25 --- /dev/null +++ b/tests/test_agy_lane_schema.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Pin the Antigravity lane's --json-schema to STAGE_SCHEMA in workflows/super-board-wave.js. + +Three hosts, one lane-result contract: the status enum and field names must match. +Offline; reads both sources, runs nothing but node on the extracted object literal. +""" +import ast +import json +from pathlib import Path +import re +import subprocess + +KIT = Path(__file__).resolve().parent.parent + + +def lane_schema(): + text = (KIT / 'scripts/super-board-agy-lane.sh').read_text(encoding='utf-8') + source = text.split("<<'PY'\n", 1)[1].rsplit('\nPY', 1)[0] + wanted = {'counts', 'properties', 'schema'} + nodes = [node for node in ast.parse(source).body if isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id in wanted for t in node.targets)] + namespace = {} + exec(compile(ast.Module(body=nodes, type_ignores=[]), 'agy-lane', 'exec'), namespace) + return namespace['schema'] + + +def stage_schema(): + text = (KIT / 'workflows/super-board-wave.js').read_text(encoding='utf-8') + match = re.search(r'^const STAGE_SCHEMA = (\{.*?^\})$', text, re.S | re.M) + assert match, 'STAGE_SCHEMA not found in workflows/super-board-wave.js' + out = subprocess.check_output(['node', '-e', 'process.stdout.write(JSON.stringify(' + match.group(1) + '))'], + text=True) + return json.loads(out) + + +def main(): + lane, stage = lane_schema(), stage_schema() + lane_props, stage_props = lane['properties'], stage['properties'] + assert lane_props['status']['enum'] == stage_props['status']['enum'], ( + lane_props['status']['enum'], stage_props['status']['enum']) + assert sorted(lane_props) == sorted(stage_props), (sorted(lane_props), sorted(stage_props)) + findings = next(option for option in lane_props['priorFindings']['anyOf'] if option['type'] == 'object') + assert sorted(findings['properties']) == sorted(stage_props['priorFindings']['properties']) + assert set(stage['required']) <= set(lane['required']) + print('PASS: test_agy_lane_schema.py (status enum and field names match STAGE_SCHEMA)') + + +if __name__ == '__main__': + main() From 7687885b4d09be21d11b55a91767f04750006cc4 Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi <daniele.galeazzi92@gmail.com> Date: Fri, 23 Oct 2026 22:28:00 +0200 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20lane:=20hand=20the?= =?UTF-8?q?=20card=20JSON=20to=20the=20first=20skills=20check=20as=20a=20f?= =?UTF-8?q?ile,=20not=20stdin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a PowerShell here-string was the one form the lane kept using for it and the permission check kept refusing; the brief now names .claude/super-board/inflight/agy-<n>-<lane>.card.json, removed with the brief Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- scripts/super-board-agy-lane.sh | 26 ++++++++++++++++---------- tests/test-agy-lane.sh | 3 ++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/scripts/super-board-agy-lane.sh b/scripts/super-board-agy-lane.sh index 60371324..6d7ecb0b 100644 --- a/scripts/super-board-agy-lane.sh +++ b/scripts/super-board-agy-lane.sh @@ -14,7 +14,10 @@ # agy -p <pointer> --output-format json --json-schema <file> --print-timeout <agy.timeout> # [--model <slug>] [--effort low|medium|high|xhigh|max] # The brief is a file under .claude/super-board/inflight/ (the pointer prompt names it: -# a Windows command line caps at 8 KB). agy has no working-directory flag, so this script +# a Windows command line caps at 8 KB), and so is the card JSON, so the lane's first +# operation is a plain command with a file argument rather than a here-string or a pipe +# the shell may mangle (on 1.4.6 a PowerShell here-string was the one form a lane kept +# reaching for and the permission check kept refusing). agy has no working-directory flag, so this script # chdirs to the repo root first. The run prints one JSON line: {status, response, # structured_output?, denied_actions?, error?, ...}; the lane result is `structured_output`, # checked against the lane schema like the Codex lane does. Anything else fails closed and @@ -148,11 +151,16 @@ if not skills['ready']: result('halted', 'skills-readiness: missing ' + ', '.join(dict.fromkeys(e['name'] for e in skills['entries'] if e['status'] == 'missing'))) sys.exit(10) +inflight = root / '.claude/super-board/inflight' +inflight.mkdir(parents=True, exist_ok=True) +brief = inflight / f"agy-{card['number']}-{args.lane}.prompt.md" +card_file = inflight / f"agy-{card['number']}-{args.lane}.card.json" +last = inflight / f"agy-{card['number']}-{args.lane}.last.json" skill = {'build': 'super-build', 'qa': 'super-qa', 'review': 'super-review'}[args.lane] section = {'build': 'Builder', 'qa': 'Tester', 'review': 'Reviewer'}[args.lane] py = 'python' if os.name == 'nt' else 'python3' prompt = f'''Run {skill} for issue #{card['number']} as an Antigravity lane worker. -First operation: recheck .claude/bin/super-board-skills-ready.py --root {root} --host agy --cards - --lane {args.lane} with the Card JSON below as a one-element stdin array, before worktree creation, classification or lifecycle writes. Exit 10 means status=halted, detail=skills-readiness:, preserve all state. Load resolved_path files and only declared fallback IDs from the report. Before activating any conditional helper, repeat with --require <exact-name>. +First operation: recheck .claude/bin/super-board-skills-ready.py --root {root} --host agy --cards {card_file.relative_to(root).as_posix()} --lane {args.lane} (that file holds the Card JSON below as a one-element array; pass the path, do not pipe or paste the JSON), before worktree creation, classification or lifecycle writes. Exit 10 means status=halted, detail=skills-readiness:, preserve all state. Load resolved_path files and only declared fallback IDs from the report. Before activating any conditional helper, repeat with --require <exact-name>. Resolved skill report: {json.dumps(skills)} Config: {config_path} Card: {json.dumps(card)} @@ -202,10 +210,6 @@ def conforms(value, rule): # The Antigravity CLI. SB_AGY_BIN overrides it with a full command line (tests: an # interpreter plus a stub); shutil.which resolves agy.exe / agy.cmd on Windows. AGY = shlex.split(os.environ.get('SB_AGY_BIN', '')) or [shutil.which('agy') or 'agy'] -inflight = root / '.claude/super-board/inflight' -inflight.mkdir(parents=True, exist_ok=True) -brief = inflight / f"agy-{card['number']}-{args.lane}.prompt.md" -last = inflight / f"agy-{card['number']}-{args.lane}.last.json" child = None def interrupted(signum, frame): if child is not None: @@ -230,6 +234,7 @@ with tempfile.TemporaryDirectory(prefix='super-board-agy-') as temporary: schema_path = Path(temporary) / 'schema.json' schema_path.write_text(json.dumps(schema)) brief.write_text(prompt, encoding='utf-8') + card_file.write_text(json.dumps([card]), encoding='utf-8') pointer = (f"Read {brief.relative_to(root).as_posix()} and follow it exactly; it is your whole task. " "Return only the JSON the brief asks for, matching the schema.") command = AGY + ['-p', pointer, '--output-format', 'json', '--json-schema', str(schema_path), '--print-timeout', timeout] @@ -273,8 +278,9 @@ with tempfile.TemporaryDirectory(prefix='super-board-agy-') as temporary: result('failed', f'Antigravity returned no valid lane result: {error} (raw run: {last.relative_to(root).as_posix()})') sys.exit(1) finally: - try: - brief.unlink() - except OSError: - pass + for scratch in (brief, card_file): + try: + scratch.unlink() + except OSError: + pass PY diff --git a/tests/test-agy-lane.sh b/tests/test-agy-lane.sh index 30cb03f9..7a129b53 100644 --- a/tests/test-agy-lane.sh +++ b/tests/test-agy-lane.sh @@ -46,7 +46,8 @@ grep -q 'invoke_subagent' "$SB_AGY_TEST_LOG" || fail 'the brief must forbid Anti [ "$(last_opt --print-timeout)" = 120m ] || fail 'default timeout is 120m' [ "$(last_opt --output-format)" = json ] || fail 'agy must print json' tail -1 "$SB_AGY_TEST_LOG" | jq -e '.schema.properties.status.enum | index("advanced")' >/dev/null || fail 'the lane schema must reach --json-schema' -[ ! -e .claude/super-board/inflight/agy-1-review.prompt.md ] || fail 'the brief file must be removed after the run' +grep -q -- '--cards .claude/super-board/inflight/agy-1-review.card.json' "$SB_AGY_TEST_LOG" || fail 'the brief must point the first skills check at the card file, not stdin' +[ ! -e .claude/super-board/inflight/agy-1-review.prompt.md ] && [ ! -e .claude/super-board/inflight/agy-1-review.card.json ] || fail 'the brief and card files must be removed after the run' # Model precedence: flag > agy.model > ladder > CLI default; fallback needs usage_fallback "agy". lane build --agy=flag-model >"$T/out" From 1fca6fb0ac01e25541ce4e3c67d260a424b185fd Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi <daniele.galeazzi92@gmail.com> Date: Fri, 23 Oct 2026 19:25:30 +0200 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20[docs]=20run:=20Antigravit?= =?UTF-8?q?y=20runs,=20--agy=20flags,=20agy=20usage=20guard=20and=20hosts?= =?UTF-8?q?=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run-workflow.md: "Antigravity runs" twin of "Codex runs", plain run inside Antigravity is --agy, agy no longer halts at the host check, preconditions/scan/skills-ready/launch/fallback/resume/stop/allowlist know the host - SKILL.md: --agy[=<model>] in the run verb and routing; onboard: host detect line and the step-8 permissions/status-line extras; setup --host agy - super-board-usage.sh: record reads the agy status line (quota buckets), check --host agy weighs every bucket; tests 11-13 - docs/hosts.md: agy is a supported host, with a first-run checklist Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/hosts.md | 20 +++++- scripts/super-board-setup.py | 4 +- scripts/super-board-usage.sh | 64 +++++++++++++------ skills/super-board/SKILL.md | 11 ++-- .../super-board/references/config-schema.json | 3 +- skills/super-board/references/onboard.md | 18 ++++-- skills/super-board/references/run-workflow.md | 63 ++++++++++++++---- tests/test-usage.sh | 31 ++++++++- 8 files changed, 167 insertions(+), 47 deletions(-) diff --git a/docs/hosts.md b/docs/hosts.md index 4567f13e..6e48a104 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -8,10 +8,28 @@ super-board runs inside an agent host: the CLI whose session types `/super-board | `claude` | Claude Code 2.1.302 | `/super-board run` (Workflow backend) | `claude` then `/login`; `gh auth login` | None. The reference host. | | `codex` | codex-cli 0.160.0 | `/super-board run` inside Codex runs as `--codex`; from Claude, `/super-board run --codex` | `codex login`; `gh auth login` | No Workflow tool, so every lane is headless `codex exec`. Review runs only with explicit `--codex` (a host-switched plain `run` counts). No Claude usage guard. | | `opencode` | OpenCode v2.0.22 | Not supported yet: the run halts with a host-selection request | `opencode auth login`; `gh auth login` | No OpenCode lane backend yet. `permission.external_directory: deny` is not enforced in v2.0.22 (`patch` still writes outside the repo), so OpenCode runs stay attended-only once a backend lands. | -| `agy` | Google Antigravity CLI 1.4.6 (`agy`) | Not supported yet: the run halts with a host-selection request | interactive `agy` once (headless runs use the cached sign-in); `gh auth login` | No Antigravity lane backend yet. Headless `agy -p` auto-denies every shell command and non-workspace file write that `permissions.allow` in `~/.gemini/antigravity-cli/settings.json` does not cover, and on Windows those rules match exactly unless written as `command(regex:…)`. | +| `agy` | Google Antigravity CLI 1.4.6 (`agy`) | `/super-board run` inside Antigravity runs as `--agy`; from Claude, `/super-board run --agy` | interactive `agy` once (headless runs use the cached sign-in); `gh auth login` | No Workflow tool, so every lane is headless `agy -p`. Review runs only with explicit `--agy` (a host-switched plain `run` counts). Headless agy auto-denies every tool call no rule in `~/.gemini/antigravity-cli/settings.json` allows: run `super-board-agy-permissions.py --apply` once (see "First run on Antigravity"). The quota guard reads the agy status line, not Claude's. | Every host also needs `gh`, `jq`, bash 3.2+ and Python 3.9+. +## First run on Antigravity + +Once per machine, on a throwaway board, before an unattended run: + +| # | Check | How | If it fails | +|---|---|---|---| +| 1 | Install for the host | `./install.sh --hosts agy --protect-main <project>` writes `.agents/hooks.json` (the guards through `hooks/agy-hook-adapter.py`) | re-run it; a hook that cannot start blocks every tool call with its error as the reason | +| 2 | Permission rules | `python3 .claude/bin/super-board-agy-permissions.py --apply` (dry run without `--apply`) | without them a headless lane is auto-denied on its first command and "succeeds" having run nothing | +| 3 | Guards fire headless | in the project: `agy -p 'Use run_command to run exactly: git worktree add ../x -b x' --output-format json` β†’ `BLOCKED: tool call denied by pre-tool hook: Blocked: worktree path …` and no worktree | the hook command in `.agents/hooks.json` does not resolve (cwd is `.agents/`; `python` not `python3` on Windows) | +| 4 | An allowed command runs | `agy -p 'Use run_command to run exactly: git status' --output-format json` prints the status | a `denied_actions` entry means step 2 was skipped | +| 5 | Worker skills resolve | `python3 .claude/bin/super-board-skills-ready.py --root . --host agy --profile setup` exits 0 | onboard's πŸ” Checks installs the helpers | +| 6 | Quota reaches the guard | set `statusLine.command` to `bash <repo>/.claude/bin/super-board-usage.sh record`, open `agy` once, then `bash .claude/bin/super-board-usage.sh check --host agy` prints `ok` | the status line only runs in an interactive session | +| 7 | One card end to end | `/super-board run --agy` on a Ready card with acceptance criteria | read `.claude/super-board/inflight/agy-<n>-<lane>.last.json` for the raw run of a lane that returned no result | + +Observed on 1.4.6: model slugs carry an effort (`gemini-3.8-flash-low`) and refuse `--effort`; the +lane passes the base slug. Lanes run in PowerShell 5.1 on Windows and hooks under `cmd /c`. +Quota is per model: an exhausted bucket exits 3 with `RESOURCE_EXHAUSTED` and the lane halts. + ## How the host is picked `super-board-host.sh [--config <config.json>]` prints one value. First match wins: diff --git a/scripts/super-board-setup.py b/scripts/super-board-setup.py index a92bb837..8369d07a 100755 --- a/scripts/super-board-setup.py +++ b/scripts/super-board-setup.py @@ -973,8 +973,8 @@ def main(argv): if cmd in ("check", "fix"): pack = find_pack(flag(rest, "--pack")) host, home, catalog = flag(rest, "--host", "claude"), flag(rest, "--home", os.environ.get("SUPER_BOARD_SKILLS_HOME")), flag(rest, "--catalog", os.environ.get("SUPER_BOARD_SKILLS_CATALOG")) - if host not in ("claude", "codex"): - raise ValueError("--host must be claude or codex") + if host not in ("claude", "codex", "agy"): + raise ValueError("--host must be claude, codex or agy") res = check(root, pack, host, home, catalog) if cmd == "check" else fix(root, pack, "--no-helpers" not in rest, dry, host, home, catalog) if res.get("error") == "pack-not-found": print(json.dumps(res, indent=1)) diff --git a/scripts/super-board-usage.sh b/scripts/super-board-usage.sh index 83eca6b4..e6dcf1bb 100755 --- a/scripts/super-board-usage.sh +++ b/scripts/super-board-usage.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# super-board-usage.sh β€” Claude plan-usage guard for the run loop. +# super-board-usage.sh β€” plan-usage guard for the run loop (Claude Code, or Antigravity). # # WHERE THE NUMBER COMES FROM # @@ -16,19 +16,28 @@ # transcripts and reports tokens/cost per 5-hour block, but not a percentage of # the plan limit, so it cannot answer "are we at 95%". # +# The Antigravity CLI (agy) does the same with its own status line +# (antigravity.google/docs/cli/statusline): `quota.<bucket>.{remaining_fraction 0-1, +# reset_time ISO-8601, reset_in_seconds}`, one bucket per plan window or model +# (`gemini-weekly`, …). `record` recognises that shape by itself, converts every bucket to +# {used_percentage, resets_at} and saves it to the agy snapshot; `check --host agy` +# reads it and weighs every bucket, since the names are not fixed. Only an interactive +# agy session renders a status line; headless `agy -p` lanes do not. +# # So this script has two halves: # # record β€” called from the status-line script with the status-line JSON on -# stdin. Saves `rate_limits` plus a timestamp to the snapshot file. -# Prints nothing, so it never disturbs the status line. +# stdin. Saves `rate_limits` (Claude) or the converted `quota` (agy) +# plus a timestamp to that host's snapshot file. Prints nothing, so it +# never disturbs the status line. # check β€” reads the snapshot and says ok / pause / unknown. # # LIMITS, STATED PLAINLY # -# - The snapshot refreshes only while some interactive Claude Code session with -# the record line is rendering its status line (after each API response). -# Headless `claude -p` workers do not render one. The workflow backend's -# orchestrator IS an interactive session, so its own turns keep it fresh. +# - The snapshot refreshes only while some interactive session with the record +# line is rendering its status line (after each API response). Headless +# workers do not render one. The workflow backend's orchestrator IS an +# interactive session, so its own turns keep it fresh. # - API-key and Bedrock/Vertex users get no `rate_limits` at all β†’ `unknown`. # - Usage only grows inside a window until it resets. A stale reading at or # over the threshold is therefore still a valid "pause" until its resets_at; @@ -36,44 +45,62 @@ # # Usage: # <status-line json> | super-board-usage.sh record -# super-board-usage.sh check [--config <cfg.json>] [--threshold 95] [--max-age 900] +# super-board-usage.sh check [--host claude|agy] [--config <cfg.json>] [--threshold 95] [--max-age 900] # -# Snapshot: $SB_USAGE_FILE, default ~/.claude/super-board/usage.json (usage is -# per account, not per repo, so one file serves every board). +# Snapshots: $SB_USAGE_FILE, default ~/.claude/super-board/usage.json (Claude) and +# $SB_USAGE_FILE_AGY, default ~/.gemini/super-board/usage.json (agy). Usage is per +# account, not per repo, so one file per host serves every board. # # check prints one JSON line: -# {"state":"ok|pause|unknown","window":"five_hour|seven_day|null", +# {"state":"ok|pause|unknown","window":"five_hour|seven_day|<agy bucket>|null", # "used":96,"resets_at":1738425600,"age":42,"reason":"…"} # Exit: 0 ok, 10 pause, 3 unknown, 64 usage error. set -euo pipefail FILE="${SB_USAGE_FILE:-$HOME/.claude/super-board/usage.json}" +AGY_FILE="${SB_USAGE_FILE_AGY:-$HOME/.gemini/super-board/usage.json}" MODE="${1:-}"; [ $# -gt 0 ] && shift case "$MODE" in record) IN=$(cat) RL=$(printf '%s' "$IN" | jq -c '.rate_limits // empty' 2>/dev/null || true) - # No rate_limits (API-key user, or before the session's first response): - # keep the last good snapshot rather than overwrite it with nothing. + if [ -z "$RL" ]; then + # Antigravity status line: quota buckets as remaining fractions + ISO reset times. + RL=$(printf '%s' "$IN" | jq -c ' + (.quota // empty) | select(type == "object" and length > 0) + | with_entries(select(.value.remaining_fraction != null and .value.reset_time != null) + | .value = { used_percentage: (((1 - .value.remaining_fraction) * 1000 | round) / 10), + resets_at: (.value.reset_time | sub("\\.[0-9]+"; "") | fromdateiso8601) }) + | select(length > 0)' 2>/dev/null || true) + [ -n "$RL" ] && FILE="$AGY_FILE" + fi + # No rate_limits and no quota (API-key user, or before the session's first + # response): keep the last good snapshot rather than overwrite it with nothing. [ -n "$RL" ] || exit 0 mkdir -p "$(dirname "$FILE")" TMP="$FILE.$$" jq -n --argjson rl "$RL" '{recorded_at: now | floor, rate_limits: $rl}' > "$TMP" && mv "$TMP" "$FILE" exit 0 ;; check) ;; - *) echo "usage: super-board-usage.sh record | check [--config f] [--threshold N] [--max-age S]" >&2; exit 64 ;; + *) echo "usage: super-board-usage.sh record | check [--host claude|agy] [--config f] [--threshold N] [--max-age S]" >&2; exit 64 ;; esac -THRESHOLD=""; MAX_AGE=900; CONFIG="" +THRESHOLD=""; MAX_AGE=900; CONFIG=""; HOST=claude while [ $# -gt 0 ]; do case "$1" in + --host) HOST="$2"; shift 2 ;; --config) CONFIG="$2"; shift 2 ;; --threshold) THRESHOLD="$2"; shift 2 ;; --max-age) MAX_AGE="$2"; shift 2 ;; *) echo "unknown arg: $1" >&2; exit 64 ;; esac done +case "$HOST" in + claude) ANY_WINDOW=false ;; + agy) FILE="$AGY_FILE"; ANY_WINDOW=true ;; + *) echo "--host must be claude or agy" >&2; exit 64 ;; +esac if [ -z "$THRESHOLD" ] && [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then THRESHOLD=$(jq -r '.usage_pause_pct // empty' "$CONFIG") fi @@ -84,12 +111,13 @@ if [ ! -f "$FILE" ]; then exit 3 fi -OUT=$(jq -c --argjson th "$THRESHOLD" --argjson maxage "$MAX_AGE" ' +OUT=$(jq -c --argjson th "$THRESHOLD" --argjson maxage "$MAX_AGE" --argjson any "$ANY_WINDOW" ' (now | floor) as $now | ($now - (.recorded_at // 0)) as $age - # A window whose reset time has passed is a fresh window: drop it. + # A window whose reset time has passed is a fresh window: drop it. Claude has two + # fixed windows; an agy snapshot names its buckets, so every one counts there. | [ (.rate_limits // {}) | to_entries[] - | select(.key == "five_hour" or .key == "seven_day") + | select($any or .key == "five_hour" or .key == "seven_day") | select((.value.used_percentage // null) != null) | select((.value.resets_at // 0) > $now) | { window: .key, used: .value.used_percentage, resets_at: .value.resets_at } ] as $w diff --git a/skills/super-board/SKILL.md b/skills/super-board/SKILL.md index 8ec440c2..7ccb2642 100644 --- a/skills/super-board/SKILL.md +++ b/skills/super-board/SKILL.md @@ -12,12 +12,12 @@ description: GitHub-Project-driven autonomous pipeline. Five verbs β€” onboard, | `super-board onboard` | interactive | one-time setup wizard; writes `.claude/super-board/configs/<slug>.json` | | `super-board lint` | interactive | walks active-pipeline issues, flags vague ACs, runs pre-flight readiness | | `super-board status` | interactive (read-only) | snapshot of active config, column counts, in-flight workers | -| `super-board run` | in-session (default) | the autonomous loop: waves of the `super-board-wave` dynamic workflow, run from this session (`worker_backend: "claude-p"` opts into the legacy headless runner `.claude/bin/super-board-run.sh`). Also the resume command β€” state lives on the board, not in process memory. Accepts a model-tier flag: `--low` (haiku/sonnet/opus ladder), default = medium (sonnet/opus/session), `--high` (opus for every card). `--codex [--low|--high]` overrides the backend for every lane with the matching Codex GPT ladder; `--codex=<model>` or `codex.model` pins one model for every card (see `references/run-workflow.md`). A host with no Workflow tool (a Codex session) runs as `--codex` by itself. | +| `super-board run` | in-session (default) | the autonomous loop: waves of the `super-board-wave` dynamic workflow, run from this session (`worker_backend: "claude-p"` opts into the legacy headless runner `.claude/bin/super-board-run.sh`). Also the resume command β€” state lives on the board, not in process memory. Accepts a model-tier flag: `--low` (haiku/sonnet/opus ladder), default = medium (sonnet/opus/session), `--high` (opus for every card). `--codex [--low|--high]` overrides the backend for every lane with the matching Codex GPT ladder; `--codex=<model>` or `codex.model` pins one model for every card (see `references/run-workflow.md`). `--agy [--low|--high]` does the same on Google Antigravity (headless `agy -p`, effort ladder); `--agy=<model>` or `agy.model` pins the model. A host with no Workflow tool runs as `--codex` (a Codex session) or `--agy` (an Antigravity session) by itself. | | `super-board stop` | interactive | graceful shutdown: posts "stopped mid-flight" comments on every in-flight issue + PR, releases assignee mutexes, kills workers + dispatcher. Next `super-board run` resumes. | If invoked with no verb, ask which one. -In Codex the skill is not a slash command: it is `$super-board <verb>`, found in `.agents/skills/`. +In Codex the skill is not a slash command: it is `$super-board <verb>`, found in `.agents/skills/`. Antigravity finds it in `.agents/skills/` too. ## Routing @@ -26,7 +26,8 @@ In Codex the skill is not a slash command: it is `$super-board <verb>`, found in | `super-board onboard ...` | `references/onboard.md` | | `super-board lint ...` | `references/lint.md` | | `super-board status ...` | `references/status.md` | -| `super-board run --codex[=<model>] ...`, or any `run` from a host with no Workflow tool (overrides any configured backend) | `references/run-workflow.md` β†’ "Codex runs" | +| `super-board run --codex[=<model>] ...`, or any `run` from a Codex session (no Workflow tool; overrides any configured backend) | `references/run-workflow.md` β†’ "Codex runs" | +| `super-board run --agy[=<model>] ...`, or any `run` from an Antigravity session (`bash .claude/bin/super-board-host.sh` prints `agy`) | `references/run-workflow.md` β†’ "Antigravity runs" | | `super-board run ...` (default β€” `worker_backend` unset or `"workflow"`) | `references/run-workflow.md` (lane lifecycles still come from `references/run.md`) | | `super-board run ...` with config `worker_backend: "claude-p"` (legacy, explicit opt-in) | `references/run.md` | | `super-board stop ...` / "stop the run" / "pause the loop" / "kill super-board" | `references/stop.md` | @@ -40,8 +41,8 @@ In Codex the skill is not a slash command: it is `$super-board <verb>`, found in super-board is an **autonomous trader**. The interactive Claude session that invokes any of the five verbs is an **orchestrator**, not a worker. The orchestrator: -- Validates preconditions, then dispatches per the config's `worker_backend`: `"workflow"` (default) β†’ stay in-session and run the wave loop in `references/run-workflow.md` (launch workflow, reconcile, repeat); `"claude-p"` (legacy, explicit opt-in only) β†’ `nohup .claude/bin/super-board-run.sh`, report PID + log path, exit. Explicit `--codex` overrides either backend with headless Codex waves; so does a host with no Workflow tool, or `bash .claude/bin/super-board-host.sh` printing `codex`. The orchestrator never does product work itself. -- Delegates all build / QA / review work to workers β€” headless `claude -p` (claude-p backend), workflow lane agents (workflow backend), or headless `codex exec` (explicit `--codex`). +- Validates preconditions, then dispatches per the config's `worker_backend`: `"workflow"` (default) β†’ stay in-session and run the wave loop in `references/run-workflow.md` (launch workflow, reconcile, repeat); `"claude-p"` (legacy, explicit opt-in only) β†’ `nohup .claude/bin/super-board-run.sh`, report PID + log path, exit. Explicit `--codex` overrides either backend with headless Codex waves; so does a host with no Workflow tool, or `bash .claude/bin/super-board-host.sh` printing `codex`. Explicit `--agy`, or the host check printing `agy`, does the same with headless Antigravity waves. The orchestrator never does product work itself. +- Delegates all build / QA / review work to workers β€” headless `claude -p` (claude-p backend), workflow lane agents (workflow backend), headless `codex exec` (explicit `--codex`) or headless `agy -p` (explicit `--agy`). - Must NOT do product work itself, must NOT patch the dispatcher mid-run, must NOT wait for workers, must NOT hold context for multi-card progress. If anything goes wrong during a run, the orchestrator captures the symptom and reports back β€” it does not silently expand the task into a fix. See `references/run.md` "Orchestrator delegation contract" for the full rule. diff --git a/skills/super-board/references/config-schema.json b/skills/super-board/references/config-schema.json index 3f9aa225..3c485e1a 100644 --- a/skills/super-board/references/config-schema.json +++ b/skills/super-board/references/config-schema.json @@ -293,7 +293,8 @@ // orchestrator runs super-board-usage.sh check; if the 5-hour or weekly // window is at or above this percentage, no new wave launches until it // resets. Default 95. See references/run-workflow.md β†’ wave loop step 1. - // Explicit `run --codex` skips this Claude-only guard. + // Explicit `run --codex` skips this Claude-only guard; `run --agy` checks the + // Antigravity quota instead (`super-board-usage.sh check --host agy`). "usage_pause_pct": 95, // What the orchestrator does while the usage guard pauses (exit 10). diff --git a/skills/super-board/references/onboard.md b/skills/super-board/references/onboard.md index fd6e8ac8..f88d5384 100644 --- a/skills/super-board/references/onboard.md +++ b/skills/super-board/references/onboard.md @@ -9,7 +9,7 @@ Invoked as `/super-board onboard` (get.sh / install.sh) or `/super-board:super-b ## Host-visible skill checks Checks use `.claude/bin/super-board-skills-ready.py --profile setup --host -<claude|codex>` and show `helper_readiness`: exact files, declared fallbacks, +<claude|codex|agy>` and show `helper_readiness`: exact files, declared fallbacks, optional skips and missing names. Setup checks the unconditional union; a selected card may need fewer helpers. Node or `skills-lock.json` never proves readiness. `check` and dispatch install nothing. Explicit `fix` retains helper @@ -144,8 +144,10 @@ Write it atomically (temp file + rename) after every answer. A halt never loses β”‚ For creation or upkeep read `../../super-qa/references/project-verifier.md`; β”‚ stage real project checks only when source names accepted features. Optional β”‚ verifier gaps never replace or block the required verification commands. - └─ machine time zone for config `timezone`: $TZ, else readlink /etc/localtime | sed - 's#.*zoneinfo/##', else timedatectl show -p Timezone --value, else "UTC". Shown at step 8. + β”œβ”€ machine time zone for config `timezone`: $TZ, else readlink /etc/localtime | sed + β”‚ 's#.*zoneinfo/##', else timedatectl show -p Timezone --value, else "UTC". Shown at step 8. + └─ host: bash $SB/super-board-host.sh (exit 65 β†’ the step-8 question); `agy` adds the + Antigravity extras at step 8. 1. πŸ” CHECKS β€” nothing asked except system tools and sign-ins β”œβ”€ python3 $SB/super-board-setup.py fix --host <actual-worker-host> --text @@ -371,8 +373,14 @@ Write it atomically (temp file + rename) after every answer. A halt never loses β”‚ β”‚ --dest AGENTS.md, pointer --tail <staged tail> --force, block, check β”‚ β”œβ”€ staged project verifier skill/map/scenarios/runner, if source-backed; then β”‚ β”‚ inspect the saved map and resolve the local skill for the actual host. - β”‚ └─ docs/agents/issue-tracker.md; docs/super-board/PROJECT.md (a sub-agent drafts it from - β”‚ manifests + README + tree, no question; listed in the table as "PROJECT.md drafted") + β”‚ β”œβ”€ docs/agents/issue-tracker.md; docs/super-board/PROJECT.md (a sub-agent drafts it from + β”‚ β”‚ manifests + README + tree, no question; listed in the table as "PROJECT.md drafted") + β”‚ └─ host `agy`: `install.sh --hosts agy` has written `.agents/hooks.json` (if not, say + β”‚ "re-run ./install.sh --hosts agy <this-dir>"). Then show `python3 + β”‚ $SB/super-board-agy-permissions.py` (dry run) and run it with `--apply` only after + β”‚ "Write everything": it edits the user's ~/.gemini/antigravity-cli/settings.json + β”‚ (backup kept). Remind once: set the agy status line to + β”‚ `bash <repo>/.claude/bin/super-board-usage.sh record` so the quota guard has a snapshot. └─ Delete onboard-staged/; keep the answers file (re-runs use it). DONE β€” run `python3 $SB/super-board-verification-ready.py --config <saved config>`; diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 1b3de189..31df5643 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -25,10 +25,9 @@ Keep this run choice in each wave and resume command; skip only the Claude usage `--codex=<model>` still apply); it counts as explicit `--codex`, Review included. Say so once, in the first wave report: `πŸ” codex host β€” running as --codex`. Never improvise a Claude workflow wave in Codex. -**Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign), -`opencode` (no OpenCode backend yet) or `agy` (no Antigravity backend yet) halts before any claim, -even with no Workflow tool, with one line: -`β›” unknown host β€” set SUPER_BOARD_HOST=claude|codex and re-run`. Never guess a backend. +**Unknown host halts.** Run the host check before preconditions. Exit 65 (no host sign) or +`opencode` (no OpenCode backend yet) halts before any claim, even with no Workflow tool, with one line: +`β›” unknown host β€” set SUPER_BOARD_HOST=claude|codex|agy and re-run`. Never guess a backend. After the same plan and claim steps, launch this command as a background task: `bash .claude/bin/super-board-codex-wave.sh --config <config-path> --cards <claimed-cards.json> --codex[=<model>] --tier <low|medium|high> --output <wave-result.json>`. @@ -43,6 +42,33 @@ merge gate with `--expect-head <reviewed-SHA>` and verify landing per `run.md`. Concurrent reviews share that gate's merge mutex. Network and repo/git-dir writes use the lane launcher's sandbox settings, including an external submodule git dir. +## Antigravity runs + +`/super-board run --agy` uses headless Google Antigravity (`agy -p`) for every lane of every +wave, overriding `worker_backend`. It is the Codex contract above with another lane launcher: +the same wave engine, PID marker, stop path, reconcile and merge gate. + +| Item | Antigravity | +|---|---| +| Wave | `bash .claude/bin/super-board-agy-wave.sh --config <config-path> --cards <claimed-cards.json> [--agy=<model>] --tier <low\|medium\|high> --output <wave-result.json>` as a background task | +| Lane | `super-board-agy-lane.sh`: the brief in a file under `.claude/super-board/inflight/`, then `agy -p <pointer> --output-format json --json-schema <lane schema> --print-timeout <agy.timeout, 120m> [--model <…>] [--effort <…>]`; the result is the run's `structured_output`, validated like the Codex lane's; a run with none fails closed and keeps the raw run at `inflight/agy-<n>-<lane>.last.json` | +| Router | Ready cards graded read-only by `agy.router_model` (else the CLI default) at `--effort low`; `--agy=<model>` or `agy.model` pins one model and skips it | +| Ladder | model: `--agy=<model>`, else `agy.model`, else `agy.ladder[tier][grade]`, else the CLI default. Effort: `agy.effort`, else Review `high`, else low/medium/high by tier Γ— grade. A slug that names its effort (`gemini-3.8-flash-low`) gets no `--effort`: agy refuses the pair | +| Sub-agents | none: lanes run pre-flight, classification and truth-check inline (no `invoke_subagent`) | +| Permissions | headless agy auto-denies every tool call `permissions.allow` does not cover and still exits 0. Before the first wave: `python3 .claude/bin/super-board-agy-permissions.py --apply` (the lane profile: its docstring says why). A lane that hit a denial says `⚠️ … auto-denied by agy permissions` in its detail: report it, re-run the helper, never pass `--dangerously-skip-permissions` | +| Guards | `.agents/hooks.json` (written by `install.sh --hosts agy`) runs the same guard hooks through `hooks/agy-hook-adapter.py`, before agy's own permission check | +| Quota | over quota agy exits 3 with `RESOURCE_EXHAUSTED`: the lane halts (79) and the wave stops; resume after the reset. `bash .claude/bin/super-board-usage.sh check --host agy --config <config-path>` replaces the Claude usage guard (same exits 0 / 10 pause / 3 unknown); the snapshot comes from the agy status line: set `statusLine.command` in `~/.gemini/antigravity-cli/settings.json` to `bash <repo>/.claude/bin/super-board-usage.sh record`, or pipe your own status line into it | +| Fallback | `usage_fallback: "agy"` lets Build/QA run on `super-board-agy-lane.sh` without `--agy` while Claude is paused; Review never | +| Precondition | `agy` on PATH (`agy --version`), signed in (an interactive `agy` once; headless runs use the cached sign-in and log harmless `not logged into Antigravity` cache warnings at start-up), `bash -n` on both agy launcher scripts | +| Windows | lanes run their commands in PowerShell 5.1 (`python`, no `&&`), hooks under `cmd /c`; the brief says so | + +**Inside Antigravity, plain `run` is `--agy`.** Your tool list has Antigravity tools +(`run_command`, `view_file`, `invoke_subagent`) and no Workflow tool, or +`bash .claude/bin/super-board-host.sh --config <config-path>` prints `agy` β†’ run as `--agy` (tier flags and +`--agy=<model>` still apply); it counts as explicit `--agy`, Review included. Say so once: +`πŸ” antigravity host β€” running as --agy`. Never improvise waves with `invoke_subagent`: +sub-agents return no schema-checked result, and the wave contract depends on one. + ## Required GitHub evidence and a paused run Required reads use `.claude/bin/super-board-github-read.py --kind <shape> -- <gh args>`. @@ -101,6 +127,7 @@ Run the same preconditions as `run.md` Β§Preconditions, minus PID checks: burning tokens): `{ echo '(async function(){'; sed 's/^export const meta/const meta/' .claude/workflows/super-board-wave.js; echo '})'; } | node --check --input-type=module` With `--codex`, instead require `codex` on PATH and `bash -n` on both Codex launcher scripts. + With `--agy`, require `agy` on PATH and `bash -n` on both agy launcher scripts ("Antigravity runs"). 5. Wave marker FIRST, then the legacy check (lock-before-look closes the TOCTOU window where both backends pass each other's checks at once): a. Atomically create `.claude/super-board/inflight/workflow-wave.lock` @@ -108,16 +135,16 @@ Run the same preconditions as `run.md` Β§Preconditions, minus PID checks: `(set -C; printf 'SLUG=%s\nSTARTED=%s\n' <slug> "$(date -u +%FT%TZ)" > <lock>)`. Before replacing an existing marker, check `/workflows` and verify the PID in `.claude/super-board/codex-wave.pid` (plain numeric PID) with `kill -0` plus its process command. - Only if neither backend has an active wave and no repo-local Codex lane - remains is the marker stale from a crashed run β€” replace it. + Only if neither backend has an active wave and no repo-local Codex or Antigravity + lane remains is the marker stale from a crashed run β€” replace it. Remove the lock when the run ends or stops. The legacy dispatcher refuses to start (and halts mid-run) while it exists. b. THEN verify no legacy run is active: BOTH `pgrep -f 'super-board-run.sh'` (the legacy dispatcher idles between dispatches with zero workers alive) and `pgrep -f 'claude -p .*super-board run'` are empty, AND `/workflows` - shows no running super-board-wave, AND no `super-board-codex-wave.sh` or - `super-board-codex-lane.sh` process belongs to this repo. If a legacy run is detected, + shows no running super-board-wave, AND no `super-board-codex-wave.sh`, + `super-board-codex-lane.sh` or `super-board-agy-lane.sh` process belongs to this repo. If a legacy run is detected, remove the lock just created and stop β€” the legacy run won. 6. Crash-recovery sweep (the workflow backend's equivalent of the legacy reaper): with no wave running, strip `bot_identity` from any @@ -142,7 +169,7 @@ Repeat until a done condition or halt gate fires: quota: `gh api repos/<o>/<r>/issues/<n>/comments -f body=…` for a comment, `gh api -X DELETE repos/<o>/<r>/issues/<n>/assignees -f 'assignees[]=<bot>'` to release a claim. Board moves have no REST route; they wait for the reset. - **Usage guard** (Claude waves only; skip for `--codex`, explicit or host-switched) β€” `bash .claude/bin/super-board-usage.sh check --config <config-path>` + **Usage guard** (Claude waves only; skip for `--codex`, explicit or host-switched; `--agy` runs the same check with `--host agy`, see "Antigravity runs") β€” `bash .claude/bin/super-board-usage.sh check --config <config-path>` (Claude 5-hour and weekly plan usage; threshold `usage_pause_pct`, default 95): - exit 0 β†’ launch. - exit 10 with `usage_fallback: "codex"` β†’ resolve the selected remaining @@ -156,6 +183,10 @@ Repeat until a done condition or halt gate fires: result, then reconcile (step 5). Re-run the usage check between cards; on exit 0 return to normal waves. The report line says `πŸ” codex fallback`. Without `usage_fallback: "codex"` the launcher refuses (exit 78) unless `--codex` is given. + - exit 10 with `usage_fallback: "agy"` β†’ the same on Antigravity: resolve the chain for + `--host agy`, then `bash .claude/bin/super-board-agy-lane.sh --config <config-path> --card + '<card JSON>' --lane <build|qa>` without `--agy`, one card at a time; Review remains + refused. The report line says `πŸ” agy fallback`. - exit 10 otherwise (pause) β†’ launch nothing new. A wave already running finishes β€” stopping it mid-lane loses work. Then release claims as usual, post the resume note (one line to the user and the run manifest: @@ -186,7 +217,7 @@ Repeat until a done condition or halt gate fires: read-only with `.claude/bin/super-board-github-read.py --kind issue -- issue view <N> --json number,title,body`. Keep number, status, labels and literal body in a JSON array file. Run `python3 .claude/bin/super-board-skills-ready.py - --root <repo-root> --host <claude|codex> --cards <cards.json>` for the actual + --root <repo-root> --host <claude|codex|agy> --cards <cards.json>` for the actual worker host before sweep/resume/refresh/flag/stranded actions or Claim. Exit 10 names missing helpers: halt, preserve all state, repair the exact visible skill files and restart explicitly. Exit 64 means invalid input. @@ -247,8 +278,9 @@ Repeat until a done condition or halt gate fires: there is no cross-session claim at all, so never run two orchestrators (or /loop re-entries) against the same board without bot_identity. 4. **Launch** β€” With `--codex`, or no Workflow tool, or the host check printing `codex`, use - "Codex runs" above. The host check printing `claude` uses the Workflow tool; exit 65, - `opencode` or `agy` never reaches Launch (see "Unknown host halts"). Workflow tool with + "Codex runs" above; with `--agy`, or the host check printing `agy`, use "Antigravity runs". + The host check printing `claude` uses the Workflow tool; exit 65 or `opencode` never + reaches Launch (see "Unknown host halts"). Workflow tool with `scriptPath: .claude/workflows/super-board-wave.js` and `args: { configPath, cards, humanApprovesMerge, tier }` (`cards` hydrated after the planner: each carries `lane` and `labels`, and a `qa` card skips the Builder). Runs in the background; the @@ -291,7 +323,7 @@ Repeat until a done condition or halt gate fires: `block_rate_alert_pct` of initial Ready; or the user says stop. The halt note is four lines: `πŸ›‘ halted β€” <gate>` Β· the evidence (counts, the cards that kept bouncing) Β· what is left in flight or claimed Β· `Resume: - /super-board run <slug> [--codex[=<model>]] [--low|--high]` (retain the run's choice) and who must act first. + /super-board run <slug> [--codex[=<model>]|--agy[=<model>]] [--low|--high]` (retain the run's choice) and who must act first. 8. Loop to 1. For unattended cadence, the user may wrap this loop in /loop; the orchestrator must still stop at halt gates. @@ -312,7 +344,7 @@ reported as skipped and does not change the completed product work. - Stop: `x` on the run in `/workflows` (or TaskStop), then release assignees for in-flight cards and post "stopped mid-flight" comments (same protocol as `references/stop.md`). Remove `.claude/super-board/inflight/workflow-wave.lock`. -- Codex stop: with GitHub available, run `bash .claude/bin/super-board-stop.sh <slug>`; it validates the wave PID and allows SIGTERM to cancel child lanes before releasing claims. Apply the same claim/comment/lock protocol to ALL selected claimed cards, including queued cards. During a GitHub halt, send SIGTERM only to the verified wave PID, wait for child cancellation, and preserve claims without GitHub writes. +- Codex / Antigravity stop: with GitHub available, run `bash .claude/bin/super-board-stop.sh <slug>`; it validates the wave PID and allows SIGTERM to cancel child lanes before releasing claims. Apply the same claim/comment/lock protocol to ALL selected claimed cards, including queued cards. During a GitHub halt, send SIGTERM only to the verified wave PID, wait for child cancellation, and preserve claims without GitHub writes. - Resume: just run again β€” board state is the only state. A workflow stopped mid-wave can also be resumed in-session via `resumeFromRunId` (completed lane agents return cached results). @@ -345,6 +377,9 @@ don't stall on prompts: "Bash(bash .claude/bin/super-board-throttle.sh:*)", "Bash(bash .claude/bin/super-board-codex-lane.sh:*)", "Bash(bash .claude/bin/super-board-codex-wave.sh:*)", + "Bash(bash .claude/bin/super-board-agy-lane.sh:*)", + "Bash(bash .claude/bin/super-board-agy-wave.sh:*)", + "Bash(python3 .claude/bin/super-board-agy-permissions.py:*)", "Bash(bash .claude/bin/super-board-host.sh:*)", "Bash(bash .claude/bin/super-board-pr-body.sh:*)", "Bash(gh pr edit:*)", "Bash(bash .claude/bin/super-board-stop.sh:*)", diff --git a/tests/test-usage.sh b/tests/test-usage.sh index 14f06fbe..8684c660 100755 --- a/tests/test-usage.sh +++ b/tests/test-usage.sh @@ -68,4 +68,33 @@ check; [ "$RC" -eq 10 ] || fail "stale over-threshold must still pause, got $RC: echo 'not json' > "$SB_USAGE_FILE"; check [ "$RC" -eq 3 ] || fail "unreadable snapshot should exit 3, got $RC" -echo "PASS: test-usage.sh (10 scenarios)" +# 11 β€” Antigravity: record recognises the agy status line (quota buckets as remaining +# fractions + ISO reset times), keeps it in the agy snapshot, and leaves the Claude one alone. +export SB_USAGE_FILE_AGY="$TMP/agy.json" +feed 40 70 +RESET=$(date -u -d "@$WEEK" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -r "$WEEK" +%Y-%m-%dT%H:%M:%SZ) +agy_feed() { # $1 = gemini-weekly remaining fraction, $2 = gemini-3.8-flash remaining fraction + printf '{"model":{"id":"auto"},"quota":{"gemini-weekly":{"remaining_fraction":%s,"reset_time":"%s","reset_in_seconds":1000},"gemini-3.8-flash":{"remaining_fraction":%s,"reset_time":"%s"}}}' \ + "$1" "$RESET" "$2" "$RESET" | "$U" record +} +[ -z "$(agy_feed 0.938 0.5)" ] || fail "agy record must print nothing" +jq -e '.rate_limits["gemini-weekly"].used_percentage == 6.2 and .rate_limits["gemini-3.8-flash"].used_percentage == 50' "$SB_USAGE_FILE_AGY" >/dev/null || fail "agy record must convert fractions to used_percentage: $(cat "$SB_USAGE_FILE_AGY")" +check; [ "$RC" -eq 0 ] || fail "an agy record must not touch the Claude snapshot, got $RC: $OUT" + +# 12 β€” check --host agy weighs every bucket by name: ok under threshold, pause over it. +check --host agy +[ "$RC" -eq 0 ] || fail "agy 6.2/50 should be ok, got $RC: $OUT" +echo "$OUT" | jq -e '.window == "gemini-3.8-flash" and .used == 50' >/dev/null || fail "agy check must report the highest bucket: $OUT" +agy_feed 0.03 0.5; check --host agy +[ "$RC" -eq 10 ] || fail "gemini-weekly at 97% should pause, got $RC: $OUT" +echo "$OUT" | jq -e '.window == "gemini-weekly" and .used == 97' >/dev/null || fail "agy pause must name the bucket: $OUT" +echo '{"usage_pause_pct":40}' > "$TMP/a.json" +agy_feed 0.9 0.5; check --host agy --config "$TMP/a.json" +[ "$RC" -eq 10 ] || fail "usage_pause_pct applies to the agy check too, got $RC" + +# 13 β€” no agy snapshot reads unknown (3); a bad --host is a usage error (64). +rm -f "$SB_USAGE_FILE_AGY"; check --host agy +[ "$RC" -eq 3 ] || fail "no agy snapshot should exit 3, got $RC" +check --host gemini; [ "$RC" -eq 64 ] || fail "unknown host should exit 64, got $RC" + +echo "PASS: test-usage.sh (13 scenarios)" From 9b0c1d0bc3f3ffedfbfb9be9fc989c2e2de76b67 Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi <daniele.galeazzi92@gmail.com> Date: Fri, 23 Oct 2026 22:16:16 +0200 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20usage:=20weigh=20o?= =?UTF-8?q?nly=20the=20quota=20buckets=20of=20the=20model=20the=20lanes=20?= =?UTF-8?q?use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agy 1.4.6 reports gemini-5h, gemini-weekly, gemini-argon-wtu-weekly, 3p-5h, 3p-weekly: quota is per model family, so a flash run must not pause on an exhausted Argon bucket - check --host agy takes --model, else agy.model from --config; no model known weighs every bucket; test 14 uses the five real buckets Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/hosts.md | 2 +- scripts/super-board-usage.sh | 43 +++++++++++++++---- skills/super-board/references/run-workflow.md | 2 +- tests/test-usage.sh | 19 +++++++- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/docs/hosts.md b/docs/hosts.md index 6e48a104..70fc189a 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -23,7 +23,7 @@ Once per machine, on a throwaway board, before an unattended run: | 3 | Guards fire headless | in the project: `agy -p 'Use run_command to run exactly: git worktree add ../x -b x' --output-format json` β†’ `BLOCKED: tool call denied by pre-tool hook: Blocked: worktree path …` and no worktree | the hook command in `.agents/hooks.json` does not resolve (cwd is `.agents/`; `python` not `python3` on Windows) | | 4 | An allowed command runs | `agy -p 'Use run_command to run exactly: git status' --output-format json` prints the status | a `denied_actions` entry means step 2 was skipped | | 5 | Worker skills resolve | `python3 .claude/bin/super-board-skills-ready.py --root . --host agy --profile setup` exits 0 | onboard's πŸ” Checks installs the helpers | -| 6 | Quota reaches the guard | set `statusLine.command` to `bash <repo>/.claude/bin/super-board-usage.sh record`, open `agy` once, then `bash .claude/bin/super-board-usage.sh check --host agy` prints `ok` | the status line only runs in an interactive session | +| 6 | Quota reaches the guard | set `statusLine.command` to `bash <repo>/.claude/bin/super-board-usage.sh record`, open `agy` once, then `bash .claude/bin/super-board-usage.sh check --host agy --config <config>` prints `ok` with the buckets of `agy.model` (`gemini-5h` / `gemini-weekly` for a Gemini slug, `3p-*` for Claude or GPT) | the status line only runs in an interactive session | | 7 | One card end to end | `/super-board run --agy` on a Ready card with acceptance criteria | read `.claude/super-board/inflight/agy-<n>-<lane>.last.json` for the raw run of a lane that returned no result | Observed on 1.4.6: model slugs carry an effort (`gemini-3.8-flash-low`) and refuse `--effort`; the diff --git a/scripts/super-board-usage.sh b/scripts/super-board-usage.sh index e6dcf1bb..6ca99d22 100755 --- a/scripts/super-board-usage.sh +++ b/scripts/super-board-usage.sh @@ -19,10 +19,15 @@ # The Antigravity CLI (agy) does the same with its own status line # (antigravity.google/docs/cli/statusline): `quota.<bucket>.{remaining_fraction 0-1, # reset_time ISO-8601, reset_in_seconds}`, one bucket per plan window or model -# (`gemini-weekly`, …). `record` recognises that shape by itself, converts every bucket to -# {used_percentage, resets_at} and saves it to the agy snapshot; `check --host agy` -# reads it and weighs every bucket, since the names are not fixed. Only an interactive -# agy session renders a status line; headless `agy -p` lanes do not. +# (observed on 1.4.6: `gemini-5h`, `gemini-weekly`, `gemini-argon-wtu-weekly`, `3p-5h`, +# `3p-weekly`; "3p" is the third-party models, Claude and GPT). `record` recognises that +# shape by itself, converts every bucket to {used_percentage, resets_at} and saves it to +# the agy snapshot. Quota is per model family, so `check --host agy` weighs only the +# buckets of the model the lanes use (`--model <slug>`, else the config's `agy.model`): +# the family's own buckets (`gemini-5h`, `gemini-weekly` for a `gemini-*` slug; `3p-*` +# for `claude-*` / `gpt-*`) plus a model-specific one (`gemini-argon-…`) only when the +# slug names that model. No model known β†’ every bucket counts. Only an interactive agy +# session renders a status line; headless `agy -p` lanes do not. # # So this script has two halves: # @@ -45,7 +50,7 @@ # # Usage: # <status-line json> | super-board-usage.sh record -# super-board-usage.sh check [--host claude|agy] [--config <cfg.json>] [--threshold 95] [--max-age 900] +# super-board-usage.sh check [--host claude|agy] [--model <agy slug>] [--config <cfg.json>] [--threshold 95] [--max-age 900] # # Snapshots: $SB_USAGE_FILE, default ~/.claude/super-board/usage.json (Claude) and # $SB_USAGE_FILE_AGY, default ~/.gemini/super-board/usage.json (agy). Usage is per @@ -83,13 +88,14 @@ case "$MODE" in jq -n --argjson rl "$RL" '{recorded_at: now | floor, rate_limits: $rl}' > "$TMP" && mv "$TMP" "$FILE" exit 0 ;; check) ;; - *) echo "usage: super-board-usage.sh record | check [--host claude|agy] [--config f] [--threshold N] [--max-age S]" >&2; exit 64 ;; + *) echo "usage: super-board-usage.sh record | check [--host claude|agy] [--model slug] [--config f] [--threshold N] [--max-age S]" >&2; exit 64 ;; esac -THRESHOLD=""; MAX_AGE=900; CONFIG=""; HOST=claude +THRESHOLD=""; MAX_AGE=900; CONFIG=""; HOST=claude; MODEL="" while [ $# -gt 0 ]; do case "$1" in --host) HOST="$2"; shift 2 ;; + --model) MODEL="$2"; shift 2 ;; --config) CONFIG="$2"; shift 2 ;; --threshold) THRESHOLD="$2"; shift 2 ;; --max-age) MAX_AGE="$2"; shift 2 ;; @@ -105,19 +111,38 @@ if [ -z "$THRESHOLD" ] && [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then THRESHOLD=$(jq -r '.usage_pause_pct // empty' "$CONFIG") fi THRESHOLD="${THRESHOLD:-95}" +if [ "$HOST" = agy ] && [ -z "$MODEL" ] && [ -n "$CONFIG" ] && [ -f "$CONFIG" ]; then + MODEL=$(jq -r '.agy.model // empty' "$CONFIG") +fi +# The bucket family of the model in use: gemini-* slugs draw on the gemini buckets, every +# other slug (claude-*, gpt-*) on the third-party ones. Empty = weigh every bucket. +FAMILY="" +case "$MODEL" in + '') ;; + gemini-*) FAMILY=gemini ;; + *) FAMILY=3p ;; +esac if [ ! -f "$FILE" ]; then echo '{"state":"unknown","window":null,"used":null,"resets_at":null,"age":null,"reason":"no usage snapshot β€” add the record line to your status-line script (run-workflow.md)"}' exit 3 fi -OUT=$(jq -c --argjson th "$THRESHOLD" --argjson maxage "$MAX_AGE" --argjson any "$ANY_WINDOW" ' +OUT=$(jq -c --argjson th "$THRESHOLD" --argjson maxage "$MAX_AGE" --argjson any "$ANY_WINDOW" \ + --arg family "$FAMILY" --arg model "$MODEL" ' (now | floor) as $now | ($now - (.recorded_at // 0)) as $age # A window whose reset time has passed is a fresh window: drop it. Claude has two - # fixed windows; an agy snapshot names its buckets, so every one counts there. + # fixed windows; an agy snapshot names its buckets: the family'"'"'s own (<family>-5h, + # <family>-weekly) always count, a model-specific one (<family>-<model>-…) only when + # the slug names that model, and with no known family every bucket counts. | [ (.rate_limits // {}) | to_entries[] | select($any or .key == "five_hour" or .key == "seven_day") + | select($family == "" + or ((.key | startswith($family + "-")) + and ((.key | test("^" + $family + "-(5h|weekly)$")) + or ((.key | capture("^" + $family + "-(?<m>[a-z0-9.]+)-") | .m) as $m + | ($model | ascii_downcase | contains($m)))))) | select((.value.used_percentage // null) != null) | select((.value.resets_at // 0) > $now) | { window: .key, used: .value.used_percentage, resets_at: .value.resets_at } ] as $w diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 31df5643..6f842e9a 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -57,7 +57,7 @@ the same wave engine, PID marker, stop path, reconcile and merge gate. | Sub-agents | none: lanes run pre-flight, classification and truth-check inline (no `invoke_subagent`) | | Permissions | headless agy auto-denies every tool call `permissions.allow` does not cover and still exits 0. Before the first wave: `python3 .claude/bin/super-board-agy-permissions.py --apply` (the lane profile: its docstring says why). A lane that hit a denial says `⚠️ … auto-denied by agy permissions` in its detail: report it, re-run the helper, never pass `--dangerously-skip-permissions` | | Guards | `.agents/hooks.json` (written by `install.sh --hosts agy`) runs the same guard hooks through `hooks/agy-hook-adapter.py`, before agy's own permission check | -| Quota | over quota agy exits 3 with `RESOURCE_EXHAUSTED`: the lane halts (79) and the wave stops; resume after the reset. `bash .claude/bin/super-board-usage.sh check --host agy --config <config-path>` replaces the Claude usage guard (same exits 0 / 10 pause / 3 unknown); the snapshot comes from the agy status line: set `statusLine.command` in `~/.gemini/antigravity-cli/settings.json` to `bash <repo>/.claude/bin/super-board-usage.sh record`, or pipe your own status line into it | +| Quota | over quota agy exits 3 with `RESOURCE_EXHAUSTED`: the lane halts (79) and the wave stops; resume after the reset. `bash .claude/bin/super-board-usage.sh check --host agy --config <config-path>` replaces the Claude usage guard (same exits 0 / 10 pause / 3 unknown). Quota is per model family: it weighs the buckets of `agy.model` (or `--model <slug>`): a `gemini-*` slug reads `gemini-5h` / `gemini-weekly`, a `claude-*` or `gpt-*` slug the `3p-*` ones, a model-specific bucket such as `gemini-argon-wtu-weekly` only when the slug names it. The snapshot comes from the agy status line: set `statusLine.command` in `~/.gemini/antigravity-cli/settings.json` to `bash <repo>/.claude/bin/super-board-usage.sh record`, or pipe your own status line into it | | Fallback | `usage_fallback: "agy"` lets Build/QA run on `super-board-agy-lane.sh` without `--agy` while Claude is paused; Review never | | Precondition | `agy` on PATH (`agy --version`), signed in (an interactive `agy` once; headless runs use the cached sign-in and log harmless `not logged into Antigravity` cache warnings at start-up), `bash -n` on both agy launcher scripts | | Windows | lanes run their commands in PowerShell 5.1 (`python`, no `&&`), hooks under `cmd /c`; the brief says so | diff --git a/tests/test-usage.sh b/tests/test-usage.sh index 8684c660..99296038 100755 --- a/tests/test-usage.sh +++ b/tests/test-usage.sh @@ -97,4 +97,21 @@ rm -f "$SB_USAGE_FILE_AGY"; check --host agy [ "$RC" -eq 3 ] || fail "no agy snapshot should exit 3, got $RC" check --host gemini; [ "$RC" -eq 64 ] || fail "unknown host should exit 64, got $RC" -echo "PASS: test-usage.sh (13 scenarios)" +# 14 β€” quota is per model family: with the five buckets agy 1.4.6 reports, a gemini-3.8-flash +# run ignores the exhausted Argon bucket and the third-party ones; an Argon run pauses on +# it; a Claude slug reads the 3p buckets; agy.model in the config stands in for --model; +# no model known weighs every bucket. +printf '{"quota":{"3p-5h":{"remaining_fraction":1,"reset_time":"%s"},"3p-weekly":{"remaining_fraction":0.446,"reset_time":"%s"},"gemini-5h":{"remaining_fraction":0.874,"reset_time":"%s"},"gemini-argon-wtu-weekly":{"remaining_fraction":0,"reset_time":"%s"},"gemini-weekly":{"remaining_fraction":0.13,"reset_time":"%s"}}}' \ + "$RESET" "$RESET" "$RESET" "$RESET" "$RESET" | "$U" record +check --host agy --model gemini-3.8-flash +[ "$RC" -eq 0 ] || fail "a flash run must ignore the Argon bucket, got $RC: $OUT" +echo "$OUT" | jq -e '.window == "gemini-weekly" and .used == 87' >/dev/null || fail "a flash run weighs the gemini buckets: $OUT" +check --host agy --model gemini-4-argon-high +[ "$RC" -eq 10 ] && echo "$OUT" | jq -e '.window == "gemini-argon-wtu-weekly"' >/dev/null || fail "an Argon run must pause on its bucket, got $RC: $OUT" +check --host agy --model claude-opus-5-5 +[ "$RC" -eq 0 ] && echo "$OUT" | jq -e '.window == "3p-weekly" and .used == 55.4' >/dev/null || fail "a Claude slug reads the 3p buckets, got $RC: $OUT" +echo '{"agy":{"model":"gemini-3.8-flash"}}' > "$TMP/m.json" +check --host agy --config "$TMP/m.json"; [ "$RC" -eq 0 ] || fail "agy.model from the config must select the family, got $RC: $OUT" +check --host agy; [ "$RC" -eq 10 ] || fail "with no model every bucket counts, got $RC: $OUT" + +echo "PASS: test-usage.sh (14 scenarios)" From ca3bc5ba28f642161000439bb62102af80fcf29b Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi <daniele.galeazzi92@gmail.com> Date: Fri, 23 Oct 2026 22:28:04 +0200 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=9D=20[docs]=20hosts:=20the=20BO?= =?UTF-8?q?M=20agy=20writes=20into=20its=20settings,=20and=20the=20lane=20?= =?UTF-8?q?denied=20on=20its=20first=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/hosts.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/hosts.md b/docs/hosts.md index 70fc189a..516ba15c 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -25,6 +25,7 @@ Once per machine, on a throwaway board, before an unattended run: | 5 | Worker skills resolve | `python3 .claude/bin/super-board-skills-ready.py --root . --host agy --profile setup` exits 0 | onboard's πŸ” Checks installs the helpers | | 6 | Quota reaches the guard | set `statusLine.command` to `bash <repo>/.claude/bin/super-board-usage.sh record`, open `agy` once, then `bash .claude/bin/super-board-usage.sh check --host agy --config <config>` prints `ok` with the buckets of `agy.model` (`gemini-5h` / `gemini-weekly` for a Gemini slug, `3p-*` for Claude or GPT) | the status line only runs in an interactive session | | 7 | One card end to end | `/super-board run --agy` on a Ready card with acceptance criteria | read `.claude/super-board/inflight/agy-<n>-<lane>.last.json` for the raw run of a lane that returned no result | +| 8 | A lane denied on its very first command, after earlier lanes ran | `head -c 3 ~/.gemini/antigravity-cli/settings.json`: agy 1.4.6 rewrites its settings with a UTF-8 BOM (seen one minute into a lane) and then loads `permissions=<nil>` from it | `python3 .claude/bin/super-board-agy-permissions.py --apply` rewrites the file without the BOM | Observed on 1.4.6: model slugs carry an effort (`gemini-3.8-flash-low`) and refuse `--effort`; the lane passes the base slug. Lanes run in PowerShell 5.1 on Windows and hooks under `cmd /c`. From 1307dcb3c6c6f19cfe57e8b8e05762dd42951e60 Mon Sep 17 00:00:00 2001 From: Daniele Galeazzi <daniele.galeazzi92@gmail.com> Date: Fri, 23 Oct 2026 22:55:43 +0200 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=93=9D=20[docs]=20hosts:=20the=20ag?= =?UTF-8?q?y=20status=20line=20needs=20Git=20Bash=20by=20path=20on=20Windo?= =?UTF-8?q?ws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agy runs statusLine.command through cmd /c, where a bare bash is WSL's and cannot open C:/ paths (observed: exit 127 on first interactive start) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/hosts.md | 2 +- skills/super-board/references/run-workflow.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/hosts.md b/docs/hosts.md index 516ba15c..12cd0b8f 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -23,7 +23,7 @@ Once per machine, on a throwaway board, before an unattended run: | 3 | Guards fire headless | in the project: `agy -p 'Use run_command to run exactly: git worktree add ../x -b x' --output-format json` β†’ `BLOCKED: tool call denied by pre-tool hook: Blocked: worktree path …` and no worktree | the hook command in `.agents/hooks.json` does not resolve (cwd is `.agents/`; `python` not `python3` on Windows) | | 4 | An allowed command runs | `agy -p 'Use run_command to run exactly: git status' --output-format json` prints the status | a `denied_actions` entry means step 2 was skipped | | 5 | Worker skills resolve | `python3 .claude/bin/super-board-skills-ready.py --root . --host agy --profile setup` exits 0 | onboard's πŸ” Checks installs the helpers | -| 6 | Quota reaches the guard | set `statusLine.command` to `bash <repo>/.claude/bin/super-board-usage.sh record`, open `agy` once, then `bash .claude/bin/super-board-usage.sh check --host agy --config <config>` prints `ok` with the buckets of `agy.model` (`gemini-5h` / `gemini-weekly` for a Gemini slug, `3p-*` for Claude or GPT) | the status line only runs in an interactive session | +| 6 | Quota reaches the guard | set `statusLine.command` to `bash <repo>/.claude/bin/super-board-usage.sh record` (on Windows name Git Bash by path, without spaces: `C:\PROGRA~1\Git\bin\bash.exe C:/<repo>/.claude/bin/super-board-usage.sh record`; agy runs the status line through `cmd /c`, where a bare `bash` is WSL's and cannot see `C:/` paths), open `agy` once, then `bash .claude/bin/super-board-usage.sh check --host agy --config <config>` prints `ok` with the buckets of `agy.model` (`gemini-5h` / `gemini-weekly` for a Gemini slug, `3p-*` for Claude or GPT) | the status line only runs in an interactive session | | 7 | One card end to end | `/super-board run --agy` on a Ready card with acceptance criteria | read `.claude/super-board/inflight/agy-<n>-<lane>.last.json` for the raw run of a lane that returned no result | | 8 | A lane denied on its very first command, after earlier lanes ran | `head -c 3 ~/.gemini/antigravity-cli/settings.json`: agy 1.4.6 rewrites its settings with a UTF-8 BOM (seen one minute into a lane) and then loads `permissions=<nil>` from it | `python3 .claude/bin/super-board-agy-permissions.py --apply` rewrites the file without the BOM | diff --git a/skills/super-board/references/run-workflow.md b/skills/super-board/references/run-workflow.md index 6f842e9a..720570ab 100644 --- a/skills/super-board/references/run-workflow.md +++ b/skills/super-board/references/run-workflow.md @@ -57,7 +57,7 @@ the same wave engine, PID marker, stop path, reconcile and merge gate. | Sub-agents | none: lanes run pre-flight, classification and truth-check inline (no `invoke_subagent`) | | Permissions | headless agy auto-denies every tool call `permissions.allow` does not cover and still exits 0. Before the first wave: `python3 .claude/bin/super-board-agy-permissions.py --apply` (the lane profile: its docstring says why). A lane that hit a denial says `⚠️ … auto-denied by agy permissions` in its detail: report it, re-run the helper, never pass `--dangerously-skip-permissions` | | Guards | `.agents/hooks.json` (written by `install.sh --hosts agy`) runs the same guard hooks through `hooks/agy-hook-adapter.py`, before agy's own permission check | -| Quota | over quota agy exits 3 with `RESOURCE_EXHAUSTED`: the lane halts (79) and the wave stops; resume after the reset. `bash .claude/bin/super-board-usage.sh check --host agy --config <config-path>` replaces the Claude usage guard (same exits 0 / 10 pause / 3 unknown). Quota is per model family: it weighs the buckets of `agy.model` (or `--model <slug>`): a `gemini-*` slug reads `gemini-5h` / `gemini-weekly`, a `claude-*` or `gpt-*` slug the `3p-*` ones, a model-specific bucket such as `gemini-argon-wtu-weekly` only when the slug names it. The snapshot comes from the agy status line: set `statusLine.command` in `~/.gemini/antigravity-cli/settings.json` to `bash <repo>/.claude/bin/super-board-usage.sh record`, or pipe your own status line into it | +| Quota | over quota agy exits 3 with `RESOURCE_EXHAUSTED`: the lane halts (79) and the wave stops; resume after the reset. `bash .claude/bin/super-board-usage.sh check --host agy --config <config-path>` replaces the Claude usage guard (same exits 0 / 10 pause / 3 unknown). Quota is per model family: it weighs the buckets of `agy.model` (or `--model <slug>`): a `gemini-*` slug reads `gemini-5h` / `gemini-weekly`, a `claude-*` or `gpt-*` slug the `3p-*` ones, a model-specific bucket such as `gemini-argon-wtu-weekly` only when the slug names it. The snapshot comes from the agy status line: set `statusLine.command` in `~/.gemini/antigravity-cli/settings.json` to `bash <repo>/.claude/bin/super-board-usage.sh record` (Windows: `C:\PROGRA~1\Git\bin\bash.exe C:/<repo>/…`, since agy runs it through `cmd /c` where a bare `bash` is WSL's), or pipe your own status line into it | | Fallback | `usage_fallback: "agy"` lets Build/QA run on `super-board-agy-lane.sh` without `--agy` while Claude is paused; Review never | | Precondition | `agy` on PATH (`agy --version`), signed in (an interactive `agy` once; headless runs use the cached sign-in and log harmless `not logged into Antigravity` cache warnings at start-up), `bash -n` on both agy launcher scripts | | Windows | lanes run their commands in PowerShell 5.1 (`python`, no `&&`), hooks under `cmd /c`; the brief says so |