diff --git a/README.md b/README.md index f5b41ae..5754aa7 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,8 @@ ID=$(scripts/agy-job.sh start --tier pro --dir . "big task"); scripts/agy-job.sh | tier | model | use for | |------|-------|---------| -| `flash` (default) | Gemini 3.5 Flash (High) | most bulk work | -| `flash-lo` | Gemini 3.5 Flash (Low) | cheapest, trivial tasks | +| `flash` (default) | Gemini 3.6 Flash (High) | most bulk work | +| `flash-lo` | Gemini 3.6 Flash (Low) | cheapest, trivial tasks | | `pro` | Gemini 3.1 Pro (High) | harder reasoning / cross-checks | **agy is multi-model.** Tiers default to Gemini, but you can use any model `agy models` lists diff --git a/agents/antigravity-delegate.md b/agents/antigravity-delegate.md index 23bbab9..51a4de3 100644 --- a/agents/antigravity-delegate.md +++ b/agents/antigravity-delegate.md @@ -40,7 +40,7 @@ hooks: - matcher: Bash hooks: - type: command - command: "\"${CLAUDE_PLUGIN_ROOT}/hooks/validate-delegate-bash.sh\"" + command: "node \"${CLAUDE_PLUGIN_ROOT}/hooks/validate-delegate-bash.js\"" model: inherit color: blue --- diff --git a/bin/agy-cost-compare.cmd b/bin/agy-cost-compare.cmd new file mode 100644 index 0000000..ce16d2c --- /dev/null +++ b/bin/agy-cost-compare.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +set "TARGET_SCRIPT=%SCRIPT_DIR%..\scripts\agy-cost-compare.sh" + +where bash >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + bash "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +where sh >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + sh "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +if exist "%ProgramFiles%\Git\bin\bash.exe" ( + "%ProgramFiles%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%ProgramFiles(x86)%\Git\bin\bash.exe" ( + "%ProgramFiles(x86)%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%LocalAppData%\Programs\Git\bin\bash.exe" ( + "%LocalAppData%\Programs\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +echo error: bash or sh is required to run this command, but neither was found on PATH or in standard Git installation paths. >&2 +exit /b 1 diff --git a/bin/agy-delegate.cmd b/bin/agy-delegate.cmd new file mode 100644 index 0000000..00de5f1 --- /dev/null +++ b/bin/agy-delegate.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +set "TARGET_SCRIPT=%SCRIPT_DIR%..\scripts\agy-delegate.sh" + +where bash >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + bash "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +where sh >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + sh "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +if exist "%ProgramFiles%\Git\bin\bash.exe" ( + "%ProgramFiles%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%ProgramFiles(x86)%\Git\bin\bash.exe" ( + "%ProgramFiles(x86)%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%LocalAppData%\Programs\Git\bin\bash.exe" ( + "%LocalAppData%\Programs\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +echo error: bash or sh is required to run this command, but neither was found on PATH or in standard Git installation paths. >&2 +exit /b 1 diff --git a/bin/agy-doctor.cmd b/bin/agy-doctor.cmd new file mode 100644 index 0000000..657138b --- /dev/null +++ b/bin/agy-doctor.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +set "TARGET_SCRIPT=%SCRIPT_DIR%..\scripts\doctor.sh" + +where bash >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + bash "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +where sh >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + sh "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +if exist "%ProgramFiles%\Git\bin\bash.exe" ( + "%ProgramFiles%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%ProgramFiles(x86)%\Git\bin\bash.exe" ( + "%ProgramFiles(x86)%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%LocalAppData%\Programs\Git\bin\bash.exe" ( + "%LocalAppData%\Programs\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +echo error: bash or sh is required to run this command, but neither was found on PATH or in standard Git installation paths. >&2 +exit /b 1 diff --git a/bin/agy-job.cmd b/bin/agy-job.cmd new file mode 100644 index 0000000..29e26f5 --- /dev/null +++ b/bin/agy-job.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +set "TARGET_SCRIPT=%SCRIPT_DIR%..\scripts\agy-job.sh" + +where bash >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + bash "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +where sh >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + sh "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +if exist "%ProgramFiles%\Git\bin\bash.exe" ( + "%ProgramFiles%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%ProgramFiles(x86)%\Git\bin\bash.exe" ( + "%ProgramFiles(x86)%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%LocalAppData%\Programs\Git\bin\bash.exe" ( + "%LocalAppData%\Programs\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +echo error: bash or sh is required to run this command, but neither was found on PATH or in standard Git installation paths. >&2 +exit /b 1 diff --git a/bin/agy-trace.cmd b/bin/agy-trace.cmd new file mode 100644 index 0000000..84ab59a --- /dev/null +++ b/bin/agy-trace.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +set "TARGET_SCRIPT=%SCRIPT_DIR%..\scripts\agy-trace.sh" + +where bash >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + bash "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +where sh >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + sh "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +if exist "%ProgramFiles%\Git\bin\bash.exe" ( + "%ProgramFiles%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%ProgramFiles(x86)%\Git\bin\bash.exe" ( + "%ProgramFiles(x86)%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%LocalAppData%\Programs\Git\bin\bash.exe" ( + "%LocalAppData%\Programs\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +echo error: bash or sh is required to run this command, but neither was found on PATH or in standard Git installation paths. >&2 +exit /b 1 diff --git a/bin/cloud-debug.cmd b/bin/cloud-debug.cmd new file mode 100644 index 0000000..d25a562 --- /dev/null +++ b/bin/cloud-debug.cmd @@ -0,0 +1,32 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +set "TARGET_SCRIPT=%SCRIPT_DIR%..\scripts\cloud-debug.sh" + +where bash >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + bash "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +where sh >nul 2>&1 +if %ERRORLEVEL% equ 0 ( + sh "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +if exist "%ProgramFiles%\Git\bin\bash.exe" ( + "%ProgramFiles%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%ProgramFiles(x86)%\Git\bin\bash.exe" ( + "%ProgramFiles(x86)%\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) +if exist "%LocalAppData%\Programs\Git\bin\bash.exe" ( + "%LocalAppData%\Programs\Git\bin\bash.exe" "%TARGET_SCRIPT%" %* + exit /b %ERRORLEVEL% +) + +echo error: bash or sh is required to run this command, but neither was found on PATH or in standard Git installation paths. >&2 +exit /b 1 diff --git a/hooks/check-agy.js b/hooks/check-agy.js new file mode 100644 index 0000000..0f3eb26 --- /dev/null +++ b/hooks/check-agy.js @@ -0,0 +1,23 @@ +const { execSync } = require('child_process'); + +let hasAgy = false; +try { + const checkCmd = process.platform === 'win32' ? 'where agy' : 'which agy'; + execSync(checkCmd, { stdio: 'ignore' }); + hasAgy = true; +} catch (e) { + hasAgy = false; +} + +if (!hasAgy) { + console.error("[antigravity] agy not on PATH — install the Antigravity CLI to enable delegation:"); + console.error("[antigravity] https://antigravity.google/docs/cli-using"); + process.exit(0); +} + +try { + execSync('agy --version', { shell: true, stdio: 'ignore', timeout: 5000 }); +} catch (e) { + console.error("[antigravity] agy is on PATH but '--version' failed — it may need authentication (run `agy` once)."); +} +process.exit(0); diff --git a/hooks/inject-policy.js b/hooks/inject-policy.js new file mode 100644 index 0000000..5acfc18 --- /dev/null +++ b/hooks/inject-policy.js @@ -0,0 +1,17 @@ +const fs = require('fs'); +const path = require('path'); + +const codingPolicy = (process.env.CLAUDE_PLUGIN_OPTION_CODING_POLICY || 'on').toLowerCase().trim(); +if (['off', 'false', '0', 'no', 'disabled'].includes(codingPolicy)) { + process.exit(0); +} + +try { + const policyPath = path.join(__dirname, 'policy-context.json'); + const policy = fs.readFileSync(policyPath, 'utf8'); + process.stdout.write(policy); +} catch (e) { + console.error("Error reading policy-context.json:", e); + process.exit(1); +} +process.exit(0); diff --git a/hooks/nudge-delegation.js b/hooks/nudge-delegation.js new file mode 100644 index 0000000..694fde8 --- /dev/null +++ b/hooks/nudge-delegation.js @@ -0,0 +1,54 @@ +const fs = require('fs'); + +const nudge = (process.env.CLAUDE_PLUGIN_OPTION_DELEGATION_NUDGE || 'on').toLowerCase().trim(); +if (['off', 'false', '0', 'no', 'disabled'].includes(nudge)) { + process.exit(0); +} + +let input = ''; +try { + input = fs.readFileSync(0, 'utf8'); +} catch (e) { + process.exit(0); +} + +if (!input) { + process.exit(0); +} + +let prompt = ''; +try { + const data = JSON.parse(input); + prompt = data.prompt || ''; +} catch (e) { + process.exit(0); +} + +if (!prompt) { + process.exit(0); +} + +if (/antigravity|agy-delegate|agy-job/i.test(prompt)) { + process.exit(0); +} + +const keywords = [ + "all files", "every file", "across the codebase", "entire codebase", "whole repo", + "migrate", "migration", "generate tests", "test coverage", "exhaustive test", + "scaffold", "boilerplate", "deep research", "web search", + "一括", "全ファイル", "すべてのファイル", "網羅", "移行", "大量", "横断", "リポジトリ全体" +]; + +const promptLower = prompt.toLowerCase(); +const hit = keywords.some(kw => promptLower.includes(kw.toLowerCase())); + +if (hit) { + const output = { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: "[antigravity plugin] This prompt looks like BULK work (mass edits / migration / exhaustive tests / fan-out search) — possibly above the delegation break-even. CONSIDER routing the bulk part to the antigravity-delegate subagent (or agy-delegate --digest) so it runs on the cheap executor, then verify its digest. THE JUDGMENT IS YOURS: if the task is actually small, self-contained, or judgement-heavy, do it yourself — delegating below the break-even is a measured net loss. Decide silently; don't mention this notice." + } + }; + console.log(JSON.stringify(output)); +} +process.exit(0); diff --git a/hooks/nudge-delegation.sh b/hooks/nudge-delegation.sh index 5bd0ade..184a6af 100755 --- a/hooks/nudge-delegation.sh +++ b/hooks/nudge-delegation.sh @@ -25,7 +25,11 @@ IN="$(cat 2>/dev/null || true)" # Extract ONLY the prompt field (matching on the whole payload would false-positive # on cwd/paths). python3 is already a plugin dependency (measure-session, agy-trace). -PROMPT="$(printf '%s' "$IN" | python3 -c 'import json,sys +py_cmd="python3" +if ! command -v python3 >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then + py_cmd="python" +fi +PROMPT="$(printf '%s' "$IN" | "$py_cmd" -c 'import json,sys try: print(json.load(sys.stdin).get("prompt","")) except Exception: pass' 2>/dev/null || true)" [ -n "$PROMPT" ] || exit 0 diff --git a/hooks/validate-delegate-bash.js b/hooks/validate-delegate-bash.js new file mode 100644 index 0000000..0ca405c --- /dev/null +++ b/hooks/validate-delegate-bash.js @@ -0,0 +1,23 @@ +const fs = require('fs'); + +let input = ''; +try { + input = fs.readFileSync(0, 'utf8'); +} catch (e) { + process.exit(2); +} + +let cmd = input; +try { + const data = JSON.parse(input); + cmd = data.tool_input?.command || ''; +} catch (e) { + // If parsing fails, fall back to matching on raw input string +} + +if (/agy-delegate|agy-job/.test(cmd)) { + process.exit(0); +} + +console.error("[antigravity-delegate] blocked: this subagent may only run agy-delegate / agy-job via Bash. Delegate file work to agy; verification is the caller's job."); +process.exit(2); diff --git a/hooks/validate-delegate-bash.sh b/hooks/validate-delegate-bash.sh index 33cbde8..a6f5ebb 100755 --- a/hooks/validate-delegate-bash.sh +++ b/hooks/validate-delegate-bash.sh @@ -14,10 +14,15 @@ set -uo pipefail input="$(cat)" -# Extract the command field. Prefer python3 (correct JSON parse); fall back to the -# raw payload so the gate still works if python3 is unavailable. -if command -v python3 >/dev/null 2>&1; then - cmd="$(printf '%s' "$input" | python3 -c \ +# Extract the command field. Prefer python3/python (correct JSON parse); fall back to the +# raw payload so the gate still works if python is unavailable. +py_cmd="python3" +if ! command -v python3 >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then + py_cmd="python" +fi + +if command -v "$py_cmd" >/dev/null 2>&1; then + cmd="$(printf '%s' "$input" | "$py_cmd" -c \ 'import json,sys try: print(json.load(sys.stdin).get("tool_input",{}).get("command","")) diff --git a/scripts/agy-cost-compare.sh b/scripts/agy-cost-compare.sh index 22840c3..77e68d8 100755 --- a/scripts/agy-cost-compare.sh +++ b/scripts/agy-cost-compare.sh @@ -42,8 +42,13 @@ PROMPT="${*:-}" # Default prices from prices.json (env vars still override); Gemini rate by tier. PRICES="$HERE/../prices.json" -if [ -f "$PRICES" ] && command -v python3 >/dev/null 2>&1; then - eval "$(python3 - "$PRICES" "$TIER" 2>/dev/null <<'PY' +PYTHON_CMD="" +if command -v python3 >/dev/null 2>&1; then PYTHON_CMD=python3; +elif command -v python >/dev/null 2>&1; then PYTHON_CMD=python; +fi + +if [ -f "$PRICES" ] && [ -n "$PYTHON_CMD" ]; then + eval "$("$PYTHON_CMD" - "$PRICES" "$TIER" 2>/dev/null <<'PY' import json,sys try: d=json.load(open(sys.argv[1])); t=sys.argv[2] diff --git a/scripts/agy-delegate.sh b/scripts/agy-delegate.sh index 6fae739..b4973c7 100755 --- a/scripts/agy-delegate.sh +++ b/scripts/agy-delegate.sh @@ -91,8 +91,8 @@ usage() { sed -n '/^# Usage:/,/^# Exit codes:/p' "$0" | sed 's/^# \{0,1\}//'; ex # (env), so non-Vertex/non-Gemini plans (Claude/GPT) work without code changes. model_for_tier() { case "$1" in - flash) echo "${CLAUDE_PLUGIN_OPTION_TIER_FLASH:-Gemini 3.5 Flash (High)}" ;; - flash-lo) echo "${CLAUDE_PLUGIN_OPTION_TIER_FLASH_LO:-Gemini 3.5 Flash (Low)}" ;; + flash) echo "${CLAUDE_PLUGIN_OPTION_TIER_FLASH:-Gemini 3.6 Flash (High)}" ;; + flash-lo) echo "${CLAUDE_PLUGIN_OPTION_TIER_FLASH_LO:-Gemini 3.6 Flash (Low)}" ;; pro) echo "${CLAUDE_PLUGIN_OPTION_TIER_PRO:-Gemini 3.1 Pro (High)}" ;; *) die "unknown tier '$1' (use flash | flash-lo | pro)" ;; esac diff --git a/scripts/agy-job.sh b/scripts/agy-job.sh index bacb4f5..b96b8d2 100755 --- a/scripts/agy-job.sh +++ b/scripts/agy-job.sh @@ -19,6 +19,12 @@ HERE="$(cd "$(dirname "$0")" && pwd)" DELEGATE="${AGY_DELEGATE:-$HERE/agy-delegate.sh}" REG="${ANTIGRAVITY_JOBS:-$HOME/.antigravity-jobs}" +on_windows_native() { + case "${OSTYPE:-}" in msys*|cygwin*|win32) return 0 ;; esac + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; esac + return 1 +} + die() { echo "agy-job: $*" >&2; exit 1; } # resolve a (possibly abbreviated) id to a job dir @@ -38,8 +44,21 @@ job_state() { if [ -f "$jd/rc" ]; then rc="$(cat "$jd/rc")" if [ "$rc" = "0" ]; then echo "done"; else echo "failed"; fi - elif [ -f "$jd/pid" ] && kill -0 "$(cat "$jd/pid")" 2>/dev/null; then - echo running + elif [ -f "$jd/pid" ]; then + local pid="$(cat "$jd/pid")" + local running=0 + if on_windows_native; then + if [ -n "$pid" ] && ! MSYS_NO_PATHCONV=1 tasklist.exe /NH /FI "PID eq $pid" 2>/dev/null | grep -q "No tasks are running"; then + running=1 + fi + else + if kill -0 "$pid" 2>/dev/null; then running=1; fi + fi + if [ "$running" = "1" ]; then + echo running + else + echo failed + fi else echo failed # pid gone, no rc recorded = crashed/killed fi @@ -70,9 +89,29 @@ case "$cmd" in jd="$REG/$id"; mkdir -p "$jd" { echo "id=$id"; echo "cwd=$PWD"; echo "started=$(date -u +%FT%TZ 2>/dev/null || date)"; echo "task=$(printf '%s' "${!#}" | tr '\n' ' ' | cut -c1-200)"; } > "$jd/meta" - ( nohup "$DELEGATE" "$@" >"$jd/out" 2>"$jd/err"; echo $? >"$jd/rc" ) >/dev/null 2>&1 & - echo $! > "$jd/pid" - disown 2>/dev/null || true + if on_windows_native; then + { + echo "#!/usr/bin/env bash" + for var in $(env | cut -d= -f1); do + case "$var" in + CLAUDE_*|STUB_*|ANTIGRAVITY_*|AGY_*) + printf 'export %s=%q\n' "$var" "${!var:-}" + ;; + esac + done + printf '"%s"' "$DELEGATE" + printf ' %q' "$@" + printf ' >"%s" 2>"%s"\n' "$jd/out" "$jd/err" + echo "echo \$? > \"$jd/rc\"" + } > "$jd/run.sh" + chmod +x "$jd/run.sh" + pid=$(powershell.exe -NoProfile -Command "(Start-Process -WindowStyle Hidden -FilePath 'bash' -ArgumentList '$jd/run.sh' -PassThru).Id" | tr -d '\r\n') + echo "$pid" > "$jd/pid" + else + ( nohup "$DELEGATE" "$@" >"$jd/out" 2>"$jd/err"; echo $? >"$jd/rc" ) >/dev/null 2>&1 & + echo $! > "$jd/pid" + disown 2>/dev/null || true + fi echo "$id" ;; list) @@ -109,9 +148,24 @@ case "$cmd" in cancel) jd="$(jobdir "${1:-}")" pid="$(cat "$jd/pid" 2>/dev/null || true)" - if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then - pkill -P "$pid" 2>/dev/null || true # children (agy) first - kill "$pid" 2>/dev/null || true + running=0 + if [ -n "$pid" ]; then + if on_windows_native; then + if ! MSYS_NO_PATHCONV=1 tasklist.exe /NH /FI "PID eq $pid" 2>/dev/null | grep -q "No tasks are running"; then + running=1 + fi + else + if kill -0 "$pid" 2>/dev/null; then running=1; fi + fi + fi + if [ "$running" = "1" ]; then + if on_windows_native; then + MSYS_NO_PATHCONV=1 /c/Windows/System32/taskkill.exe /F /T /PID "$pid" >/dev/null 2>&1 || MSYS_NO_PATHCONV=1 taskkill.exe /F /T /PID "$pid" >/dev/null 2>&1 || true + sleep 1 + else + pkill -P "$pid" 2>/dev/null || true # children (agy) first + kill "$pid" 2>/dev/null || true + fi echo "cancelled $(basename "$jd")" else echo "not running" diff --git a/scripts/agy-trace.sh b/scripts/agy-trace.sh index 4b8e899..51477e2 100755 --- a/scripts/agy-trace.sh +++ b/scripts/agy-trace.sh @@ -61,7 +61,11 @@ list_recent() { pretty() { # $1 = transcript path echo "# $1" - python3 - "$1" <<'PY' + local py="python3" + if ! command -v python3 >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then + py="python" + fi + "$py" - "$1" <<'PY' import json, sys path = sys.argv[1] with open(path, encoding="utf-8", errors="replace") as fh: diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 215dc1d..4db2506 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -76,8 +76,8 @@ if command -v agy >/dev/null 2>&1; then ok "agy authenticated — $(printf '%s' "$MODELS" | grep -c . ) models available" # 2b. configured tier->model names exist (respecting userConfig remaps). agy is # multi-model and plan-dependent, so a miss is a WARNING, not a failure. - FLASH="${CLAUDE_PLUGIN_OPTION_TIER_FLASH:-Gemini 3.5 Flash (High)}" - FLASH_LO="${CLAUDE_PLUGIN_OPTION_TIER_FLASH_LO:-Gemini 3.5 Flash (Low)}" + FLASH="${CLAUDE_PLUGIN_OPTION_TIER_FLASH:-Gemini 3.6 Flash (High)}" + FLASH_LO="${CLAUDE_PLUGIN_OPTION_TIER_FLASH_LO:-Gemini 3.6 Flash (Low)}" PRO="${CLAUDE_PLUGIN_OPTION_TIER_PRO:-Gemini 3.1 Pro (High)}" for m in "$FLASH" "$FLASH_LO" "$PRO"; do if printf '%s' "$MODELS" | grep -qF "$m"; then diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 8d5d8c1..ef5a53e 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -11,6 +11,17 @@ ROOT="$(cd "$HERE/.." && pwd)" DELEGATE="$ROOT/scripts/agy-delegate.sh" MEASURE="$ROOT/scripts/measure-session.py" +PYTHON="python3" +if ! command -v python3 >/dev/null 2>&1 && command -v python >/dev/null 2>&1; then + PYTHON="python" +fi + +on_windows_native() { + case "${OSTYPE:-}" in msys*|cygwin*|win32) return 0 ;; esac + case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; esac + return 1 +} + TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT PASS=0; FAIL=0 @@ -59,9 +70,25 @@ export PATH="$TMP/bin:$PATH" # "missing on PATH" tests stay deterministic on runners that ship gcloud in # /usr/bin (GitHub-hosted ubuntu does — so PATH=/usr/bin:/bin would still find it). mkdir -p "$TMP/min" -for u in bash sh env dirname basename pwd sed cat mktemp grep tr cut find wc head tail sort uniq sleep python3 rm chmod; do - s="$(command -v "$u" 2>/dev/null)" && ln -sf "$s" "$TMP/min/$u" +for u in bash sh env dirname basename pwd sed cat mktemp grep tr cut find wc head tail sort uniq sleep rm chmod; do + s="$(type -p "$u" 2>/dev/null || which "$u" 2>/dev/null || command -v "$u" 2>/dev/null)" + if [ -n "$s" ] && [ -f "$s" ]; then + ln -sf "$s" "$TMP/min/$u" + fi done +s_py="" +if command -v python3 >/dev/null 2>&1; then s_py="$(type -p python3 2>/dev/null || which python3 2>/dev/null || command -v python3)"; +elif command -v python >/dev/null 2>&1; then s_py="$(type -p python 2>/dev/null || which python 2>/dev/null || command -v python)"; +fi +if [ -n "$s_py" ] && [ -f "$s_py" ]; then + ln -sf "$s_py" "$TMP/min/python3" + ln -sf "$s_py" "$TMP/min/python" +fi + +MIN_PATH="$TMP/min" +if on_windows_native; then + MIN_PATH="$TMP/min:/usr/bin:/bin" +fi check() { # desc expected_rc actual_rc [substr] [actual_out] local desc="$1" erc="$2" arc="$3" sub="${4:-}" out="${5:-}" @@ -92,7 +119,7 @@ out=$("$DELEGATE" --tier 2>/dev/null); rc=$? check "option without value -> exit 1 (friendly)" 1 "$rc" out=$(STUB_MODE=args "$DELEGATE" --tier flash "hi" 2>/dev/null); rc=$? -check "flash tier -> correct model string" 0 "$rc" "Gemini 3.5 Flash (High)" "$out" +check "flash tier -> correct model string" 0 "$rc" "Gemini 3.6 Flash (High)" "$out" out=$(STUB_MODE=args "$DELEGATE" --tier pro "hi" 2>/dev/null); rc=$? check "pro tier -> correct model string" 0 "$rc" "Gemini 3.1 Pro (High)" "$out" @@ -133,13 +160,13 @@ out=$(STUB_MODE=args CLAUDE_PLUGIN_OPTION_DEFAULT_TIER=pro "$DELEGATE" "hi" 2>/d check "userConfig default_tier=pro -> Pro model" 0 "$rc" "Gemini 3.1 Pro (High)" "$out" out=$(STUB_MODE=args CLAUDE_PLUGIN_OPTION_DEFAULT_TIER=pro "$DELEGATE" --tier flash "hi" 2>/dev/null); rc=$? -check "explicit --tier overrides userConfig" 0 "$rc" "Gemini 3.5 Flash (High)" "$out" +check "explicit --tier overrides userConfig" 0 "$rc" "Gemini 3.6 Flash (High)" "$out" # multi-model: default_model + per-tier remap (agy supports Claude/GPT on some plans) out=$(STUB_MODE=args CLAUDE_PLUGIN_OPTION_DEFAULT_MODEL="Claude Sonnet 4.5" "$DELEGATE" "hi" 2>/dev/null); rc=$? check "userConfig default_model -> used as-is" 0 "$rc" "Claude Sonnet 4.5" "$out" out=$(STUB_MODE=args CLAUDE_PLUGIN_OPTION_DEFAULT_MODEL="Claude Sonnet 4.5" "$DELEGATE" --tier flash "hi" 2>/dev/null); rc=$? -check "explicit --tier beats default_model" 0 "$rc" "Gemini 3.5 Flash (High)" "$out" +check "explicit --tier beats default_model" 0 "$rc" "Gemini 3.6 Flash (High)" "$out" out=$(STUB_MODE=args CLAUDE_PLUGIN_OPTION_DEFAULT_MODEL="Claude Sonnet 4.5" "$DELEGATE" -m "GPT-X" "hi" 2>/dev/null); rc=$? check "explicit --model beats default_model" 0 "$rc" "GPT-X" "$out" out=$(STUB_MODE=args CLAUDE_PLUGIN_OPTION_TIER_FLASH="Claude Sonnet 4.5" "$DELEGATE" --tier flash "hi" 2>/dev/null); rc=$? @@ -155,7 +182,7 @@ check "explicit --timeout overrides userConfig" 0 "$rc" "--print-timeout 3m" "$o # invalid default tier from config falls back to flash; explicit --tier typo still errors out=$(STUB_MODE=args CLAUDE_PLUGIN_OPTION_DEFAULT_TIER=bogus "$DELEGATE" "hi" 2>/dev/null); rc=$? -check "invalid userConfig tier -> falls back to flash" 0 "$rc" "Gemini 3.5 Flash (High)" "$out" +check "invalid userConfig tier -> falls back to flash" 0 "$rc" "Gemini 3.6 Flash (High)" "$out" out=$("$DELEGATE" --tier bogus "hi" 2>/dev/null); rc=$? check "explicit --tier bogus -> exit 1" 1 "$rc" @@ -265,7 +292,7 @@ out=$("$CLOUD" 2>/dev/null); rc=$? check "missing --service -> exit 1" 1 "$rc" out=$(GCLOUD_MODE=fail "$CLOUD" --service svc 2>/dev/null); rc=$? check "generic gcloud failure -> exit 2" 2 "$rc" -out=$(PATH="$TMP/min" "$CLOUD" --service svc 2>&1); rc=$? +out=$(PATH="$MIN_PATH" "$CLOUD" --service svc 2>&1); rc=$? check "gcloud missing on PATH -> exit 4" 4 "$rc" "gcloud" "$out" out=$(GCLOUD_MODE=empty "$CLOUD" --service svc 2>/dev/null); rc=$? check "no matching logs -> exit 0 + clear note" 0 "$rc" "no logs" "$out" @@ -296,29 +323,44 @@ else echo "ok: no clip NOTE when under the cap"; PASS=$((PASS+1)); fi echo "== hooks ==" HOOKS="$ROOT/hooks" - -python3 -c "import json; json.load(open('$HOOKS/policy-context.json'))" 2>/dev/null; rc=$? +"$PYTHON" -c "import json; json.load(open('hooks/policy-context.json'))" 2>/dev/null; rc=$? check "policy-context.json is valid JSON" 0 "$rc" +# Test both SH and JS implementations of hooks for maximum compatibility out=$("$HOOKS/inject-policy.sh" 2>/dev/null); rc=$? check "inject-policy default on -> emits additionalContext" 0 "$rc" "additionalContext" "$out" check "inject-policy is cost-aware (not 'delegate everything')" 0 "$rc" "COST-AWARE" "$out" -# the emitted stdout is a well-formed SessionStart hook payload (not just substrings) -printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["hookSpecificOutput"]["hookEventName"]=="SessionStart"' 2>/dev/null; rc=$? +printf '%s' "$out" | "$PYTHON" -c 'import json,sys; d=json.load(sys.stdin); assert d["hookSpecificOutput"]["hookEventName"]=="SessionStart"' 2>/dev/null; rc=$? check "inject-policy emits valid SessionStart JSON" 0 "$rc" out=$(CLAUDE_PLUGIN_OPTION_CODING_POLICY=off "$HOOKS/inject-policy.sh" 2>/dev/null); rc=$? if [ "$rc" = 0 ] && [ -z "$out" ]; then echo "ok: inject-policy off -> exit 0 + no output"; PASS=$((PASS+1)); else echo "FAIL: inject-policy off (rc=$rc, out='${out:0:40}')"; FAIL=$((FAIL+1)); fi +out=$(node "$HOOKS/inject-policy.js" 2>/dev/null); rc=$? +check "inject-policy.js default on -> emits additionalContext" 0 "$rc" "additionalContext" "$out" +check "inject-policy.js is cost-aware" 0 "$rc" "COST-AWARE" "$out" +printf '%s' "$out" | "$PYTHON" -c 'import json,sys; d=json.load(sys.stdin); assert d["hookSpecificOutput"]["hookEventName"]=="SessionStart"' 2>/dev/null; rc=$? +check "inject-policy.js emits valid SessionStart JSON" 0 "$rc" + +out=$(CLAUDE_PLUGIN_OPTION_CODING_POLICY=off node "$HOOKS/inject-policy.js" 2>/dev/null); rc=$? +if [ "$rc" = 0 ] && [ -z "$out" ]; then echo "ok: inject-policy.js off -> exit 0 + no output"; PASS=$((PASS+1)); +else echo "FAIL: inject-policy.js off (rc=$rc, out='${out:0:40}')"; FAIL=$((FAIL+1)); fi + # check-agy: exits 0 whether agy is present (stub) or absent, and warns when absent out=$("$HOOKS/check-agy.sh" 2>/dev/null); rc=$? check "check-agy (agy present) -> exit 0" 0 "$rc" err=$( { PATH="/usr/bin:/bin" "$HOOKS/check-agy.sh" >/dev/null; } 2>&1 ); rc=$? check "check-agy (agy absent) -> exit 0 + warns" 0 "$rc" "not on PATH" "$err" -# hooks.json structural shape (all events: command hooks referencing the plugin root) -python3 - "$HOOKS/hooks.json" <<'PY' 2>/dev/null; rc=$? +out=$(node "$HOOKS/check-agy.js" 2>/dev/null); rc=$? +check "check-agy.js (agy present) -> exit 0" 0 "$rc" +NODE_BIN="$(type -p node 2>/dev/null || which node 2>/dev/null || command -v node 2>/dev/null)" +NODE_DIR="$(dirname "$NODE_BIN")" +err=$( { PATH="$NODE_DIR:/usr/bin:/bin" node "$HOOKS/check-agy.js" >/dev/null; } 2>&1 ); rc=$? +check "check-agy.js (agy absent) -> exit 0 + warns" 0 "$rc" "not on PATH" "$err" + +"$PYTHON" - "$HOOKS/hooks.json" <<'PY' 2>/dev/null; rc=$? import json,sys hooks=json.load(open(sys.argv[1]))["hooks"] assert hooks.get("SessionStart") and hooks.get("UserPromptSubmit") @@ -335,19 +377,46 @@ NUDGE="$HOOKS/nudge-delegation.sh" out=$(printf '%s' '{"prompt":"migrate every caller from APIv1 to APIv2 across the codebase"}' | "$NUDGE" 2>/dev/null); rc=$? check "nudge fires on bulk EN prompt" 0 "$rc" "additionalContext" "$out" check "nudge preserves Claude's judgment (not a mandate)" 0 "$rc" "THE JUDGMENT IS YOURS" "$out" -printf '%s' "$out" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d['hookSpecificOutput']['hookEventName']=='UserPromptSubmit'" 2>/dev/null; rc=$? +printf '%s' "$out" | "$PYTHON" -c "import json,sys; d=json.load(sys.stdin); assert d['hookSpecificOutput']['hookEventName']=='UserPromptSubmit'" 2>/dev/null; rc=$? check "nudge emits valid UserPromptSubmit JSON" 0 "$rc" + +NUDGE_JS="node $HOOKS/nudge-delegation.js" +out=$(printf '%s' '{"prompt":"migrate every caller from APIv1 to APIv2 across the codebase"}' | $NUDGE_JS 2>/dev/null); rc=$? +check "nudge-delegation.js fires on bulk EN prompt" 0 "$rc" "additionalContext" "$out" +check "nudge-delegation.js preserves Claude's judgment" 0 "$rc" "THE JUDGMENT IS YOURS" "$out" +printf '%s' "$out" | "$PYTHON" -c "import json,sys; d=json.load(sys.stdin); assert d['hookSpecificOutput']['hookEventName']=='UserPromptSubmit'" 2>/dev/null; rc=$? +check "nudge-delegation.js emits valid UserPromptSubmit JSON" 0 "$rc" + out=$(printf '%s' '{"prompt":"リポジトリ全体のテストを網羅的に生成して"}' | "$NUDGE" 2>/dev/null); rc=$? check "nudge fires on bulk JA prompt" 0 "$rc" "additionalContext" "$out" + +out=$(printf '%s' '{"prompt":"リポジトリ全体のテストを網羅的に生成して"}' | $NUDGE_JS 2>/dev/null); rc=$? +check "nudge-delegation.js fires on bulk JA prompt" 0 "$rc" "additionalContext" "$out" + out=$(printf '%s' '{"prompt":"fix the typo in README"}' | "$NUDGE" 2>/dev/null); rc=$? if [ "$rc" = 0 ] && [ -z "$out" ]; then echo "ok: nudge silent on a small prompt"; PASS=$((PASS+1)); else echo "FAIL: nudge fired on a small prompt (rc=$rc)"; FAIL=$((FAIL+1)); fi + +out=$(printf '%s' '{"prompt":"fix the typo in README"}' | $NUDGE_JS 2>/dev/null) +if [ -z "$out" ]; then echo "ok: nudge-delegation.js silent on a small prompt"; PASS=$((PASS+1)); +else echo "FAIL: nudge-delegation.js fired on a small prompt (out='$out')"; FAIL=$((FAIL+1)); fi + out=$(printf '%s' '{"prompt":"/antigravity:delegate migrate everything"}' | "$NUDGE" 2>/dev/null) if [ -z "$out" ]; then echo "ok: nudge silent when already delegating"; PASS=$((PASS+1)); else echo "FAIL: nudge fired on an antigravity command"; FAIL=$((FAIL+1)); fi + +out=$(printf '%s' '{"prompt":"/antigravity:delegate migrate everything"}' | $NUDGE_JS 2>/dev/null) +if [ -z "$out" ]; then echo "ok: nudge-delegation.js silent when already delegating"; PASS=$((PASS+1)); +else echo "FAIL: nudge-delegation.js fired on an antigravity command (out='$out')"; FAIL=$((FAIL+1)); fi + out=$(printf '%s' '{"prompt":"migrate all files"}' | CLAUDE_PLUGIN_OPTION_DELEGATION_NUDGE=off "$NUDGE" 2>/dev/null) if [ -z "$out" ]; then echo "ok: delegation_nudge=off suppresses the nudge"; PASS=$((PASS+1)); else echo "FAIL: nudge fired while disabled"; FAIL=$((FAIL+1)); fi + +out=$(printf '%s' '{"prompt":"migrate all files"}' | CLAUDE_PLUGIN_OPTION_DELEGATION_NUDGE=off $NUDGE_JS 2>/dev/null) +if [ -z "$out" ]; then echo "ok: nudge-delegation.js disabled via env"; PASS=$((PASS+1)); +else echo "FAIL: nudge-delegation.js fired when disabled"; FAIL=$((FAIL+1)); fi + out=$(printf '%s' '{"prompt":"hello","cwd":"/home/u/migration-tool"}' | "$NUDGE" 2>/dev/null) if [ -z "$out" ]; then echo "ok: nudge scans only the prompt field (cwd noise ignored)"; PASS=$((PASS+1)); else echo "FAIL: nudge matched a non-prompt field"; FAIL=$((FAIL+1)); fi @@ -360,6 +429,15 @@ printf '%s' '{"tool_input":{"command":"agy-job.sh start --tier pro \"b\""}}' | " check "gate allows the job wrapper -> exit 0" 0 "$rc" printf '%s' '{"tool_input":{"command":"rm -rf /tmp/x ; cat > f.txt"}}' | "$GATE" >/dev/null 2>&1; rc=$? check "gate blocks arbitrary bash -> exit 2" 2 "$rc" + +GATE_JS="node $HOOKS/validate-delegate-bash.js" +printf '%s' '{"tool_input":{"command":"X/scripts/agy-delegate.sh --tier flash \"x\""}}' | $GATE_JS >/dev/null 2>&1; rc=$? +check "validate-delegate-bash.js allows the delegate wrapper -> exit 0" 0 "$rc" +printf '%s' '{"tool_input":{"command":"agy-job.sh start --tier pro \"b\""}}' | $GATE_JS >/dev/null 2>&1; rc=$? +check "validate-delegate-bash.js allows the job wrapper -> exit 0" 0 "$rc" +printf '%s' '{"tool_input":{"command":"rm -rf /tmp/x ; cat > f.txt"}}' | $GATE_JS >/dev/null 2>&1; rc=$? +check "validate-delegate-bash.js blocks arbitrary bash -> exit 2" 2 "$rc" + # gate also accepts the bin-name entrypoints (no .sh) the subagent now calls (issue #11) printf '%s' '{"tool_input":{"command":"agy-delegate --tier flash \"x\""}}' | "$GATE" >/dev/null 2>&1; rc=$? check "gate allows bin name agy-delegate -> exit 0" 0 "$rc" @@ -370,7 +448,7 @@ AGENT="$ROOT/agents/antigravity-delegate.md" tl=$(grep -m1 '^tools:' "$AGENT") if [ "$tl" = "tools: Bash, Read, Glob" ]; then echo "ok: delegate agent tools allowlist exact (no Write/Edit)"; PASS=$((PASS+1)); else echo "FAIL: delegate agent tools line unexpected: '$tl'"; FAIL=$((FAIL+1)); fi -if grep -q "PreToolUse" "$AGENT" && grep -q "validate-delegate-bash.sh" "$AGENT"; then +if grep -q "PreToolUse" "$AGENT" && grep -q "validate-delegate-bash.js" "$AGENT"; then echo "ok: delegate agent wires the PreToolUse Bash gate"; PASS=$((PASS+1)); else echo "FAIL: delegate agent missing PreToolUse gate"; FAIL=$((FAIL+1)); fi # proactive auto-selection, WITH the judgment kept on Claude (not "delegate everything") @@ -383,6 +461,8 @@ BIN="$ROOT/bin" for b in agy-delegate agy-job agy-cost-compare agy-doctor cloud-debug agy-trace; do if [ -x "$BIN/$b" ]; then echo "ok: bin/$b executable"; PASS=$((PASS+1)); else echo "FAIL: bin/$b missing or not executable"; FAIL=$((FAIL+1)); fi + if [ -f "$BIN/$b.cmd" ]; then echo "ok: bin/$b.cmd exists"; PASS=$((PASS+1)); + else echo "FAIL: bin/$b.cmd missing"; FAIL=$((FAIL+1)); fi done # the shim must forward to scripts/ without needing $CLAUDE_PLUGIN_ROOT in the env out=$(env -u CLAUDE_PLUGIN_ROOT "$BIN/agy-delegate" --tier pro --print-command "hi" 2>/dev/null); rc=$? @@ -423,14 +503,14 @@ cat > "$SESS" <<'JSONL' {"message":{"role":"assistant","usage":{"output_tokens":10,"input_tokens":2,"cache_read_input_tokens":100},"content":[{"type":"tool_use","name":"Bash"}]}} {"message":{"role":"assistant","usage":{"output_tokens":5}}} JSONL -out=$(python3 "$MEASURE" "$SESS" "T" 2>/dev/null); rc=$? +out=$("$PYTHON" "$MEASURE" "$SESS" "T" 2>/dev/null); rc=$? # output=15 input=2 cache_read=100 -> weighted = 15*5 + 2 + 100*0.1 = 87 ; total=117 ; turns=2 check "measure: total tokens" 0 "$rc" "TOTAL tokens 117" "$out" check "measure: cost-weighted" 0 "$rc" "COST-WEIGHTED 87" "$out" check "measure: turns" 0 "$rc" "turns 2" "$out" check "measure: tool count" 0 "$rc" "'Bash': 1" "$out" -out=$(python3 "$MEASURE" /no/such/file 2>/dev/null); rc=$? +out=$("$PYTHON" "$MEASURE" /no/such/file 2>/dev/null); rc=$? check "measure: missing file -> exit 1" 1 "$rc" echo "== agy-job.sh (background jobs) ==" @@ -472,7 +552,7 @@ if printf '%s' "$out" | grep -q "QUOTA_EXHAUSTED"; then echo "ok: job shows AGY_ else echo "FAIL: job did not surface AGY_SIGNAL"; FAIL=$((FAIL+1)); fi echo "== plugin contract ==" -python3 - "$ROOT" <<'PY' +"$PYTHON" - "$ROOT" <<'PY' import json, os, re, sys, glob root = sys.argv[1] def p(*a): return os.path.join(root, *a) @@ -515,13 +595,16 @@ for f in glob.glob(p("commands", "*.md")) + [p("skills", "antigravity", "SKILL.m # the delegate subagent's PreToolUse gate points at a real script agent = open(p("agents", "antigravity-delegate.md")).read() -m = re.search(r"\$\{CLAUDE_PLUGIN_ROOT\}/([^\"']+\.sh)", agent) +m = re.search(r"\$\{CLAUDE_PLUGIN_ROOT\}/([^\"']+\.js)", agent) need(bool(m), "agent PreToolUse gate path not found") if m: need(os.path.isfile(p(m.group(1))), "agent gate references missing file: " + m.group(1)) for s in ("hooks/check-agy.sh", "hooks/inject-policy.sh", "hooks/validate-delegate-bash.sh", "hooks/nudge-delegation.sh"): need(os.access(p(s), os.X_OK), "not executable: " + s) +for s in ("hooks/check-agy.js", "hooks/inject-policy.js", "hooks/validate-delegate-bash.js", "hooks/nudge-delegation.js"): + need(os.path.isfile(p(s)), "missing JS hook file: " + s) + # bin/ entrypoints exist + executable (issue #11: $CLAUDE_PLUGIN_ROOT isn't exported # to model-run Bash, so commands/skill must call these bare names on the PATH) for b in ("agy-delegate", "agy-job", "agy-cost-compare", "agy-doctor", "cloud-debug", "agy-trace"):