fix(#3738): antigravity global skills/agents install to ~/.gemini/config (#3921)

* test(#3738): antigravity global skills/agents must resolve under ~/.gemini/config

Regression tests (RED first): --skills-root and gsd-tools query surfaces,
install-plan dest dirs, and converter skills-path rewrite.

* fix(#3738): antigravity global skills/agents install to ~/.gemini/config

Antigravity's machine-local discovery scans ~/.gemini/config/{skills,agents};
the configHome (~/.gemini/antigravity) is deprecated for artifacts. Declare the
ADR-1239 skills/agents 'home' override on the antigravity global layout — the
same mechanism codex uses (.agents) — and divert ~/.claude/skills/ references
in converted global content to ~/.gemini/config/skills/. configHome, settings,
probe/migration semantics, and the local .agents layout are unchanged.

* fix(#3738): retire deprecated configHome artifacts via installer migration 010

Next install converges an existing antigravity install: manifest-managed
skills/gsd-*/ and agents/gsd-*.md under the configHome (a location AGY does
not scan) are removed — modified files backed up first, unmanifested and
non-gsd entries preserved — and now-empty containers retired. Global scope
only; the local .agents surface is live. Docs + inventory updated.

* fix(#3738): converter sync in bin/install.js, harness emit-root coverage, migration baseline

- bin/install.js converter gains the same ~/.claude/skills → ~/.gemini/config/
  rewrite as src (ADR-1508 dual copy must stay in sync).
- Parity-manifest walk covers home-override emit roots (extraEmitRootsFor) so
  antigravity's emitted skills/agents stay differential-visible at their new
  install root; install-tree fixture regen confirms an unchanged key set.
- skills-from-commands rule declares the antigravity converter as a
  runtime-scoped transform; one ack fragment covers the identity-classed
  workflow whose antigravity copy embeds the old skills path.
- Migration 010 checksum baseline + home-override set doc updated; existing
  tests updated to the #3738 contract (global dest, golden parity via layout
  dest, integration expectations).

* fix(#3738): tolerate an absent extra emit root on baseline-side measurement

The base tree's installer predates the home override, so <HOME>/.gemini/config
does not exist there; walk() threw ENOENT and the in-job baseline build failed.
An absent extra root is the legitimate pre-override shape — skip it.

* fix(#3738): review findings — manifest agents root, bare skills-path rewrite, guard comment

- writeManifest resolves the agents-kind home override (_kindDestDirSafe), so
  the manifest records agents at their actual install root and drift detection
  keeps working (isolated review finding 1, major).
- Converter bare forms ~/.claude/skills and $HOME/.claude/skills (no trailing
  slash) divert to ~/.gemini/config/skills instead of falling through to the
  retired configHome path (finding 2).
- real-home-guard comment updated: antigravity's global agents kind is the
  first agents-kind home override (finding 3, doc-only).
- Regression tests for both behavioral findings.

* chore(#3738): changeset fragment (pr number backfilled after PR creation)

* chore(#3738): backfill changeset PR number (3921)

* fix(#3738): sandbox HOME in tests that install antigravity global artifacts

antigravity is the first home-override runtime in the golden-parity and
skills-wrapper suites (codex is not in their runtime lists), so those tests
never needed HOME sandboxing — the real-home guard now (correctly) refuses
their un-sandboxed global installs on CI, where HOME is the passwd home.

* fix(#3738): stop the K3 sequential-sandbox env leak; sandbox L2's home-override plans

K3's two back-to-back sandboxHome calls leave HOME pointing at the first
sandbox once the after-hooks restore (each call saves the env as it found
it, so the second saves the first's sandbox as 'original'). On the windows
matrix that leaked gsd-k3-qwen-* home into the L2 property, whose
antigravity/global run then (correctly) refused via the #3712 real-home
guard — antigravity is the runtime that made L2's plan escape into
os.homedir(). K3 now manages the env with a single restore; L2 sandboxes
HOME per run, mirroring L1.

* fix(#3738): L2 property's HOME sandbox must exist on disk

The #3712 guard's sandbox exemption fails closed when identify(effectiveHome)
is 'absent' — L2 never created its configDir, so on the windows matrix (tmpdir
under the real home) the antigravity/global run refused even with HOME
sandboxed. Create the per-run sandbox dir and clean it up.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 02:24:03 -04:00
committed by GitHub
parent e20744eacb
commit 39673ae9ff
29 changed files with 936 additions and 55 deletions

View File

@@ -0,0 +1,10 @@
{
"$comment": "Hash ack (#2914 fragment). Reason: #3738 moved antigravity's global skills and agents from the configHome (~/.gemini/antigravity, which AGY does not scan) to ~/.gemini/config via the artifactLayout kind `home` override, and the antigravity content converter now rewrites `~/.claude/skills/` and `$HOME/.claude/skills/` references in GLOBAL installs to `~/.gemini/config/skills/` — the directory Antigravity actually discovers (antigravity.google/docs/skills, /docs/subagents; re-verified 2026-08-26). The skills ripple is attributed permanently by the new runtime-scoped `transforms` on the skills-from-commands rule (ANTIGRAVITY_SKILL_TRANSFORM_SRCS), so this fragment covers the ONE remaining converted path that rule cannot reach: gsd-core/workflows/profile-user.md, an identity-classed gsd-core file whose antigravity-emitted copy embeds `$HOME/.claude/skills/gsd-dev-preferences/SKILL.md` in two display strings (the dev-preferences skill location table). Those strings now point at `$HOME/.gemini/config/skills/…`, matching where the installer actually writes the skill post-#3738; the source workflow's claude-form text is deliberately unchanged. The parity-manifest walk was widened the same PR (extraEmitRootsFor, tests/helpers/install-shared.cjs) so the antigravity family's emitted keys stay manifest-visible at their new install root — without that, all 71 skill keys silently left the differential, which is the exact #3547 blind-spot class this gate exists to prevent.",
"version": 1,
"paths": {
"gsd-core/workflows/profile-user.md": {
"runtime": "antigravity",
"reason": "#3738: the antigravity-emitted copy of this workflow embeds the global dev-preferences skill location in two display strings ('$HOME/.claude/skills/gsd-dev-preferences/SKILL.md'); post-#3738 the installer writes that skill to $HOME/.gemini/config/skills/, so the converter rewrites the strings to the scanned dir and the emitted hash moves with no gsd-core/workflows/profile-user.md source change. The gsd-core-verbatim rule is identity-classed and cannot declare the converter as a transform, so the ripple is acked here; the claude-family copy is byte-identical to base and does not ripple."
}
}
}

View File

@@ -1165,12 +1165,29 @@ describe('installRuntimeArtifacts — K3: real install before/after, full recurs
for (const runtime of ['claude', 'qwen']) {
const dirA = createTempDir(`gsd-k3-${runtime}-a-`);
const dirB = createTempDir(`gsd-k3-${runtime}-b-`);
t.after(() => { cleanup(dirA); cleanup(dirB); });
// Two SEQUENTIAL sandboxes for one test: sandboxHome()'s per-call
// t.after hooks each save the env as they found it, so the second call
// saves the FIRST sandbox as its "original" — hook ordering then leaves
// HOME pointing at dirA after the test, leaking into later tests in the
// shard (observed on the windows matrix: a leaked gsd-k3-qwen-* home
// made the L2 property's antigravity/global run refuse via the
// #3712 real-home guard). Manage the env directly with ONE restore.
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
const savedMarker = process.env.GSD_TEST_HOME_SANDBOX;
t.after(() => {
if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile;
if (savedMarker === undefined) delete process.env.GSD_TEST_HOME_SANDBOX; else process.env.GSD_TEST_HOME_SANDBOX = savedMarker;
cleanup(dirA); cleanup(dirB);
});
sandboxHome(t, dirA);
installRuntimeArtifacts(runtime, dirA, 'global', RESOLVED_FULL);
sandboxHome(t, dirB);
installRuntimeArtifacts(runtime, dirB, 'global', RESOLVED_FULL);
for (const dir of [dirA, dirB]) {
process.env.HOME = dir;
process.env.USERPROFILE = dir;
process.env.GSD_TEST_HOME_SANDBOX = dir;
installRuntimeArtifacts(runtime, dir, 'global', RESOLVED_FULL);
}
const filesA = walkFilesRecursively(dirA);
const filesB = walkFilesRecursively(dirB);
@@ -1260,17 +1277,36 @@ describe('installRuntimeArtifacts — L2: plan is deterministic (property)', ()
};
}
test('plan is deterministic', () => {
test('plan is deterministic', (t) => {
const runtimes = Object.keys(registry.runtimes);
const RUNTIME_ARB = fc.constantFrom(...runtimes);
const SCOPE_ARB = fc.constantFrom('global', 'local');
let hits = 0;
// #3738: the per-run HOME sandbox must EXIST on disk — the #3712 guard's
// sandbox exemption fails closed when it cannot stat the effective home
// (identify() -> 'absent'), which is exactly what a never-created configDir
// gives it on the windows matrix where tmpdir sits under the real home.
const createdL2Dirs = [];
t.after(() => { for (const d of createdL2Dirs) cleanup(d); });
fc.assert(
fc.property(RUNTIME_ARB, SCOPE_ARB, (runtime, scope) => {
// configDir is never created for real — both calls run against fresh,
// independent fake adapters, so no real fs cleanup is needed here.
const configDir = path.join(os.tmpdir(), `gsd-l2-${runtime}-${crypto.randomUUID()}`);
fs.mkdirSync(configDir, { recursive: true });
createdL2Dirs.push(configDir);
// #3738: a home-override runtime (antigravity → <HOME>/.gemini/config)
// resolves its dest from os.homedir(), NOT configDir — sandbox HOME to
// configDir for the duration of both calls (mirroring L1 above) so the
// plan never escapes into an ambient or leaked home and the #3712
// real-home guard stays satisfied on hosts where tmpdir sits under the
// real home (windows).
const savedL2Home = process.env.HOME;
const savedL2UserProfile = process.env.USERPROFILE;
process.env.HOME = configDir;
process.env.USERPROFILE = configDir;
try {
const planA = installRuntimeArtifacts(runtime, configDir, scope, RESOLVED_CORE, undefined, undefined, { fs: createFakeInstallFs() });
const planB = installRuntimeArtifacts(runtime, configDir, scope, RESOLVED_CORE, undefined, undefined, { fs: createFakeInstallFs() });
hits++;
@@ -1281,6 +1317,10 @@ describe('installRuntimeArtifacts — L2: plan is deterministic (property)', ()
`L2 (${runtime}/${scope}): two installs against fresh fake adapters with the same inputs must ` +
'yield structurally identical plans (temp-dir names normalized — see normalizePlanForIdempotence)',
);
} finally {
if (savedL2Home === undefined) delete process.env.HOME; else process.env.HOME = savedL2Home;
if (savedL2UserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedL2UserProfile;
}
}),
{ numRuns: 30, seed: 2874, verbose: true },
);

View File

@@ -27,7 +27,7 @@ const { runNode } = require('./helpers/process-seam.cjs');
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
const { cleanup } = require('./helpers.cjs');
const { RUNTIME_META, runMinimalInstall, BUILD_SCRIPT, buildInstallTree } = require('./helpers/install-shared.cjs');
const { RUNTIME_META, runMinimalInstall, BUILD_SCRIPT, buildInstallTree, extraEmitRootsFor } = require('./helpers/install-shared.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
@@ -74,7 +74,7 @@ for (const runtime of runtimes) {
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
let actual;
try {
actual = buildInstallTree(configDir, root);
actual = buildInstallTree(configDir, root, extraEmitRootsFor(runtime, 'global', root));
} finally {
cleanup(root);
}

View File

@@ -132,7 +132,7 @@ const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
const { cleanup } = require('./helpers.cjs');
const { cleanup, sandboxHome } = require('./helpers.cjs');
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
@@ -171,12 +171,22 @@ describe('#1575 — golden-parity: surface path matches install path for descrip
test(`${runtime}: surface agents byte-identical to install agents`, (t) => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1575-${runtime}-`));
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
// #3738: antigravity's skills/agents kinds declare a global `home`
// override resolved from os.homedir() — sandbox HOME to the configDir
// (the #3712 marker real-home-guard needs) so the override resolves
// inside the sandbox instead of the runner's real home.
sandboxHome(t, configDir);
// Step 1: install path writes agents
installRuntimeArtifacts(runtime, configDir, 'global', parity1575Profile, resolveAttribution1575);
// Step 2: snapshot agent files
const agentsDir = path.join(configDir, 'agents');
// Step 2: snapshot agent files at the installer's REAL destination —
// honor the kind `home` override (codex → ~/.agents, antigravity →
// ~/.gemini/config per #3738) exactly like assertDestWithinConfigHome's
// root selection, never assume configDir/agents.
const parityLayout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
const parityAgentsKind = parityLayout.kinds.find((k) => k.kind === 'agents');
const agentsDir = path.join(parityAgentsKind.home ?? configDir, parityAgentsKind.destSubpath);
const installSnap = snapshotAgents(agentsDir);
assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`);

View File

@@ -160,6 +160,18 @@ const AGENT_TRANSFORM_SRCS = [
'src/model-catalog.cts',
];
// #3738: antigravity's global skills pass through the antigravity converter
// (src/runtime-artifact-conversion.cts, mirrored hand-authored in bin/install.js
// per ADR-1508), whose rewrites — e.g. ~/.claude/skills/ → ~/.gemini/config/skills/
// — can move emitted bytes with NO commands/gsd source changing. Declaring the
// transform here attributes that ripple class permanently, the same way
// AGENT_TRANSFORM_SRCS does for agents; scoped to runtime 'antigravity' so a
// converter change never blankets the other skills runtimes' attribution.
const ANTIGRAVITY_SKILL_TRANSFORM_SRCS = [
'src/runtime-artifact-conversion.cts',
'bin/install.js',
];
/**
* A `sources` entry ending in `/` is a PREFIX, not a file: it means "any repo path
* under this directory legitimately explains this emitted path". Used where an
@@ -481,6 +493,9 @@ const PROVENANCE_RULES = [
roots: SKILLS_ROOTS,
pattern: /^([^/]+)\/SKILL\.md$/,
sources: (m) => [`${COMMANDS_SRC}/${stripSkillPrefix(m[1])}.md`],
// #3738: see ANTIGRAVITY_SKILL_TRANSFORM_SRCS above — antigravity's skill
// bytes are converter-produced, so a converter change explains the ripple.
transforms: (_m, ctx) => (ctx.runtime === 'antigravity' ? ANTIGRAVITY_SKILL_TRANSFORM_SRCS : []),
},
{
id: 'skills-nested-from-commands',

View File

@@ -42,6 +42,7 @@ const {
MINIMUM_MANIFEST_FAMILIES,
runMinimalInstall,
buildParityManifest,
extraEmitRootsFor,
PKG_VERSION,
} = require('./install-shared.cjs');
const { mergeAckSources, MAX_ACK_FRAGMENTS } = require('./emitted-diff.cjs');
@@ -898,7 +899,12 @@ function currentManifests({ repoRoot } = {}) {
for (const { name, runtime, scope } of MANIFEST_FAMILIES) {
const { configDir, root } = runMinimalInstall({ runtime, scope, installScript });
try {
manifests[name] = buildParityManifest(configDir, root, { pkgVersion });
manifests[name] = buildParityManifest(configDir, root, {
pkgVersion,
// #3738: cover home-override emit roots outside configDir (antigravity
// → <HOME>/.gemini/config) so the differential keeps seeing them.
extraEmitRoots: extraEmitRootsFor(runtime, scope, root),
});
} finally {
cleanup(root);
}

View File

@@ -299,6 +299,32 @@ function walk(dir) {
return results;
}
// #3738: runtimes whose GLOBAL artifact layout declares a kind `home` override
// that resolves OUTSIDE configDir (antigravity → <HOME>/.gemini/config, the dir
// AGY scans for machine-local discovery). The parity walk must cover those
// roots too, or every emitted skill/agent silently leaves the manifest the
// moment the override appears — exactly the #3547 blind-spot class this
// harness exists to prevent (a real install shape the manifest cannot see).
// Mirrors the capability registry's artifactLayout `home` fields the same way
// RUNTIME_META mirrors configHome; codex's `.agents` override is deliberately
// NOT listed here — its skills have never been manifest-covered, and widening
// this table for codex is a coverage change unrelated to #3738.
const EXTRA_GLOBAL_EMIT_ROOTS = {
antigravity: [path.join('.gemini', 'config')],
};
/**
* Absolute extra emit-root directories for a runtime/scope pair — the
* home-override install roots outside configDir — or [] when none.
* @param {string} runtime
* @param {string} scope
* @param {string} root the sandboxed HOME/temp root the install ran under
*/
function extraEmitRootsFor(runtime, scope, root) {
const suffixes = (scope === 'global' && EXTRA_GLOBAL_EMIT_ROOTS[runtime]) || [];
return suffixes.map((suffix) => path.join(root, suffix));
}
/**
* Build a deterministic hash-map of all non-volatile files under configDir.
*
@@ -391,7 +417,29 @@ function collectNormalizedEmittedFiles(configDir, root, opts, callerName) {
'Pass the version of the tree that produced the emitted content at configDir.'
);
}
const allFiles = walk(configDir);
// #3738: home-override emit roots outside configDir (see
// EXTRA_GLOBAL_EMIT_ROOTS). Each extra root's files are keyed rel to THAT
// root — the emitted key space is install-location-relative ('skills/…',
// 'agents/…'), so the same artifact keeps the same key whether the layout
// resolves it under configDir or under the override root. configDir entries
// win on collision (a layout would never write both, but a stale leftover
// under configDir must not shadow the live emit root).
const extraEmitRoots = Array.isArray(opts.extraEmitRoots) ? opts.extraEmitRoots : [];
const allFiles = walk(configDir).map((full) => ({ full, relRoot: configDir }));
for (const extraRoot of extraEmitRoots) {
if (typeof extraRoot !== 'string' || extraRoot.length === 0) {
throw new Error(`${callerName}: opts.extraEmitRoots entries must be non-empty absolute paths`);
}
if (path.resolve(extraRoot) === path.resolve(configDir)) continue;
// An absent extra root is a legitimate shape, not an error: the baseline
// side measures a BASE tree whose installer may predate the home override
// (the artifacts then live under configDir, which IS walked). Only a
// root that exists but cannot be read is a failure — walk() surfaces that.
let stat;
try { stat = fs.statSync(extraRoot); } catch { continue; }
if (!stat.isDirectory()) continue;
for (const full of walk(extraRoot)) allFiles.push({ full, relRoot: extraRoot });
}
const unsorted = {};
// The claude LOCAL install resolves its config dir via realpath, which on macOS
@@ -405,11 +453,15 @@ function collectNormalizedEmittedFiles(configDir, root, opts, callerName) {
let realRoot = root;
try { realRoot = fs.realpathSync(root); } catch { /* root already gone / not resolvable */ }
for (const full of allFiles) {
for (const { full, relRoot } of allFiles) {
// Build POSIX-style relative path for cross-platform stability
const rel = path.relative(configDir, full).split(path.sep).join('/');
const rel = path.relative(relRoot, full).split(path.sep).join('/');
if (VOLATILE_FILES.has(rel)) continue;
// Collision policy stated above: configDir owns the key first; a file in
// an extra emit root with an already-claimed rel is the stale-leftover
// case, not a second opinion.
if (relRoot !== configDir && Object.prototype.hasOwnProperty.call(unsorted, rel)) continue;
if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue;
if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue;
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue;
@@ -500,10 +552,15 @@ function buildEmittedSizes(configDir, root, opts = {}) {
* normalizes to, never which paths buildParityManifest walks or excludes — so there
* is nothing for a caller to pass here, and forwarding one through would only let a
* bad version value make a pure file-set query throw for no file-set-shaped reason
* (#2891 review FINDING 6; verified no caller passes a third argument —
* tests/golden-install-tree.test.cjs, scripts/gen-install-tree-fixtures.cjs). */
function buildInstallTree(configDir, root) {
return Object.keys(buildParityManifest(configDir, root)).sort();
* (#2891 review FINDING 6; verified no caller passes a version argument —
* tests/golden-install-tree.test.cjs, scripts/gen-install-tree-fixtures.cjs).
* #3738: an OPTIONAL third argument — extraEmitRoots (array, see
* extraEmitRootsFor) — is the one non-version thing a file-set query legitimately
* needs: the home-override install roots outside configDir. Omitted/null keep the
* legacy configDir-only walk, so buildInstallTree(cd, root, null) still equals
* buildInstallTree(cd, root). */
function buildInstallTree(configDir, root, extraEmitRoots) {
return Object.keys(buildParityManifest(configDir, root, { extraEmitRoots })).sort();
}
function simulateHookCopy(hooksSrc, hooksDest) {
@@ -752,6 +809,7 @@ module.exports = {
buildParityManifest,
buildEmittedSizes,
buildInstallTree,
extraEmitRootsFor,
simulateHookCopy,
installerEnv,
runMinimalInstall,

View File

@@ -1001,6 +1001,10 @@ describe('skills wrapper threads install scope into converter isGlobal (regressi
const globalDir = createTempDir(`gsd-ial-g-${runtime}-`);
const localDir = createTempDir(`gsd-ial-l-${runtime}-`);
t.after(() => { cleanup(globalDir); cleanup(localDir); });
// #3738: antigravity's global skills kind resolves its `home` override
// from os.homedir(); sandbox HOME (with the #3712 marker) so the global
// install writes inside globalDir instead of the runner's real home.
sandboxHome(t, globalDir);
installRuntimeArtifacts(runtime, globalDir, 'global', RESOLVED_CORE);
installRuntimeArtifacts(runtime, localDir, 'local', RESOLVED_CORE);
@@ -1009,8 +1013,10 @@ describe('skills wrapper threads install scope into converter isGlobal (regressi
const lSkills = resolveRuntimeArtifactLayout(runtime, localDir, 'local').kinds.find(k => k.kind === 'skills');
assert.ok(gSkills && lSkills, `${runtime}: must resolve a skills kind for both scopes`);
const gCombined = readAllSkillMd(path.join(globalDir, gSkills.destSubpath));
const lCombined = readAllSkillMd(path.join(localDir, lSkills.destSubpath));
// #3738: the global skills tree may live under the kind `home` override
// (antigravity → ~/.gemini/config), so honor it like the installer does.
const gCombined = readAllSkillMd(path.join(gSkills.home ?? globalDir, gSkills.destSubpath));
const lCombined = readAllSkillMd(path.join(lSkills.home ?? localDir, lSkills.destSubpath));
// Precondition (non-vacuity guard): some core skill carries a ~/.claude
// reference, so the GLOBAL install surfaces the global home marker. If this
@@ -1108,6 +1114,58 @@ describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () =
const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length;
assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`);
});
// #3738: global skills install under ~/.gemini/config/skills (the dir AGY
// scans), while gsd-core runtime references stay under configHome. A skills
// path must therefore rewrite to the config root, not ~/.gemini/antigravity.
test('replaces ~/.claude/skills/ with ~/.gemini/config/skills (#3738)', () => {
const input = 'Skill dirs live at `~/.claude/skills/gsd-*/`.';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(
result.includes('~/.gemini/config/skills/gsd-*/'),
`Expected ~/.gemini/config/skills rewrite, got: ${result}`
);
assert.ok(
!result.includes('~/.gemini/antigravity/skills'),
`Skills must not point at the deprecated dir, got: ${result}`
);
});
test('replaces $HOME/.claude/skills/ with $HOME/.gemini/config/skills (#3738)', () => {
const input = 'ls $HOME/.claude/skills/';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(
result.includes('$HOME/.gemini/config/skills/'),
`Expected $HOME/.gemini/config/skills rewrite, got: ${result}`
);
assert.ok(!result.includes('$HOME/.claude/'), `Expected full replacement, got: ${result}`);
});
test('replaces bare ~/.claude/skills (no trailing slash) with ~/.gemini/config/skills (#3738)', () => {
const input = 'ls ~/.claude/skills';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(
result.includes('~/.gemini/config/skills'),
`bare skills form must divert to the config root, got: ${result}`
);
assert.ok(
!result.includes('~/.gemini/antigravity/skills'),
`bare skills form must not fall through to the retired configHome path, got: ${result}`
);
});
test('keeps gsd-core references under ~/.gemini/antigravity when a skills ref is present (#3738)', () => {
const input = 'Read ~/.claude/gsd-core/workflows/x.md then list ~/.claude/skills/.';
const result = convertClaudeToAntigravityContent(input, true);
assert.ok(
result.includes('~/.gemini/antigravity/gsd-core/workflows/x.md'),
`gsd-core ref must stay under configHome, got: ${result}`
);
assert.ok(
result.includes('~/.gemini/config/skills/'),
`skills ref must move to the config root, got: ${result}`
);
});
});
describe('local install', () => {
@@ -7606,3 +7664,54 @@ describe('#3719: real global Claude install — agents/*.md @-refs must resolve
assert.deepStrictEqual(failures, [], `local install must not leak @$HOME/ or @~/.claude/:\n${failures.join('\n')}`);
});
});
// ── #3738: antigravity global artifacts install under ~/.gemini/config ────────
describe('#3738: antigravity global artifacts install under ~/.gemini/config', () => {
test('global skills and agents dest dirs resolve under <home>/.gemini/config, not configHome', (t) => {
const configDir = createTempDir('gsd-3738-antigravity-');
t.after(() => cleanup(configDir));
sandboxHome(t, configDir);
const layout = resolveRuntimeArtifactLayout('antigravity', configDir, 'global');
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
assert.ok(skillsKind, 'antigravity must have a skills kind');
assert.ok(agentsKind, 'antigravity must have an agents kind');
const expectedHome = path.join(configDir, '.gemini', 'config');
assert.strictEqual(skillsKind.home, expectedHome, 'skills home override must be ~/.gemini/config');
assert.strictEqual(agentsKind.home, expectedHome, 'agents home override must be ~/.gemini/config');
installRuntimeArtifacts('antigravity', configDir, 'global', RESOLVED_CORE);
assert.ok(
fs.existsSync(path.join(expectedHome, 'skills', 'gsd-help', 'SKILL.md')),
'a gsd-* skill must exist under ~/.gemini/config/skills'
);
const agentsDir = path.join(expectedHome, 'agents');
assert.ok(fs.existsSync(agentsDir), '~/.gemini/config/agents must exist');
assert.ok(
fs.readdirSync(agentsDir).some(n => n.startsWith('gsd-')),
'at least one gsd-* agent must exist under ~/.gemini/config/agents'
);
assert.ok(
!fs.existsSync(path.join(configDir, 'skills')),
'no skills dir may be created under the configHome (~/.gemini/antigravity)'
);
assert.ok(
!fs.existsSync(path.join(configDir, 'agents')),
'no agents dir may be created under the configHome (~/.gemini/antigravity)'
);
});
test('local (workspace) layout is unchanged: .agents/skills and .agents/agents', (t) => {
const configDir = createTempDir('gsd-3738-antigravity-local-');
t.after(() => cleanup(configDir));
sandboxHome(t, configDir);
const layout = resolveRuntimeArtifactLayout('antigravity', configDir, 'local');
for (const kind of layout.kinds) {
assert.strictEqual(kind.home, undefined, `local ${kind.kind} must not carry a home override`);
}
});
});

View File

@@ -1317,7 +1317,7 @@ describe('antigravity local install writes to .agents/ canonical dir (#791)', ()
'.agent/ must not be created by a fresh install (new installs use .agents/)');
});
test('global antigravity install still writes to ~/.gemini/antigravity (unchanged)', () => {
test('global antigravity install writes skills/agents to ~/.gemini/config, runtime files to the configHome (#3738)', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ag-global-'));
const savedHome = process.env.HOME;
const savedUserProfile = process.env.USERPROFILE;
@@ -1332,13 +1332,39 @@ describe('antigravity local install writes to .agents/ canonical dir (#791)', ()
result.configDir.startsWith(homeDir),
`global antigravity install must go under HOME, got: ${result.configDir}`,
);
// #3738: Antigravity scans ~/.gemini/config for machine-local discovery,
// so skills and agents install under the global layout's home override…
const configRoot = path.join(homeDir, '.gemini', 'config');
assert.ok(
fs.existsSync(path.join(result.configDir, 'skills')),
'global antigravity install must create skills/ under ~/.gemini/antigravity',
fs.existsSync(path.join(configRoot, 'skills', 'gsd-help', 'SKILL.md')),
'global antigravity install must create gsd-* skills under ~/.gemini/config/skills',
);
assert.ok(
fs.existsSync(path.join(configRoot, 'agents')),
'global antigravity install must create agents/ under ~/.gemini/config',
);
assert.ok(
!fs.existsSync(path.join(result.configDir, 'skills')),
'no skills/ may be created under the configHome (~/.gemini/antigravity) — AGY does not scan it (#3738)',
);
assert.ok(
!fs.existsSync(path.join(result.configDir, 'agents')),
'no agents/ may be created under the configHome (~/.gemini/antigravity) (#3738)',
);
assert.ok(
!fs.existsSync(path.join(homeDir, '.agents')),
'.agents/ must NOT be created by a global install (global path is ~/.gemini/antigravity)',
'.agents/ must NOT be created by a global install (global skills path is ~/.gemini/config)',
);
// #3738 review finding 1: the manifest must record the agents surface at
// its ACTUAL install root — writeManifest resolves the agents-kind home
// override, so drift detection sees the files AGY reads.
const manifestPath = path.join(result.configDir, 'gsd-file-manifest.json');
assert.ok(fs.existsSync(manifestPath), 'global install must write the manifest');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const agentKeys = Object.keys(manifest.files).filter((k) => k.startsWith('agents/'));
assert.ok(
agentKeys.length > 0,
`manifest must track agents/ at the home-override root, got keys: ${Object.keys(manifest.files).slice(0, 5).join(', ')}`,
);
} finally {
if (savedHome === undefined) delete process.env.HOME;
@@ -6473,6 +6499,10 @@ describe('install.js --skills-root', () => {
// #2088 (ADR-1239 upgrade 3): Codex skills resolve to the canonical
// $HOME/.agents/skills root (skills-kind home override), not $CODEX_HOME/skills.
{ runtime: 'codex', expected: path.join(os.homedir(), '.agents', 'skills') },
// #3738: Antigravity global discovery scans ~/.gemini/config/ — skills resolve
// to the skills-kind home override, not the configHome (~/.gemini/antigravity)
// that still holds settings.json and the gsd-core runtime files.
{ runtime: 'antigravity', expected: path.join(os.homedir(), '.gemini', 'config', 'skills') },
{ runtime: 'copilot', expected: path.join(os.homedir(), '.copilot', 'skills') },
{ runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') },
{ runtime: 'trae', expected: path.join(os.homedir(), '.trae', 'skills') },
@@ -6515,6 +6545,8 @@ describe('#3024: gsd-tools query skills-root', () => {
const CASES = [
{ runtime: 'claude', expected: path.join(os.homedir(), '.claude', 'skills') },
{ runtime: 'codex', expected: path.join(os.homedir(), '.agents', 'skills') },
// #3738: the query surface must agree with install.js --skills-root above.
{ runtime: 'antigravity', expected: path.join(os.homedir(), '.gemini', 'config', 'skills') },
{ runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') },
];

View File

@@ -0,0 +1,296 @@
'use strict';
/**
* TDD tests for installer migration 010:
* 2026-08-26-antigravity-retire-confighome-artifacts (#3738)
*
* Antigravity scans ~/.gemini/config/{skills,agents} for machine-local
* discovery; the configHome (~/.gemini/antigravity{,-ide,-cli}) is not
* scanned, so pre-#3738 installs placed skills and agents where the runtime
* silently ignored them. Since #3738 the global layout installs both kinds
* under the .gemini/config home override. This migration converges an
* existing install: managed files under the configHome's skills/ and agents/
* are removed (or backed up if locally modified), unmanifested files are
* preserved, and now-empty container directories are retired. GLOBAL scope
* only — the local .agents workspace surface is live and must be untouched.
*
* Coverage:
* 1. only manifested, unmodified skills/ + agents/ -> both trees gone
* 2. a manifested skill locally modified -> backed up, not silently deleted
* 3. an unmanifested user agent (non-gsd AND gsd-prefixed) -> file AND parent directory preserved
* 4. a non-gsd-prefixed entry under skills/ -> untouched, skills/ preserved
* 5. claude install with a populated skills/ -> completely untouched (independence)
* 6. local scope -> no actions at all
* 7. running the migration twice on case 1 -> second run is a clean no-op
* 8. a symlink under skills/ pointing outside configDir -> not followed, not deleted through
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const migration = require('../gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs');
const {
classifyArtifact: realClassifyArtifact,
readInstallManifest,
planInstallerMigrations,
applyInstallerMigrationPlan,
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
const { cleanup, createTempDir } = require('./helpers.cjs');
function writeFile(root, relPath, content) {
const fullPath = path.join(root, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
function writeManifest(root, files) {
fs.writeFileSync(
path.join(root, 'gsd-file-manifest.json'),
JSON.stringify(
{
version: '1.11.0',
timestamp: '2026-08-26T00:00:00.000Z',
mode: 'full',
files,
},
null,
2,
),
'utf8',
);
}
function hashOf(root, relPath) {
return crypto.createHash('sha256').update(fs.readFileSync(path.join(root, relPath))).digest('hex');
}
function makePlanCtx(configDir, runtime = 'antigravity', scope = 'global') {
const manifest = readInstallManifest(configDir);
return {
configDir,
runtime,
scope,
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
};
}
function runFullMigration(configDir, runtime = 'antigravity', scope = 'global') {
const plan = planInstallerMigrations({
configDir,
runtime,
scope,
migrations: [migration],
});
assert.deepEqual(plan.blocked, [], 'no action should ever require a prompt or be blocked as unknown');
if (plan.actions.length === 0) return plan;
applyInstallerMigrationPlan({ configDir, plan });
return plan;
}
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
describe('migration 010 metadata', () => {
test('exports a single migration object with the required authoring fields', () => {
assert.equal(typeof migration, 'object');
assert.equal(typeof migration.id, 'string');
assert.equal(migration.id, '2026-08-26-antigravity-retire-confighome-artifacts');
assert.equal(typeof migration.title, 'string');
assert.equal(typeof migration.description, 'string');
assert.equal(typeof migration.introducedIn, 'string');
assert.deepEqual(migration.runtimes, ['antigravity']);
assert.deepEqual(migration.scopes, ['global']);
assert.strictEqual(migration.destructive, true);
assert.equal(typeof migration.plan, 'function');
});
});
// ---------------------------------------------------------------------------
// 1. Only manifested, unmodified files -> both trees gone
// ---------------------------------------------------------------------------
describe('migration 010: fully managed configHome skills/ + agents/', () => {
test('removes manifested unmodified files and then the emptied directories', (t) => {
const dir = createTempDir('gsd-migration-010-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
writeFile(dir, 'skills/gsd-plan/refs/a.md', '# a\n');
writeFile(dir, 'agents/gsd-executor.md', '# executor\n');
writeManifest(dir, {
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
'skills/gsd-plan/refs/a.md': hashOf(dir, 'skills/gsd-plan/refs/a.md'),
'agents/gsd-executor.md': hashOf(dir, 'agents/gsd-executor.md'),
});
const plan = runFullMigration(dir);
assert.ok(plan.actions.some(a => a.type === 'remove-managed' && a.relPath === 'skills/gsd-help/SKILL.md'));
assert.ok(plan.actions.some(a => a.type === 'remove-managed' && a.relPath === 'agents/gsd-executor.md'));
assert.ok(!fs.existsSync(path.join(dir, 'skills')), 'emptied skills/ directory must be removed');
assert.ok(!fs.existsSync(path.join(dir, 'agents')), 'emptied agents/ directory must be removed');
});
test('second run on the converged install is a clean no-op', (t) => {
const dir = createTempDir('gsd-migration-010-idempotent-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
writeManifest(dir, {
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
});
runFullMigration(dir);
const plan = runFullMigration(dir);
assert.deepEqual(plan.actions, [], 'nothing left to retire');
});
});
// ---------------------------------------------------------------------------
// 2. A manifested file locally modified -> backed up, not silently deleted
// ---------------------------------------------------------------------------
describe('migration 010: locally modified manifested skill', () => {
test('backs up the modified file before removing it', (t) => {
const dir = createTempDir('gsd-migration-010-modified-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/gsd-help/SKILL.md', '# user-patched managed skill\n');
// Manifest records a DIFFERENT hash -> managed-modified.
writeManifest(dir, { 'skills/gsd-help/SKILL.md': 'a'.repeat(64) });
const plan = planInstallerMigrations({ configDir: dir, runtime: 'antigravity', scope: 'global', migrations: [migration] });
const fileAction = plan.actions.find((a) => a.relPath === 'skills/gsd-help/SKILL.md');
assert.ok(fileAction, 'expected an action for the modified file');
assert.equal(fileAction.type, 'backup-and-remove');
const result = applyInstallerMigrationPlan({ configDir: dir, plan });
assert.equal(fs.existsSync(path.join(dir, 'skills/gsd-help/SKILL.md')), false, 'the live modified copy is removed');
const journal = JSON.parse(fs.readFileSync(path.join(dir, result.journalRelPath), 'utf8'));
const journaledFileAction = journal.actions.find((a) => a.relPath === 'skills/gsd-help/SKILL.md');
assert.ok(journaledFileAction, 'expected the file action in the journal');
assert.ok(journaledFileAction.backupRelPath, 'expected a recorded backup path');
assert.equal(
fs.existsSync(path.join(dir, journaledFileAction.backupRelPath)),
true,
'the modified file must be recoverable from its backup',
);
assert.equal(
fs.readFileSync(path.join(dir, journaledFileAction.backupRelPath), 'utf8'),
'# user-patched managed skill\n',
);
});
});
// ---------------------------------------------------------------------------
// 3. Unmanifested user files -> preserved, parent dir preserved
// ---------------------------------------------------------------------------
describe('migration 010: unmanifested files under retired surfaces', () => {
test('preserves an unmanifested gsd-prefixed user agent and the agents/ dir', (t) => {
const dir = createTempDir('gsd-migration-010-unknown-');
t.after(() => cleanup(dir));
writeFile(dir, 'agents/gsd-my-own.md', '# user-written\n');
writeManifest(dir, {});
const plan = runFullMigration(dir);
assert.ok(fs.existsSync(path.join(dir, 'agents/gsd-my-own.md')), 'unmanifested file preserved');
assert.ok(fs.existsSync(path.join(dir, 'agents')), 'parent dir preserved while it holds a user file');
assert.ok(!plan.actions.some(a => a.relPath === 'agents/gsd-my-own.md'), 'no action planned against an unknown file');
});
});
// ---------------------------------------------------------------------------
// 4. Non-gsd entries untouched
// ---------------------------------------------------------------------------
describe('migration 010: non-gsd entries under skills/', () => {
test('never walks or removes non-gsd-prefixed entries', (t) => {
const dir = createTempDir('gsd-migration-010-nongsd-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/user-skill/SKILL.md', '# user skill\n');
writeManifest(dir, {
'skills/user-skill/SKILL.md': hashOf(dir, 'skills/user-skill/SKILL.md'),
});
runFullMigration(dir);
assert.ok(fs.existsSync(path.join(dir, 'skills/user-skill/SKILL.md')), 'non-gsd entry untouched even when manifested');
assert.ok(fs.existsSync(path.join(dir, 'skills')), 'skills/ preserved while it holds a non-gsd entry');
});
});
// ---------------------------------------------------------------------------
// 5. Independence: another runtime is never touched
// ---------------------------------------------------------------------------
describe('migration 010: runtime independence', () => {
test('a claude configDir with a populated skills/ tree is completely untouched', (t) => {
const dir = createTempDir('gsd-migration-010-claude-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
writeFile(dir, 'agents/gsd-executor.md', '# executor\n');
writeManifest(dir, {
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
'agents/gsd-executor.md': hashOf(dir, 'agents/gsd-executor.md'),
});
const plan = runFullMigration(dir, 'claude');
assert.deepEqual(plan.actions, [], 'claude never produces retirement actions');
assert.ok(fs.existsSync(path.join(dir, 'skills/gsd-help/SKILL.md')));
assert.ok(fs.existsSync(path.join(dir, 'agents/gsd-executor.md')));
});
});
// ---------------------------------------------------------------------------
// 6. Local scope -> no actions (the .agents workspace surface is live)
// ---------------------------------------------------------------------------
describe('migration 010: local scope is out of scope', () => {
test('plans no actions for a local-scope install even with gsd artifacts present', (t) => {
const dir = createTempDir('gsd-migration-010-local-');
t.after(() => cleanup(dir));
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
writeManifest(dir, {
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
});
const ctx = makePlanCtx(dir, 'antigravity', 'local');
assert.deepEqual(migration.plan(ctx), [], 'local scope must never retire these surfaces');
});
});
// ---------------------------------------------------------------------------
// 7. Symlink escape is not followed
// ---------------------------------------------------------------------------
describe('migration 010: symlinked entries are not followed', () => {
test('a symlinked gsd- dir under skills/ pointing outside configDir is not traversed', (t) => {
const outside = createTempDir('gsd-migration-010-outside-');
t.after(() => cleanup(outside));
const dir = createTempDir('gsd-migration-010-symlink-');
t.after(() => cleanup(dir));
writeFile(outside, 'SKILL.md', '# outside\n');
fs.mkdirSync(path.join(dir, 'skills'), { recursive: true });
fs.symlinkSync(outside, path.join(dir, 'skills/gsd-link'));
const ctx = makePlanCtx(dir);
const actions = migration.plan(ctx);
assert.ok(!actions.some(a => a.relPath.includes('gsd-link')), 'no action planned through the symlink');
assert.ok(fs.existsSync(outside), 'the symlink target is untouched');
});
});

View File

@@ -271,6 +271,14 @@ function assertFreshInstallContract(runtime, targetDir) {
// the skill dir under that sandboxed home instead of under targetDir.
const codexSandboxHome = path.join(path.dirname(targetDir), 'home');
assertHasGsdDirectory(path.join(codexSandboxHome, '.agents'), 'skills');
} else if (runtime === 'antigravity') {
// #3738: Antigravity's machine-local discovery scans ~/.gemini/config, so
// global skills install under the sandboxed home's .gemini/config root
// (kind `home` override), NOT `<config-dir>/skills`. Same sandboxed-HOME
// reasoning as the codex branch above.
const agySandboxHome = path.join(path.dirname(targetDir), 'home');
assertHasGsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'skills');
assertHasGsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'agents');
} else {
// Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs
// (assumption: Codex auto-discovers from workflows). That assumption
@@ -389,10 +397,20 @@ function assertFreshInstallContract(runtime, targetDir) {
}
if (contract.surface !== 'kimi-skills-agents' && contract.surface !== 'global-artifacts-noop' && contract.surface !== 'plugin-only') {
assert.ok(
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
`${runtime} full install should install agents`
);
if (runtime === 'antigravity') {
// #3738: antigravity agents install under the sandboxed home's
// .gemini/config root (see the flat-skills branch above), not targetDir.
const agySandboxHomeForAgents = path.join(path.dirname(targetDir), 'home');
assert.ok(
listDirNames(path.join(agySandboxHomeForAgents, '.gemini', 'config'), 'agents').some((name) => name.startsWith('gsd-')),
`${runtime} full install should install agents`
);
} else {
assert.ok(
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
`${runtime} full install should install agents`
);
}
}
assert.equal(

View File

@@ -1694,6 +1694,14 @@ test('shipped installer-migration checksums are locked to a committed baseline (
// itself retired via the new remove-empty-dir action.
'2026-08-07-pi-retire-reserved-hooks-dir':
'sha256:34264415b00e15e5a1691eae3db9bd24dca11e5c04d78358420a7a8adf115f9e',
// Migration 010 (NEW, added here per this test's own sanctioned "adding a new
// migration" case): retire Antigravity's configHome skills/agents surfaces
// (#3738). Antigravity scans ~/.gemini/config for global discovery, so
// pre-#3738 artifacts sat in a dir the runtime never read; since #3738 both
// kinds install under the .gemini/config home override and this migration
// converges upgraded installs. Global scope only — local .agents is live.
'2026-08-26-antigravity-retire-confighome-artifacts':
'sha256:52764c43418accdc26c89482b1a46bf9ebcde1c1d9a80e302971a7b068a3be3c',
};
const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs');

View File

@@ -1180,13 +1180,14 @@ describe('skills-kind destination parity: installer vs surface-apply (#2911)', (
test('registry home-override discrimination report (#2911)', () => {
const overrides = runtimesWithHomeOverride();
// Stated per the brief: at time of writing only codex/global has a `home`
// override, so this parity test discriminates on exactly one runtime/scope
// pair. This assertion documents that fact and fails loudly if the set
// ever changes shape unexpectedly empty (a discrimination-less parity
// test would be silently vacuous).
// Stated per the brief: at time of writing only codex/global had a `home`
// override; #3738 added antigravity/global (skills AND agents →
// ~/.gemini/config, the dir AGY scans). This parity test discriminates on
// exactly these runtime/scope pairs. This assertion documents that fact and
// fails loudly if the set ever changes shape unexpectedly empty (a
// discrimination-less parity test would be silently vacuous).
assert.ok(overrides.length > 0, 'expected at least one runtime/scope with a home override (codex/global)');
assert.deepStrictEqual(overrides, ['codex/global'], `home-override set changed — update this test's documentation. Found: ${overrides.join(', ')}`);
assert.deepStrictEqual(overrides, ['antigravity/global', 'codex/global'], `home-override set changed — update this test's documentation. Found: ${overrides.join(', ')}`);
});
for (const scope of ['global', 'local']) {