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..1c841d86 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" 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 '^