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/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/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..a49a9da9 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 '?')" @@ -226,7 +241,7 @@ for skill in $PRIMARY_SKILLS $SECONDARY_SKILLS; do install_skill "$skill" "$SHARED/skills/$skill" || true done -SCRIPTS="super-board-project-verifier.py super-board-skills-ready.py super-board-calibration.py super-board-verification-ready.py super-board-github-read.py super-board-collect-state.py super-board-run.sh super-board-gh-guard.sh super-board-card.sh super-board-status.py super-board-wave-plan.sh super-board-codex-lane.sh super-board-codex-wave.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-throttle.sh super-board-host.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh" +SCRIPTS="super-board-project-verifier.py super-board-skills-ready.py super-board-calibration.py super-board-verification-ready.py super-board-github-read.py super-board-collect-state.py super-board-run.sh super-board-gh-guard.sh super-board-card.sh super-board-status.py super-board-wave-plan.sh super-board-codex-lane.sh super-board-codex-wave.sh super-board-deps.sh super-board-preflight.sh super-board-merge-gate.sh super-board-merge-policy.py super-board-approval.py super-board-env-check.sh super-board-agents-md.py super-board-settings.py super-board-setup.py super-board-usage.sh super-board-throttle.sh super-board-host.sh super-board-pr-body.sh super-review-file-refactor.sh super-qa-file-bug.sh super-board-stop.sh super-board-agy-permissions.py super-board-agy-lane.sh super-board-agy-wave.sh" for script in $SCRIPTS; do if [ -f "$REPO_ROOT/scripts/$script" ]; then copy_file "$REPO_ROOT/scripts/$script" "$SHARED/bin/$script" @@ -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'] +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') + 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] + 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: + for scratch in (brief, card_file): + try: + scratch.unlink() + except OSError: + pass +PY diff --git a/scripts/super-board-agy-permissions.py b/scripts/super-board-agy-permissions.py new file mode 100644 index 00000000..a3a72cff --- /dev/null +++ b/scripts/super-board-agy-permissions.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Write the Antigravity permission rules a headless super-board lane needs. + +Headless `agy -p` cannot ask: a shell command or a file access that no rule in +~/.gemini/antigravity-cli/settings.json -> permissions.allow covers is auto-denied (the run +still exits 0, with `denied_actions` in its JSON and a notice on stderr). Without rules a +lane "succeeds" having run nothing. Rules are `action(target)`: `command(git status)`, +`command(regex:...)`, `read_file(<path>)`, `write_file(<path>)`; deny beats ask beats allow. + +What a lane needs, measured on agy 1.4.6 (2026-10-23, Windows 11, nine waves on one card): +a per-program allowlist does not hold. On Windows the rules match the whole command +exactly (`command(python)` does not allow `python --version`), and once written as regexes +the lane still improvised a `;`-chain, a multi-line `python -c`, a PowerShell here-string, +then a write to a `mktemp` file outside the workspace, each denied in turn. What worked: + + allow command(*) read_file(*) write_file(*) + deny the dangerous forms (force pushes, repo deletion, `gh auth`, `rm -rf /`, ...) + and the secret paths (.env, ~/.ssh, ~/.gemini, ~/.gitconfig*) + +with the guard hooks in .agents/hooks.json as the real safety net: they run BEFORE the +permission check, fail closed, and already refuse secret reads, deletions outside the +project, worktrees outside .claude/worktrees/ and key literals. `--dangerously-skip-permissions` +is not the same thing: it also skips the ask/deny evaluation the deny rules need. + +That is the default profile. `--from-allowlist` instead converts the board's Claude allowlist +(<repo>/.claude/settings.json -> permissions.allow, written by onboard) rule by rule into +`command(regex:(?s)<prefix>(\\s.*)?)`, which matches on every OS: tighter, and a lane will +sooner or later hit a form it does not cover. `--add RULE` appends any rule as written +(platform tools: `command(./gradlew)`). Default is a dry run; `--apply` merges into the +settings file (other keys untouched, no duplicates) after copying it to <file>.bak-<ts>. +An invalid settings file is left untouched (exit 2). agy 1.4.6 itself rewrites the file +with a UTF-8 BOM (seen one minute into a lane) and then loads `permissions=<nil>` from it, +so every tool call is denied: this script reads the file BOM-tolerant and `--apply` +rewrites it without one, even when it has no new rule to add. + +Usage: + super-board-agy-permissions.py [--repo .] [--settings ~/.gemini/antigravity-cli/settings.json] + [--from-allowlist] [--add RULE ...] [--apply] [--verbose] +Exit: 0 ok ยท 2 settings file is not valid JSON ยท 64 usage. +""" +import argparse +import json +import re +import shutil +import sys +import time +from pathlib import Path + +BASH_RULE = re.compile(r"^Bash\((?P<body>.+)\)$") +LANE_ALLOW = ["command(*)", "read_file(*)", "write_file(*)"] +# Regex rules are matched against the whole command; (?s) lets them span a multi-line command. +DENY_COMMANDS = [ + r"command(regex:(?s).*\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+/\s*$.*)", + r"command(regex:(?s).*\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+/(\s|$).*)", + r"command(regex:(?s).*\bgit\s+push\b.*(\s--force\b|\s-f\b).*)", + r"command(regex:(?s).*\bgit\s+reset\s+--hard\s+origin.*)", + r"command(regex:(?s).*\bgh\s+repo\s+delete\b.*)", + r"command(regex:(?s).*\bgh\s+auth\b.*)", + r"command(regex:(?s).*--dangerously-skip-permissions.*)", +] +SECRET_PATHS = [".env", ".ssh", ".gemini", ".gitconfig", ".gitconfig-personal", ".aws", ".config/gh"] + + +def deny_paths(home): + rules = ["read_file(.env)", "write_file(.env)"] + for name in SECRET_PATHS: + for action in ("read_file", "write_file"): + rule = f"{action}({home / name})" + if rule not in rules: + rules.append(rule) + return rules + + +def convert(rule): + """`Bash(gh pr view:*)` -> `command(regex:(?s)gh pr view(\\s.*)?)`; None for rules with no twin.""" + match = BASH_RULE.match(rule.strip()) + if not match: + return None + body = re.sub(r"(:\*|\s\*|\*)$", "", match.group("body").strip()).strip() + return f"command(regex:(?s){re.escape(body)}(\\s.*)?)" if body else None + + +def main(): + parser = argparse.ArgumentParser(description="Write the Antigravity permission rules a headless lane needs.") + parser.add_argument("--repo", default=".") + parser.add_argument("--settings", default=str(Path.home() / ".gemini/antigravity-cli/settings.json")) + parser.add_argument("--from-allowlist", action="store_true", + help="convert the Claude allowlist to regex command rules instead of command(*)") + parser.add_argument("--add", action="append", default=[], help="extra rule as written, e.g. command(./gradlew)") + parser.add_argument("--apply", action="store_true") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + wanted_allow, skipped = [], [] + if args.from_allowlist: + claude = Path(args.repo) / ".claude/settings.json" + try: + allow = (json.loads(claude.read_text(encoding="utf-8-sig")).get("permissions") or {}).get("allow") or [] if claude.exists() else [] + except ValueError: + print(f"โœ‹ {claude} is not valid JSON; fix it first", file=sys.stderr) + return 2 + for rule in allow: + converted = convert(rule) if isinstance(rule, str) else None + (wanted_allow if converted else skipped).append(converted or rule) + wanted_allow += ["read_file(*)", "write_file(*)"] + else: + wanted_allow += LANE_ALLOW + for rule in args.add: + if not re.match(r"^[a-z_]+\(.+\)$", rule): + print(f"--add takes an Antigravity rule like command(./gradlew), got {rule!r}", file=sys.stderr) + return 64 + wanted_allow.append(rule) + wanted_deny = DENY_COMMANDS + deny_paths(Path.home()) + + target = Path(args.settings).expanduser() + bom = target.exists() and target.read_bytes().startswith(b"\xef\xbb\xbf") + try: + current = json.loads(target.read_text(encoding="utf-8-sig")) if target.exists() else {} + if not isinstance(current, dict): + raise ValueError + except ValueError: + print(f"โœ‹ {target} is not valid JSON; left untouched", file=sys.stderr) + return 2 + permissions = current.get("permissions") if isinstance(current.get("permissions"), dict) else {} + existing_allow = list(permissions.get("allow") or []) + existing_deny = list(permissions.get("deny") or []) + new_allow = [r for i, r in enumerate(wanted_allow) if r not in existing_allow and r not in wanted_allow[:i]] + new_deny = [r for i, r in enumerate(wanted_deny) if r not in existing_deny and r not in wanted_deny[:i]] + + for rule in new_allow: + print(f"+ allow {rule}") + for rule in new_deny: + print(f"+ deny {rule}") + if args.verbose: + for rule in skipped: + print(f" skipped (no Antigravity twin): {rule}") + if bom: + print(f"! {target} starts with a UTF-8 BOM: agy loads no permissions from it until it is rewritten") + if not new_allow and not new_deny and not (bom and args.apply): + print(f"โœ“ {target}: every board rule already present") + return 0 + if not args.apply: + print(f"dry run: {len(new_allow)} allow and {len(new_deny)} deny rule(s) for {target} โ€” re-run with --apply to write them") + return 0 + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + shutil.copy2(target, target.with_name(target.name + time.strftime(".bak-%Y%m%d%H%M%S"))) + current["permissions"] = dict(permissions, allow=existing_allow + new_allow, deny=existing_deny + new_deny) + temporary = target.with_name(target.name + ".tmp") + temporary.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8") + temporary.replace(target) + print(f"โœ“ {len(new_allow)} allow and {len(new_deny)} deny rule(s) added to {target}" + (" (BOM stripped)" if bom else "")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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-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 <file>), 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 <file>), else exit 65. A live env sign beats the +# saved config. # # Usage: super-board-host.sh [--config <config.json>] -# 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/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 e4bd4bbb..3f9aa225 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 <this file>` 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. @@ -68,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 @@ -279,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/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 โ† <slug> โ”‚ โ”œโ”€ .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=<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) 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/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-hook-adapter.sh b/tests/test-agy-hook-adapter.sh new file mode 100644 index 00000000..e71cb1f7 --- /dev/null +++ b/tests/test-agy-hook-adapter.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Tests hooks/agy-hook-adapter.py: Antigravity PreToolUse payloads in (the shape agy 1.4.6 +# sends: toolCall.name, toolCall.args.CommandLine, workspacePaths), the existing Claude guards +# run, an Antigravity decision out. No agy binary needed. +# Fake keys and dotenv names are assembled at runtime so this file holds no live-looking literal. +set -euo pipefail +cd "$(dirname "$0")" +A="../hooks/agy-hook-adapter.py" +ROOT=$(cd .. && (pwd -W 2>/dev/null || pwd)) +PASS=0 +fail() { echo "FAIL: $1" >&2; exit 1; } + +call() { # $1 tool name, $2 args JSON, $3.. adapter flags โ†’ prints the decision JSON + local tool="$1" args="$2"; shift 2 + jq -cn --arg n "$tool" --argjson a "$args" --arg r "$ROOT" \ + '{toolCall:{name:$n, args:$a}, workspacePaths:[$r], conversationId:"t", stepIdx:1}' \ + | python3 "$A" --event PreToolUse "$@" +} +expect() { # $1 decision, $2 label, $3 output + printf '%s' "$3" | jq -e --arg d "$1" '.decision == $d' >/dev/null || fail "$2: expected $1, got '$3'" + PASS=$((PASS + 1)) +} +cmd() { jq -cn --arg c "$1" '{CommandLine:$c}'; } + +D=.env +ANT="sk-""ant-$(printf 'a%.0s' $(seq 1 30))" + +# Shell commands: every Bash guard runs; no objection keeps agy's own permission flow ("ask"). +expect deny "dotenv read via run_command" "$(call run_command "$(cmd "cat $D")")" +expect ask "ordinary command passes as ask" "$(call run_command "$(cmd "git status")")" +expect deny "worktree outside .claude/worktrees" "$(call run_command "$(cmd "git worktree add ../elsewhere")")" +expect deny "rm outside the project" "$(call run_command "$(cmd "rm -rf /")")" +expect ask "push guard is opt-in" "$(call run_command "$(cmd "git push origin HEAD:main")")" +expect deny "push guard with --protect-push" "$(call run_command "$(cmd "git push origin HEAD:main")" --protect-push)" +expect deny "lower-case command key" "$(call run_command "$(jq -cn --arg c "cat $D" '{command:$c}')")" +expect deny "args as a JSON string" "$(jq -cn --arg c "cat $D" --arg r "$ROOT" '{toolCall:{name:"run_command", args:({CommandLine:$c}|tojson)}, workspacePaths:[$r]}' | python3 "$A" --event PreToolUse)" + +# Files: secrets on reads, key literals on writes; workspace files pass as allow. +expect deny "view_file on a dotenv" "$(call view_file "$(jq -cn --arg p "$ROOT/$D.local" '{AbsolutePath:$p}')")" +expect allow "view_file in the workspace" "$(call view_file "$(jq -cn --arg p "$ROOT/README.md" '{AbsolutePath:$p}')")" +expect ask "view_file outside the workspace" "$(call view_file '{"AbsolutePath":"/definitely/elsewhere/file.txt"}')" +expect deny "write_to_file with a live key" "$(call write_to_file "$(jq -cn --arg p "$ROOT/x.ts" --arg k "const k = '$ANT'" '{TargetFile:$p, CodeContent:$k}')")" +expect deny "replace chunks with a live key" "$(call replace_file_content "$(jq -cn --arg p "$ROOT/x.ts" --arg k "$ANT" '{TargetFile:$p, ReplacementChunks:[{TargetContent:"a", ReplacementContent:("x = \"" + $k + "\"")}]}')")" +expect allow "ordinary write" "$(call write_to_file "$(jq -cn --arg p "$ROOT/x.ts" '{TargetFile:$p, CodeContent:"export const x = 1"}')")" +expect allow "relative path resolves in the workspace" "$(call write_to_file '{"TargetFile":"src/x.ts","CodeContent":"export const x = 1"}')" + +# Overrides for the pass decision, and fail-open on garbage. +OUT=$(SB_AGY_HOOK_PASS_COMMAND=force_ask call run_command "$(cmd "ls")") +expect force_ask "command pass override" "$OUT" +OUT=$(echo 'not json' | python3 "$A" --event PreToolUse) +expect ask "garbage input falls back to the pass decision" "$OUT" +OUT=$(echo '{}' | python3 "$A" --event PostToolUse) +[ "$OUT" = "{}" ] || fail "PostToolUse must answer {}, got '$OUT'"; PASS=$((PASS + 1)) +expect ask "unknown tool passes" "$(call some_unrelated_tool '{}')" + +# The shipped hooks.json is valid, points at the adapter relative to .agents/ (the hook's cwd) +# and matches the tool names agy uses. +jq -e '."super-board-guards".PreToolUse[0].hooks[0].command == "python3 hooks/agy-hook-adapter.py --event PreToolUse"' ../hooks/agy-hooks.json >/dev/null \ + || fail "hooks/agy-hooks.json must run hooks/agy-hook-adapter.py relative to .agents/"; PASS=$((PASS + 1)) +M=$(jq -r '."super-board-guards".PreToolUse[0].matcher' ../hooks/agy-hooks.json) +for t in run_command view_file write_to_file replace_file_content grep_search find_by_name; do + echo "$t" | grep -Eq "^($M)$" || fail "matcher must cover $t" +done; PASS=$((PASS + 1)) + +echo "PASS: test-agy-hook-adapter.sh ($PASS checks)" diff --git a/tests/test-agy-lane.sh b/tests/test-agy-lane.sh new file mode 100644 index 00000000..7a129b53 --- /dev/null +++ b/tests/test-agy-lane.sh @@ -0,0 +1,217 @@ +#!/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' +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" +[ "$(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-permissions.sh b/tests/test-agy-permissions.sh new file mode 100644 index 00000000..d457b287 --- /dev/null +++ b/tests/test-agy-permissions.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Tests scripts/super-board-agy-permissions.py: the rules a headless Antigravity lane needs, +# written once into the agy settings file without touching anything else. No agy binary needed. +set -euo pipefail +cd "$(dirname "$0")" +P="../scripts/super-board-agy-permissions.py" +PASS=0 +fail() { echo "FAIL: $1" >&2; exit 1; } +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +S="$T/settings.json" +mkdir -p "$T/repo/.claude" + +# 1 โ€” dry run on a missing file: the lane profile (allow *) plus the deny rules, nothing written. +OUT=$(python3 "$P" --repo "$T/repo" --settings "$S") +echo "$OUT" | grep -q '^+ allow command(\*)$' || fail "dry run must offer command(*): $OUT" +echo "$OUT" | grep -q '^+ allow write_file(\*)$' || fail "dry run must offer write_file(*)" +echo "$OUT" | grep -q '^+ deny command(regex:.*gh.*auth' || fail "dry run must deny gh auth" +echo "$OUT" | grep -q '^+ deny read_file(.env)$' || fail "dry run must deny reading .env" +echo "$OUT" | grep -q '^dry run:' || fail "dry run must say so" +[ ! -e "$S" ] || fail "dry run must write nothing" +PASS=$((PASS + 1)) + +# 2 โ€” apply keeps the other keys, writes allow and deny once, and backs the file up. +printf '{"colorScheme":"dark","permissions":{"allow":["command(git status)"]},"trustedWorkspaces":["/x"]}\n' > "$S" +python3 "$P" --repo "$T/repo" --settings "$S" --apply | grep -q 'โœ“ .* added to' || fail "apply must report what it added" +jq -e '.colorScheme == "dark" and .trustedWorkspaces == ["/x"]' "$S" >/dev/null || fail "apply must keep the other keys" +jq -e '.permissions.allow == ["command(git status)", "command(*)", "read_file(*)", "write_file(*)"]' "$S" >/dev/null || fail "allow list wrong: $(cat "$S")" +jq -e '(.permissions.deny | length) > 8 and (.permissions.deny | index("read_file(.env)")) != null' "$S" >/dev/null || fail "deny list missing: $(cat "$S")" +ls "$T"/settings.json.bak-* >/dev/null 2>&1 || fail "apply must back the file up" +PASS=$((PASS + 1)) + +# 3 โ€” a second apply adds nothing and says so. +OUT=$(python3 "$P" --repo "$T/repo" --settings "$S" --apply) +echo "$OUT" | grep -q 'every board rule already present' || fail "second apply must be a no-op: $OUT" +[ "$(jq '.permissions.allow | length' "$S")" = 4 ] || fail "second apply duplicated rules" +PASS=$((PASS + 1)) + +# 4 โ€” --from-allowlist converts the Claude rules to regex command rules that match on every OS. +printf '{"permissions":{"allow":["Bash(gh pr view:*)","Bash(npm test)","Read(**)","Bash(git worktree:*)"]}}\n' > "$T/repo/.claude/settings.json" +OUT=$(python3 "$P" --repo "$T/repo" --settings "$T/none.json" --from-allowlist --verbose) +echo "$OUT" | grep -qF '+ allow command(regex:(?s)gh\ pr\ view(\s.*)?)' || fail "Bash prefix rule must become a regex rule: $OUT" +echo "$OUT" | grep -qF '+ allow command(regex:(?s)npm\ test(\s.*)?)' || fail "exact Bash rule must become a regex rule: $OUT" +echo "$OUT" | grep -q 'skipped (no Antigravity twin): Read(\*\*)' || fail "--verbose must list the skipped rule: $OUT" +echo "$OUT" | grep -q '^+ allow command(\*)$' && fail "--from-allowlist must not add command(*)" +echo "$OUT" | grep -q '^+ allow write_file(\*)$' || fail "--from-allowlist still allows workspace-adjacent file writes" +PASS=$((PASS + 1)) + +# 5 โ€” --add appends a rule as written; a malformed one is refused with 64. +python3 "$P" --repo "$T/repo" --settings "$S" --add 'command(./gradlew)' --apply >/dev/null +jq -e '.permissions.allow | index("command(./gradlew)") != null' "$S" >/dev/null || fail "--add rule missing" +rc=0; python3 "$P" --repo "$T/repo" --settings "$S" --add 'gradlew' >/dev/null 2>&1 || rc=$? +[ "$rc" = 64 ] || fail "malformed --add must exit 64, got $rc" +PASS=$((PASS + 1)) + +# 6 โ€” an invalid settings file is left untouched, exit 2. +printf '{not json' > "$T/bad.json" +rc=0; python3 "$P" --repo "$T/repo" --settings "$T/bad.json" --apply >/dev/null 2>&1 || rc=$? +[ "$rc" = 2 ] || fail "invalid settings must exit 2, got $rc" +[ "$(cat "$T/bad.json")" = '{not json' ] || fail "invalid settings must be left untouched" +PASS=$((PASS + 1)) + +# 7 โ€” a settings file agy rewrote with a UTF-8 BOM is read all the same and rewritten clean by --apply. +printf '\xef\xbb\xbf{"permissions":{"allow":["command(*)"]}}\n' > "$T/bom.json" +OUT=$(python3 "$P" --repo "$T/repo" --settings "$T/bom.json") +echo "$OUT" | grep -q 'starts with a UTF-8 BOM' || fail "dry run must warn about the BOM: $OUT" +python3 "$P" --repo "$T/repo" --settings "$T/bom.json" --apply | grep -q 'BOM stripped' || fail "apply must say it stripped the BOM" +[ "$(head -c 1 "$T/bom.json")" = "{" ] || fail "apply must rewrite the file without a BOM" +jq -e '.permissions.allow | index("command(*)") != null' "$T/bom.json" >/dev/null || fail "the rules must survive the rewrite" +PASS=$((PASS + 1)) + +echo "PASS: test-agy-permissions.sh ($PASS scenarios)" 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-install.sh b/tests/test-install.sh index 3b91e452..db0008e0 100755 --- a/tests/test-install.sh +++ b/tests/test-install.sh @@ -414,4 +414,29 @@ doc = re.search(r"```json\n(.*?)```", open(sys.argv[1]).read(), re.S).group(1) sys.exit(0 if json.loads(doc) == json.load(open(sys.argv[2])) else 1) PY -echo "PASS: test-install.sh (24 scenarios)" +# 25 โ€” --hosts agy: Antigravity reads .agents/ itself, so no .agy/ folder of links. The guards +# reach agy lanes through .agents/hooks.json: a super-board-guards entry that runs the +# adapter relative to .agents/, other entries in that file kept, --protect-main adds the +# push guard and a later install without the flag keeps it, the host is detected again +# from the entry, the permissions step is named, and a plain install writes no hooks.json. +T=$(mktemp -d); mkdir -p "$T/.agents" +echo '{"my-hook":{"Stop":[{"type":"command","command":"true"}]}}' > "$T/.agents/hooks.json" +OUT=$("$INSTALL" --hosts agy --protect-main "$T" 2>&1) +[ ! -e "$T/.agy" ] || fail "--hosts agy must not create .agy/" +[ -f "$T/.agents/hooks/agy-hook-adapter.py" ] || fail "--hosts agy: the adapter was not installed next to the guards" +jq -e '."my-hook".Stop and (."super-board-guards".PreToolUse[0].hooks[0].command | test("^python3? hooks/agy-hook-adapter.py --event PreToolUse --protect-push$"))' \ + "$T/.agents/hooks.json" >/dev/null || fail "--hosts agy hooks.json wrong: $(cat "$T/.agents/hooks.json")" +echo "$OUT" | grep -q "Antigravity guards โ†’ .agents/hooks.json" || fail "--hosts agy must report the hooks line: $OUT" +echo "$OUT" | grep -q "super-board-agy-permissions.py --apply" || fail "--hosts agy must name the permissions step" +echo "$OUT" | grep -q " 6 guard hooks" || fail "the adapter must not count as a guard: $OUT" +"$INSTALL" "$T" >/dev/null 2>&1 || fail "re-install on an agy project failed" +jq -e '."super-board-guards".PreToolUse[0].hooks[0].command | contains("--protect-push")' "$T/.agents/hooks.json" >/dev/null \ + || fail "a re-install without --protect-main dropped the Antigravity push guard" +T2=$(mktemp -d); "$INSTALL" "$T2" >/dev/null 2>&1 +[ ! -e "$T2/.agents/hooks.json" ] || fail "a plain install must not write .agents/hooks.json" +T3=$(mktemp -d); mkdir -p "$T3/.claude/super-board/configs" +echo '{"host": "agy"}' > "$T3/.claude/super-board/configs/app.json" +"$INSTALL" "$T3" >/dev/null 2>&1; [ -f "$T3/.agents/hooks.json" ] || fail "a config host agy must wire the hooks" +rm -rf "$T" "$T2" "$T3" + +echo "PASS: test-install.sh (25 scenarios)" 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" 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()