From f27a20a388444303c079d61f220a412fdb1b4592 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 15 May 2026 21:58:51 -0400 Subject: [PATCH] fix(3608): model Antigravity as a first-class runtime in update.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/install.js and the SDK already treat Antigravity as a distinct runtime with config dir ~/.gemini/antigravity, env var ANTIGRAVITY_CONFIG_DIR, and CLI flag --antigravity. get-shit-done/workflows/update.md did not — so /gsd-update invoked from an Antigravity install classified the runtime as base Gemini, because: - RUNTIME_DIRS listed "gemini:.gemini" with no antigravity entry, so the scan matched ~/.gemini before ever looking for ~/.gemini/antigravity. - The PREFERRED_RUNTIME env-var ladder checked GEMINI_CONFIG_DIR but not ANTIGRAVITY_CONFIG_DIR. - The local-scope scan loops at lines 101 and 590 listed .gemini with no .gemini/antigravity sibling. - The ENV_RUNTIME_DIRS append block ignored ANTIGRAVITY_CONFIG_DIR. - The path-to-runtime classification bullets only mapped /.gemini/ -> gemini, with no /.gemini/antigravity/ -> antigravity branch. Every list is now updated so the more-specific antigravity entry precedes the base gemini entry, matching the installer at bin/install.js (lines 396-404, 1745-1749, 6175, 6475). tests/bug-3608-antigravity-update-runtime-classification.test.cjs is the structural regression guard. It parses the RUNTIME_DIRS bash array out of update.md and asserts the antigravity entry is present and ordered before gemini, asserts the env-var ladder checks ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR, asserts every `for dir in ...` scan loop that mentions .gemini also lists .gemini/antigravity ordered before it, and asserts the path-classification prose bullet lists antigravity before gemini. Per CONTRIBUTING.md: the test uses readFileSync on a .md file annotated `// allow-test-rule: source-text-is-the-product` because the bash blocks inside update.md ARE the deployed program — the agent loads update.md and runs them as-written. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/3608-antigravity-update-runtime.md | 5 + get-shit-done/workflows/update.md | 12 +- ...ity-update-runtime-classification.test.cjs | 170 ++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 .changeset/3608-antigravity-update-runtime.md create mode 100644 tests/bug-3608-antigravity-update-runtime-classification.test.cjs diff --git a/.changeset/3608-antigravity-update-runtime.md b/.changeset/3608-antigravity-update-runtime.md new file mode 100644 index 000000000..f807ac09e --- /dev/null +++ b/.changeset/3608-antigravity-update-runtime.md @@ -0,0 +1,5 @@ +--- +type: Fixed +issue: 3608 +--- +**`/gsd-update` invoked from Antigravity now resolves the correct runtime instead of collapsing into base Gemini** — `get-shit-done/workflows/update.md` now models `antigravity` as a first-class runtime everywhere it lists candidate dirs / env vars / classification rules. `RUNTIME_DIRS`, the `PREFERRED_RUNTIME` env-var ladder, the local-scope scan loops, the `ENV_RUNTIME_DIRS` env push, and the path-to-runtime classification bullets all list `antigravity:.gemini/antigravity` (and `ANTIGRAVITY_CONFIG_DIR`) before the base Gemini entry so the more-specific match wins. Adds `tests/bug-3608-antigravity-update-runtime-classification.test.cjs` as a structural regression guard. diff --git a/get-shit-done/workflows/update.md b/get-shit-done/workflows/update.md index 9d82e3427..554ecf103 100644 --- a/get-shit-done/workflows/update.md +++ b/get-shit-done/workflows/update.md @@ -14,6 +14,7 @@ Detect whether GSD is installed locally or globally by checking both locations a First, derive `PREFERRED_CONFIG_DIR` and `PREFERRED_RUNTIME` from the invoking prompt's `execution_context` path: - If the path contains `/get-shit-done/workflows/update.md`, strip that suffix and store the remainder as `PREFERRED_CONFIG_DIR` - Path contains `/.codex/` -> `codex` +- Path contains `/.gemini/antigravity/` -> `antigravity` - Path contains `/.gemini/` -> `gemini` - Path contains `/.config/kilo/` or `/.kilo/`, or `PREFERRED_CONFIG_DIR` contains `kilo.json` / `kilo.jsonc` -> `kilo` - Path contains `/.config/opencode/` or `/.opencode/`, or `PREFERRED_CONFIG_DIR` contains `opencode.json` / `opencode.jsonc` -> `opencode` @@ -36,7 +37,7 @@ expand_home() { # Using an array instead of a space-separated string ensures correct # iteration in both bash and zsh (zsh does not word-split unquoted # variables by default). Fixes #1173. -RUNTIME_DIRS=( "claude:.claude" "opencode:.config/opencode" "opencode:.opencode" "gemini:.gemini" "kilo:.config/kilo" "kilo:.kilo" "codex:.codex" ) +RUNTIME_DIRS=( "claude:.claude" "opencode:.config/opencode" "opencode:.opencode" "antigravity:.gemini/antigravity" "gemini:.gemini" "kilo:.config/kilo" "kilo:.kilo" "codex:.codex" ) ENV_RUNTIME_DIRS=() # PREFERRED_CONFIG_DIR / PREFERRED_RUNTIME should be set from execution_context @@ -58,6 +59,8 @@ fi if [ -z "$PREFERRED_RUNTIME" ]; then if [ -n "$CODEX_HOME" ]; then PREFERRED_RUNTIME="codex" + elif [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then + PREFERRED_RUNTIME="antigravity" elif [ -n "$GEMINI_CONFIG_DIR" ]; then PREFERRED_RUNTIME="gemini" elif [ -n "$KILO_CONFIG_DIR" ]; then @@ -95,7 +98,7 @@ if [ -n "$PREFERRED_CONFIG_DIR" ] && { [ -f "$PREFERRED_CONFIG_DIR/get-shit-done printf '%s' "$p" } normalized_preferred="$(normalize_path "$PREFERRED_CONFIG_DIR")" - for dir in .claude .config/opencode .opencode .gemini .config/kilo .kilo .codex; do + for dir in .claude .config/opencode .opencode .gemini/antigravity .gemini .config/kilo .kilo .codex; do resolved_local="$(cd "./$dir" 2>/dev/null && pwd)" normalized_local="$(normalize_path "$resolved_local")" if [ -n "$normalized_local" ] && [ "$normalized_local" = "$normalized_preferred" ]; then @@ -126,6 +129,9 @@ fi if [ -n "$CLAUDE_CONFIG_DIR" ]; then ENV_RUNTIME_DIRS+=( "claude:$(expand_home "$CLAUDE_CONFIG_DIR")" ) fi +if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then + ENV_RUNTIME_DIRS+=( "antigravity:$(expand_home "$ANTIGRAVITY_CONFIG_DIR")" ) +fi if [ -n "$GEMINI_CONFIG_DIR" ]; then ENV_RUNTIME_DIRS+=( "gemini:$(expand_home "$GEMINI_CONFIG_DIR")" ) fi @@ -581,7 +587,7 @@ for dir in "${CACHE_DIRS[@]}"; do fi done -for dir in .claude .config/opencode .opencode .gemini .config/kilo .kilo .codex; do +for dir in .claude .config/opencode .opencode .gemini/antigravity .gemini .config/kilo .kilo .codex; do rm -f "./$dir/cache/gsd-update-check.json" rm -f "$HOME/$dir/cache/gsd-update-check.json" done diff --git a/tests/bug-3608-antigravity-update-runtime-classification.test.cjs b/tests/bug-3608-antigravity-update-runtime-classification.test.cjs new file mode 100644 index 000000000..2badfbdb5 --- /dev/null +++ b/tests/bug-3608-antigravity-update-runtime-classification.test.cjs @@ -0,0 +1,170 @@ +// allow-test-rule: source-text-is-the-product +// update.md is loaded verbatim by the runtime as the /gsd-update workflow. +// The bash blocks inside it ARE the deployed program — the agent runs them. +// Asserting on the structural shape of those bash arrays is asserting on the +// deployed contract, identical to asserting on a workflow's instructions. + +/** + * Bug #3608: get-shit-done/workflows/update.md does not model Antigravity as + * a first-class runtime, so /gsd-update invoked from an Antigravity install + * (~/.gemini/antigravity) classifies the runtime as base Gemini. + * + * The installer (bin/install.js) and SDK already treat Antigravity as a + * distinct runtime with its own config dir (~/.gemini/antigravity), env var + * (ANTIGRAVITY_CONFIG_DIR), and CLI flag (--antigravity). update.md must + * agree, or /gsd-update routes Antigravity installs through the base Gemini + * path. + * + * Order matters: every bash array / env-var ladder / scan list that contains + * a Gemini entry MUST list the more-specific Antigravity entry first. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const UPDATE_MD = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'); + +function readUpdateMd() { + return fs.readFileSync(UPDATE_MD, 'utf-8'); +} + +// Parse a single bash array literal from a line like: +// RUNTIME_DIRS=( "claude:.claude" "gemini:.gemini" ... ) +// Returns the entries as an ordered list of "runtime:dir" strings. +function parseBashArray(content, varName) { + const re = new RegExp(`${varName}=\\(\\s*([^)]*?)\\s*\\)`, 'm'); + const m = content.match(re); + if (!m) return null; + return [...m[1].matchAll(/"([^"]+)"/g)].map((mm) => mm[1]); +} + +// Extract the runtime tokens (the part before ':') in declaration order. +function runtimeTokens(entries) { + return entries.map((e) => e.split(':')[0]); +} + +function firstIndex(arr, token) { + return arr.indexOf(token); +} + +describe('bug #3608: update.md models Antigravity as a first-class runtime', () => { + const content = readUpdateMd(); + + test('RUNTIME_DIRS contains antigravity before gemini', () => { + const entries = parseBashArray(content, 'RUNTIME_DIRS'); + assert.ok(entries, 'RUNTIME_DIRS array literal not found in update.md'); + + const tokens = runtimeTokens(entries); + const antIdx = firstIndex(tokens, 'antigravity'); + const gemIdx = firstIndex(tokens, 'gemini'); + + assert.notStrictEqual(antIdx, -1, 'RUNTIME_DIRS missing antigravity entry'); + assert.notStrictEqual(gemIdx, -1, 'RUNTIME_DIRS missing gemini entry'); + assert.ok( + antIdx < gemIdx, + `antigravity must precede gemini in RUNTIME_DIRS (got antigravity@${antIdx}, gemini@${gemIdx}). ` + + `Order matters: classification iterates this list and the first match wins.`, + ); + }); + + test('RUNTIME_DIRS antigravity entry points at .gemini/antigravity', () => { + const entries = parseBashArray(content, 'RUNTIME_DIRS'); + assert.ok(entries); + + const ant = entries.find((e) => e.startsWith('antigravity:')); + assert.ok(ant, 'antigravity entry missing'); + assert.strictEqual( + ant, + 'antigravity:.gemini/antigravity', + `antigravity entry must be exactly "antigravity:.gemini/antigravity" to match the installer ` + + `(bin/install.js line ~404: ~/.gemini/antigravity)`, + ); + }); + + test('PREFERRED_RUNTIME env-var inference recognizes ANTIGRAVITY_CONFIG_DIR before GEMINI_CONFIG_DIR', () => { + // Extract the inference block — the if/elif ladder that maps env vars to runtime. + // Match from the comment marker through the closing `fi` of the inference block. + const blockMatch = content.match( + /If runtime is still unknown, infer from runtime env vars[\s\S]*?\nfi\n/, + ); + assert.ok(blockMatch, 'env-var inference block not found'); + + const block = blockMatch[0]; + const antPos = block.indexOf('ANTIGRAVITY_CONFIG_DIR'); + const gemPos = block.indexOf('GEMINI_CONFIG_DIR'); + + assert.notStrictEqual( + antPos, + -1, + 'env-var inference must check ANTIGRAVITY_CONFIG_DIR (used by bin/install.js)', + ); + assert.notStrictEqual(gemPos, -1, 'env-var inference must check GEMINI_CONFIG_DIR'); + assert.ok( + antPos < gemPos, + `ANTIGRAVITY_CONFIG_DIR must be checked before GEMINI_CONFIG_DIR ` + + `(got antigravity@${antPos}, gemini@${gemPos}) — otherwise an Antigravity ` + + `install with both env vars set falls through to gemini.`, + ); + }); + + test('ENV_RUNTIME_DIRS appends an antigravity entry when ANTIGRAVITY_CONFIG_DIR is set', () => { + // Match the `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then` block and require an + // `ENV_RUNTIME_DIRS+=( "antigravity:..." )` push inside it. The pushed value + // may contain nested `"$VAR"` quotes (bash command substitution), so we + // don't try to match the full string literal — just the leading runtime + // tag `"antigravity:`. + const re = /if \[ -n "\$ANTIGRAVITY_CONFIG_DIR" \];\s*then\s+ENV_RUNTIME_DIRS\+=\(\s*"antigravity:/; + assert.match( + content, + re, + 'expected `if [ -n "$ANTIGRAVITY_CONFIG_DIR" ]; then ENV_RUNTIME_DIRS+=( "antigravity:..." )`', + ); + }); + + test('local-scope scan dir list includes .gemini/antigravity before .gemini', () => { + // Lines like: for dir in .claude .config/opencode .opencode .gemini ... + // The first iteration of the local scan ranges over a hardcoded list. + const forLoops = [...content.matchAll(/for dir in ([^;]+); do/g)]; + assert.ok(forLoops.length > 0, 'no `for dir in ...; do` scan loops found'); + + for (const m of forLoops) { + const tokens = m[1].trim().split(/\s+/); + const antIdx = tokens.indexOf('.gemini/antigravity'); + const gemIdx = tokens.indexOf('.gemini'); + if (gemIdx === -1) continue; // scan loop without .gemini — irrelevant + assert.notStrictEqual( + antIdx, + -1, + `scan loop "for dir in ${m[1].trim()}" mentions .gemini but not .gemini/antigravity — ` + + `the more-specific Antigravity dir must be present`, + ); + assert.ok( + antIdx < gemIdx, + `scan loop "for dir in ${m[1].trim()}": .gemini/antigravity (idx ${antIdx}) must precede .gemini (idx ${gemIdx})`, + ); + } + }); + + test('path-to-runtime classification documents /.gemini/antigravity/ before /.gemini/', () => { + // The markdown bullet list near the top of get_installed_version step. + // We assert the antigravity bullet exists and appears before the gemini bullet + // in the file (textual order = evaluation order in the prose contract). + const antBullet = content.search(/Path contains `\/\.gemini\/antigravity\/?` -> `antigravity`/); + const gemBullet = content.search(/Path contains `\/\.gemini\/` -> `gemini`/); + + assert.notStrictEqual( + antBullet, + -1, + 'classification bullet for /.gemini/antigravity/ -> antigravity is missing', + ); + assert.notStrictEqual(gemBullet, -1, 'classification bullet for /.gemini/ -> gemini is missing'); + assert.ok( + antBullet < gemBullet, + 'antigravity bullet must precede gemini bullet in path-classification list', + ); + }); +});