feat(#2097): migrate Augment onto EoS declarative adapter + settings.json MCP companion (ADR-1239)

Fold augment's runtime-literal conversion branches onto descriptor-driven
hostBehaviors and delete dead code:
- Site A (_applyRuntimeRewrites case 'augment'): the 4 ~/.augment dot-dir
  regexes now derive from getDirName('augment') via escapeRegExp (byte-
  identical; getDirName('augment')==='.augment') — no runtime literal.
- Site B (applyRuntimeContentRewritesForCommandsInPlace): the
  `if (runtime==='augment')` markdown-converter branch now reads
  runtime.hostBehaviors.commandBodyConverter and dispatches through a local
  COMMAND_BODY_CONVERTERS map (degrade-closed on unknown/absent name).
- Deleted dead `claudeToAugmentTools` map (zero refs; orphaned by ADR-1508
  single-sourcing) and the unreachable `else if (isAugment)` agent-conversion
  branch (augment ∈ _DESCRIPTOR_AGENTS_RUNTIMES → gated out upstream).
- Incidental orphan cleanup (no-defer): removed the equally-unreachable
  `else if (isTrae)` agent-conversion arm left behind by trae's already-merged
  migration #2094 (trae ∈ _DESCRIPTOR_AGENTS_RUNTIMES, same upstream gate).
  copilot/windsurf/codebuddy arms are removed by their own pending migrations.

UPGRADE 3 (transport:mcp): register the GSD companion MCP server in Augment's
settings.json under mcpServers.gsd (Augment hosts MCP in settings.json, not a
standalone file). mergeGsdMcpServerIntoSettings mutates the in-memory settings
object finishInstall already writes (gated on hostBehaviors.mcpCompanion===
'settings-json'); non-destructive + idempotent; symmetric uninstall removal.
settings.json is golden-excluded, so no golden change. UPGRADE 1 (named/
background dispatch) + UPGRADE 2 (settings-json hook bus, Claude dialect)
were already live in production — this adds tests exercising both.

Golden: byte-identical for all 16 runtimes (folds preserve regex behavior;
MCP lives in golden-excluded settings.json) — verified by a real double-install
tree diff. Tests: declarative-reference-augment (adapter/axes/fail-closed/
undocumented-sub-axes + source-grep guard scoped to conversion-logic branches)
+ augment-upgrades (dispatch negotiation, hook-bus live install, MCP add/
idempotent/preserve/uninstall). Matrix + connect-gsd-mcp-server + a stale
install-on-your-runtime hook-ownership claim corrected; changeset (Changed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-11 04:04:48 -04:00
parent 41d544248b
commit 17aa35fba7
10 changed files with 564 additions and 21 deletions

View File

@@ -0,0 +1,6 @@
---
type: Changed
pr: 2166
---
**Augment Code now installs through its capability descriptor, with a native MCP companion** — installing GSD into Augment registers the GSD companion server in Augment's `settings.json` `mcpServers` and drives command/skill/agent conversion from Augment's negotiated descriptor instead of hardcoded runtime special-cases. (#2097)

View File

@@ -2606,14 +2606,6 @@ function convertClaudeAgentToWindsurfAgent(content) {
// Augment uses a tool set similar to Cursor/Windsurf.
// Config lives in .augment/ (local) and ~/.augment/ (global).
const claudeToAugmentTools = {
Bash: 'launch-process',
Edit: 'str-replace-editor',
AskUserQuestion: null,
SlashCommand: null,
TodoWrite: 'add_tasks',
};
// #1675 (ADR-1508): the augment converter family below was a byte-identical
// duplicate of runtime-artifact-conversion.cjs:
// convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown,
@@ -7546,6 +7538,21 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
}
}
// #2097 UPGRADE 3 — Remove the MCP companion entry from settings.json for
// runtimes that host MCP there (Augment), symmetric to the mcp_config.json
// removal for Antigravity below. Only the GSD-owned mcpServers.gsd key is
// removed — any other user-configured MCP servers are preserved.
if (_hostBehaviors(runtime).mcpCompanion === 'settings-json' &&
settings.mcpServers && typeof settings.mcpServers === 'object' &&
settings.mcpServers.gsd !== undefined) {
delete settings.mcpServers.gsd;
if (Object.keys(settings.mcpServers).length === 0) {
delete settings.mcpServers;
}
settingsModified = true;
console.log(` ${green}✓${reset} Removed GSD MCP companion server from settings.json`);
}
if (settingsModified) {
writeSettings(settingsPath, settings);
removedCount++;
@@ -8078,6 +8085,27 @@ function configureAntigravityMcpConfig(isGlobal = true, configDir = null) {
console.log(` ${green}✓${reset} Configured Antigravity MCP companion server (gsd)`);
}
/**
* #2097 (ADR-1239 transport:mcp): register the GSD companion MCP server inside a
* runtime's settings.json (Augment hosts MCP in settings.json.mcpServers, unlike
* Antigravity's standalone mcp_config.json). Mutates the in-memory settings object
* that finishInstall already writes — non-destructive + idempotent: only sets
* mcpServers.gsd, preserving any user-defined servers (a user's own `gsd` override
* is respected — Hyrum's Law).
* @param {object} settings - the in-memory settings object finishInstall will write
*/
function mergeGsdMcpServerIntoSettings(settings) {
if (!settings.mcpServers || typeof settings.mcpServers !== 'object' || Array.isArray(settings.mcpServers)) {
settings.mcpServers = {};
}
if (settings.mcpServers.gsd === undefined) {
settings.mcpServers.gsd = {
command: 'npx',
args: ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server'],
};
}
}
/**
* Verify a directory exists and contains files
*/
@@ -9643,10 +9671,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
content = convertClaudeAgentToCopilotAgent(content, isGlobal);
} else if (isWindsurf) {
content = convertClaudeAgentToWindsurfAgent(content);
} else if (isAugment) {
content = convertClaudeAgentToAugmentAgent(content);
} else if (isTrae) {
content = convertClaudeAgentToTraeAgent(content);
} else if (isCodebuddy) {
content = convertClaudeAgentToCodebuddyAgent(content);
} else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
@@ -10918,6 +10942,12 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
mergeClaudePermissions(settings);
}
// #2097 UPGRADE 3 (transport:mcp): companion MCP server for runtimes that host
// MCP in settings.json (Augment). settings.json is golden-excluded, so no golden change.
if (_hostBehaviors(runtime).mcpCompanion === 'settings-json' && settings && plan.writesSharedSettings) {
mergeGsdMcpServerIntoSettings(settings);
}
// Write settings when runtime supports settings.json.
// #3002 CR: defense-in-depth — re-run validateHookFields right before
// serialization. The push-site guards above already skip null-command
@@ -11930,6 +11960,8 @@ module.exports = {
buildAntigravityAllowRules,
configureAntigravityPermissions,
configureAntigravityMcpConfig,
// #2097 UPGRADE 3 — Augment MCP companion (settings.json-hosted)
mergeGsdMcpServerIntoSettings,
claudeToCopilotTools,
convertCopilotToolName,
convertClaudeToCopilotContent,

View File

@@ -82,6 +82,10 @@
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": [],
"hostBehaviors": {
"commandBodyConverter": "convertClaudeToAugmentMarkdown",
"mcpCompanion": "settings-json"
},
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",

View File

@@ -1,7 +1,8 @@
# How to connect a host to the GSD companion MCP server
This guide shows you how to make a MCP-capable host (Claude Code, Codex,
OpenCode, VS Code, Antigravity CLI, Cursor, Cline, Hermes) drive GSD — run GSD
OpenCode, VS Code, Antigravity CLI, Cursor, Cline, Hermes, Augment Code) drive
GSD — run GSD
commands and read/write `.planning/` state — through the companion MCP server,
with no bespoke plugin.
@@ -28,6 +29,11 @@ host.
- **Claude Code / Codex / Cursor / Cline / Hermes** — under the host's
`mcpServers` object (project or user config).
- **Augment Code** — under the `mcpServers` block of its own
`settings.json` (not a standalone MCP config file, unlike Antigravity)
— global at `~/.augment/settings.json`, project-local at
`.augment/settings.json`. GSD's installer configures this entry
automatically (`--augment` installs).
- **VS Code** — in the workspace MCP servers list.
- **Antigravity** — under the `mcpServers` block of its standalone
`mcp_config.json` profile (not embedded in `settings.json`) — global at

View File

@@ -397,7 +397,7 @@ Qwen Code supports 15 hook events. GSD registers the following events automatica
npx @opengsd/gsd-core@latest --augment --global
```
Skills land in `~/.augment/skills/` and slash command definitions land in `~/.augment/commands/`. GSD installs skills, agents, and commands (`/gsd-phase`, `/gsd-ship`, etc.). No hook or statusline ownership.
Skills land in `~/.augment/skills/` and slash command definitions land in `~/.augment/commands/`. GSD installs skills, agents, and commands (`/gsd-phase`, `/gsd-ship`, etc.). GSD's managed lifecycle hooks are registered into Augment's own `settings.json` `hooks` block (Claude hook event dialect, covering session-start, tool-use, and phase-boundary events) — no statusline ownership. #2097 also registers the GSD companion MCP server under `settings.json`'s `mcpServers.gsd` (see [Connect a host to the GSD MCP server](connect-gsd-mcp-server.md)).
---

View File

@@ -334,6 +334,35 @@ Documentation gaps:
- dispatch.nested
- dispatch.maxDepth
**EoS migration status (#2097):** Folded onto descriptor-driven dispatch.
Augment already installed through the declarative adapter (nested-skill
artifact layout, `settings-json` hook surface, Claude hook event dialect), but
carried two remaining runtime-literal branches in
`src/runtime-artifact-conversion.cts`: the 4 `~/.augment`/`$HOME/.augment`
dot-dir rewrites in `_applyRuntimeRewrites`'s `case 'augment':` block are now
built from `getDirName('augment')` (dirName-derived, byte-identical) instead
of a hardcoded `.augment` literal, and the
`applyRuntimeContentRewritesForCommandsInPlace` command-body conversion
dispatch now reads `runtime.hostBehaviors.commandBodyConverter`
(`"convertClaudeToAugmentMarkdown"`) instead of a hardcoded
`runtime === 'augment'` branch. Two dead-code sites were also removed from
`bin/install.js`: the orphaned `claudeToAugmentTools` map (superseded by the
single-sourced converters per ADR-1508 / #1675) and the unreachable
`else if (isAugment) { content = convertClaudeAgentToAugmentAgent(content); }`
inline agent-conversion branch (augment has been on the descriptor-agents path
since `_DESCRIPTOR_AGENTS_RUNTIMES` was introduced, making that `if`/`else if`
arm dead). **UPGRADE 3 — MCP companion config**
(`mergeGsdMcpServerIntoSettings`) registers the `gsd` MCP server directly
inside the same `settings.json` `mcpServers` block GSD's own hook
registration already writes (Augment hosts MCP in `settings.json`, unlike
Antigravity's standalone `mcp_config.json`) — non-destructively preserving
any other user-configured `mcpServers` entries; uninstall removes only the
GSD-owned `gsd` entry. `settings.json` is golden-excluded
(`HOOK_CONFIG_FILES`), so this upgrade produces no golden fixture change.
Source-grep guard + fail-closed negotiation coverage is in
`tests/declarative-reference-augment.test.cjs`; the dispatch/hook-bus/MCP
upgrade coverage is in `tests/augment-upgrades.test.cjs`.
---
## qwen

View File

@@ -360,6 +360,10 @@ const capabilities = {
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": [],
"hostBehaviors": {
"commandBodyConverter": "convertClaudeToAugmentMarkdown",
"mcpCompanion": "settings-json"
},
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",
@@ -3953,6 +3957,10 @@ const runtimes = {
"writesSharedSettings": true,
"permissionWriter": null,
"extendedHookEvents": [],
"hostBehaviors": {
"commandBodyConverter": "convertClaudeToAugmentMarkdown",
"mcpCompanion": "settings-json"
},
"hostIntegration": {
"embeddingMode": "declarative",
"commandSurface": "slash-file",

View File

@@ -1107,6 +1107,11 @@ function convertClaudeToAugmentMarkdown(content) {
return converted;
}
// #2097 (ADR-1239): command-body converters selected by descriptor
// (runtime.hostBehaviors.commandBodyConverter) instead of a runtime-name
// branch. Degrade-closed: unknown/absent name → no conversion.
const COMMAND_BODY_CONVERTERS = { convertClaudeToAugmentMarkdown };
function getAugmentSkillAdapterHeader(skillName) {
return `<augment_skill_adapter>
## A. Skill Invocation
@@ -2442,19 +2447,24 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false, a
break;
}
case 'augment':
case 'augment': {
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
content = content.replace(/\.\/\.claude(?![\w-])/g, `./${dirName}`);
content = content.replace(/~\/\.augment\//g, pathPrefix);
content = content.replace(/\$HOME\/\.augment\//g, pathPrefix);
content = content.replace(/~\/\.augment(?![\w-])/g, normalizedPathPrefix);
content = content.replace(/\$HOME\/\.augment(?![\w-])/g, normalizedPathPrefix);
// #2097: dot-dir self-references (~/.augment/…) → resolved prefix,
// dirName-derived (no runtime literal). getDirName('augment') resolves
// to '.augment', so this is byte-identical to the prior hardcoded regexes.
const _dd = escapeRegExp(dirName);
content = content.replace(new RegExp('~/' + _dd + '/', 'g'), pathPrefix);
content = content.replace(new RegExp('\\$HOME/' + _dd + '/', 'g'), pathPrefix);
content = content.replace(new RegExp('~/' + _dd + '(?![\\w-])', 'g'), normalizedPathPrefix);
content = content.replace(new RegExp('\\$HOME/' + _dd + '(?![\\w-])', 'g'), normalizedPathPrefix);
content = processAttribution(content, attribution);
break;
}
case 'trae':
content = content.replace(/~\/\.claude\//g, pathPrefix);
@@ -2631,8 +2641,11 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal, attribution);
if (runtime === 'augment') {
content = convertClaudeToAugmentMarkdown(content);
// #2097 (ADR-1239): descriptor-driven — commandBodyConverter name comes
// from runtime.hostBehaviors instead of a hardcoded runtime-name branch.
const _cmdConv = _hostBehaviors(runtime).commandBodyConverter;
if (_cmdConv && COMMAND_BODY_CONVERTERS[_cmdConv]) {
content = COMMAND_BODY_CONVERTERS[_cmdConv](content);
}
fs.writeFileSync(path.join(tempDir, entry.name), content);
}

View File

@@ -0,0 +1,243 @@
'use strict';
/**
* Augment Code capability UPGRADES — ADR-1239 / #2097 (EoS/augment migration).
*
* Mirrors tests/antigravity-upgrades.test.cjs's structure. Augment already
* shipped as a declarative-CLI host prior to #2097 (nested-skill artifact
* layout, settings-json hook surface, Claude hook event dialect, dispatch
* namedDispatch/background/subagentToolkit:'full'), so UPGRADE 1 (dispatch)
* and UPGRADE 2 (hook-bus) below are REGRESSION locks on that pre-existing
* wiring, not new capabilities. UPGRADE 3 (MCP companion) IS new: #2097 adds
* `mergeGsdMcpServerIntoSettings`, registering the GSD companion MCP server
* inside Augment's own settings.json `mcpServers` block (Augment hosts MCP
* there, unlike Antigravity's standalone mcp_config.json) — non-destructively
* appending GSD's own entry while preserving any other user-configured
* `mcpServers` entries. Uninstall removes only the GSD-owned `gsd` entry.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const { runMinimalInstall, installerEnv, INSTALL_SCRIPT } = require('./helpers/install-shared.cjs');
const { cleanup } = require('./helpers.cjs');
const {
mergeGsdMcpServerIntoSettings,
} = require('../bin/install.js');
const { negotiateHostCapabilities } = require('../gsd-core/bin/lib/host-integration.cjs');
const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs');
const AUGMENT_CAP = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'augment', 'capability.json'), 'utf8'),
);
// ---------------------------------------------------------------------------
// UPGRADE 1: dispatch — descriptor + negotiation regression lock
// ---------------------------------------------------------------------------
test('capabilities/augment/capability.json declares dispatch.namedDispatch/background/subagentToolkit for full subagent support (UPGRADE 1)', () => {
const dispatch = AUGMENT_CAP.runtime.hostIntegration.dispatch;
assert.equal(dispatch.namedDispatch, true);
assert.equal(dispatch.background, true);
assert.equal(dispatch.subagentToolkit, 'full');
});
test('negotiateHostCapabilities does NOT flatten dispatch for augment — namedDispatch/background/subagentToolkit survive negotiation (UPGRADE 1)', () => {
const { effective } = negotiateHostCapabilities(AUGMENT_CAP.runtime.hostIntegration);
assert.equal(effective.dispatch.namedDispatch, true,
'documented namedDispatch:true must survive negotiation unflattened');
assert.equal(effective.dispatch.background, true,
'documented background:true must survive negotiation unflattened (not capped to false)');
assert.equal(effective.dispatch.subagentToolkit, 'full',
'documented subagentToolkit:full must survive negotiation unflattened');
});
// ---------------------------------------------------------------------------
// UPGRADE 2: hook-bus — live install proves the settings.json hook surface
// (SessionStart/PostToolUse/PreToolUse) is actually wired for augment.
// ---------------------------------------------------------------------------
for (const scope of ['global', 'local']) {
test(`augment --${scope}: settings.json contains GSD's managed hook entries (UPGRADE 2)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'augment', scope });
t.after(() => cleanup(root));
const settingsPath = path.join(configDir, 'settings.json');
assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.ok(settings.hooks && typeof settings.hooks === 'object', 'settings.hooks must be an object');
// Augment's hookEvents dialect is 'claude' (capabilities/augment/capability.json),
// so it gets the Claude-dialect event names (PostToolUse/PreToolUse), not
// antigravity's gemini-dialect (AfterTool/BeforeTool).
assert.ok(Array.isArray(settings.hooks.SessionStart) && settings.hooks.SessionStart.length > 0,
'settings.hooks.SessionStart must be registered');
assert.ok(Array.isArray(settings.hooks.PostToolUse) && settings.hooks.PostToolUse.length > 0,
'settings.hooks.PostToolUse must be registered');
assert.ok(Array.isArray(settings.hooks.PreToolUse) && settings.hooks.PreToolUse.length > 0,
'settings.hooks.PreToolUse must be registered');
const hasGsdCommand = (entries) => entries.some(
(entry) => entry && Array.isArray(entry.hooks) &&
entry.hooks.some((h) => h && typeof h.command === 'string' && h.command.includes('gsd-')),
);
assert.ok(hasGsdCommand(settings.hooks.SessionStart), 'SessionStart must reference a gsd- managed hook');
assert.ok(hasGsdCommand(settings.hooks.PostToolUse), 'PostToolUse must reference a gsd- managed hook');
assert.ok(hasGsdCommand(settings.hooks.PreToolUse), 'PreToolUse must reference a gsd- managed hook');
});
}
// ---------------------------------------------------------------------------
// UPGRADE 3: MCP companion config — direct-unit idempotency + preservation
// (mirrors antigravity's configureAntigravityMcpConfig unit tests exactly,
// scoped to the in-memory settings object mergeGsdMcpServerIntoSettings mutates).
// ---------------------------------------------------------------------------
test('mergeGsdMcpServerIntoSettings adds mcpServers.gsd with command "npx" and args including gsd-mcp-server + PACKAGE_NAME', () => {
const settings = {};
mergeGsdMcpServerIntoSettings(settings);
assert.ok(settings.mcpServers && settings.mcpServers.gsd, 'mcpServers.gsd must be present');
assert.equal(settings.mcpServers.gsd.command, 'npx');
assert.deepEqual(settings.mcpServers.gsd.args, ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server']);
});
test('mergeGsdMcpServerIntoSettings is idempotent — a second call does not clobber an existing gsd entry', () => {
const settings = {};
mergeGsdMcpServerIntoSettings(settings);
// Simulate a user hand-edit of the gsd entry after install.
settings.mcpServers.gsd.args.push('--custom-flag');
mergeGsdMcpServerIntoSettings(settings);
assert.ok(settings.mcpServers.gsd.args.includes('--custom-flag'),
"a user-owned gsd override is never clobbered (Hyrum's Law)");
assert.equal(Object.keys(settings.mcpServers).length, 1, 'exactly one mcpServers entry (gsd)');
});
test('mergeGsdMcpServerIntoSettings preserves a pre-existing unrelated mcpServers entry', () => {
const settings = {
mcpServers: {
'my-own-server': { command: 'my-tool', args: ['--flag'] },
},
};
mergeGsdMcpServerIntoSettings(settings);
assert.deepEqual(settings.mcpServers['my-own-server'], { command: 'my-tool', args: ['--flag'] });
assert.ok(settings.mcpServers.gsd);
assert.equal(Object.keys(settings.mcpServers).length, 2, 'both entries present');
});
test('mergeGsdMcpServerIntoSettings recovers a corrupted (non-object) mcpServers field', () => {
const settingsArrayCase = { mcpServers: ['not', 'an', 'object'] };
mergeGsdMcpServerIntoSettings(settingsArrayCase);
assert.ok(settingsArrayCase.mcpServers.gsd, 'array mcpServers must be recovered to an object with gsd set');
const settingsStringCase = { mcpServers: 'corrupted-string' };
mergeGsdMcpServerIntoSettings(settingsStringCase);
assert.ok(settingsStringCase.mcpServers.gsd, 'non-object mcpServers must be recovered to an object with gsd set');
});
// ---------------------------------------------------------------------------
// UPGRADE 3: MCP companion config — live install (both scopes) proves the
// end-to-end wiring through finishInstall, not just the unit function.
// ---------------------------------------------------------------------------
for (const scope of ['global', 'local']) {
test(`augment --${scope}: settings.json registers the gsd MCP companion (UPGRADE 3)`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'augment', scope });
t.after(() => cleanup(root));
const settingsPath = path.join(configDir, 'settings.json');
assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.ok(settings.mcpServers && settings.mcpServers.gsd, 'mcpServers.gsd must be present');
assert.equal(settings.mcpServers.gsd.command, 'npx');
assert.deepEqual(settings.mcpServers.gsd.args, ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server']);
});
}
test('augment --global: reinstalling does not duplicate or clobber the gsd MCP companion entry (live-install idempotency)', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-augment-mcp-idem-'));
t.after(() => cleanup(root));
const args = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root];
const env = installerEnv({ HOME: root, USERPROFILE: root });
const first = spawnSync(process.execPath, args, { encoding: 'utf8', env });
assert.strictEqual(first.status, 0, `first install failed: ${first.stderr}`);
const settingsPath = path.join(root, 'settings.json');
const afterFirst = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.ok(afterFirst.mcpServers.gsd);
// Simulate a user hand-edit of the gsd entry between installs.
afterFirst.mcpServers.gsd.args.push('--custom-flag');
fs.writeFileSync(settingsPath, JSON.stringify(afterFirst, null, 2) + '\n');
const second = spawnSync(process.execPath, args, { encoding: 'utf8', env });
assert.strictEqual(second.status, 0, `second install failed: ${second.stderr}`);
const afterSecond = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.equal(Object.keys(afterSecond.mcpServers).length, 1, 'reinstall must not duplicate the gsd entry');
assert.ok(afterSecond.mcpServers.gsd.args.includes('--custom-flag'),
"a user-owned gsd override is never clobbered across reinstall (Hyrum's Law)");
});
test('augment --global: installing preserves a pre-existing unrelated mcpServers entry (live-install preservation)', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-augment-mcp-preserve-'));
fs.mkdirSync(root, { recursive: true });
t.after(() => cleanup(root));
// Pre-seed settings.json with a user's own MCP server BEFORE install runs.
fs.writeFileSync(path.join(root, 'settings.json'), JSON.stringify({
mcpServers: {
other: { command: 'my-tool', args: ['--flag'] },
},
}, null, 2));
const args = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root];
const result = spawnSync(process.execPath, args, {
encoding: 'utf8',
env: installerEnv({ HOME: root, USERPROFILE: root }),
});
assert.strictEqual(result.status, 0, `install failed: ${result.stderr}`);
const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8'));
assert.deepEqual(settings.mcpServers.other, { command: 'my-tool', args: ['--flag'] },
"the user's pre-existing mcpServers entry must be preserved");
assert.ok(settings.mcpServers.gsd, 'the gsd companion entry must also be present');
});
// ---------------------------------------------------------------------------
// Uninstall — symmetric cleanup for the MCP companion entry.
// ---------------------------------------------------------------------------
test('augment --global uninstall removes only the GSD-owned mcpServers.gsd entry, preserving user data', (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-augment-uninstall-'));
t.after(() => cleanup(root));
const env = installerEnv({ HOME: root, USERPROFILE: root });
const installArgs = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root];
const installResult = spawnSync(process.execPath, installArgs, { encoding: 'utf8', env });
assert.strictEqual(installResult.status, 0, `install failed: ${installResult.stderr}`);
// Seed user-owned data alongside GSD's contributions, post-install.
const settingsPath = path.join(root, 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
settings.mcpServers['my-own-server'] = { command: 'my-tool', args: [] };
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
const uninstallArgs = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root, '--uninstall'];
const uninstallResult = spawnSync(process.execPath, uninstallArgs, { encoding: 'utf8', env });
assert.strictEqual(uninstallResult.status, 0, `uninstall failed: ${uninstallResult.stderr}`);
const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.equal(settingsAfter.mcpServers && settingsAfter.mcpServers.gsd, undefined, 'gsd MCP entry removed');
assert.deepEqual(settingsAfter.mcpServers['my-own-server'], { command: 'my-tool', args: [] },
'user MCP server preserved');
});

View File

@@ -0,0 +1,202 @@
// allow-test-rule: structural-regression-guard — the descriptor-migration contract (no `runtime === 'augment'` / `isAugment`-as-conversion-branch dispatch logic left in the folded modules) is a property of the source text itself, so a source-grep is the only faithful check (#2097, mirrors tests/declarative-reference-antigravity.test.cjs #2096 AC2 guard).
'use strict';
/**
* Declarative reference host coverage — Augment Code (ADR-1239 / #2097
* EoS/augment migration).
*
* Augment is already a declarative-CLI host (nested-skill artifact layout,
* settings-json hook surface, Claude hook event dialect). #2097 folds its two
* remaining runtime-literal branches in src/runtime-artifact-conversion.cts
* onto descriptor-driven dispatch (runtime.hostBehaviors.commandBodyConverter)
* and deletes 2 dead-code sites in bin/install.js — mirroring the Antigravity
* migration (#2096) structure without duplicating its Antigravity-specific
* (GEMINI.md / reviewerCli / noPathRewrite) assertions.
*
* UPGRADE 3 (MCP companion, settings.json-hosted) live-install coverage is in
* tests/augment-upgrades.test.cjs — not duplicated here.
*/
const { test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const {
profileOf,
negotiateHostCapabilities,
UNDOCUMENTED,
} = require('../gsd-core/bin/lib/host-integration.cjs');
const { createDeclarativeAdapter } = require('../gsd-core/bin/lib/adapter-declarative.cjs');
const { cleanup } = require('./helpers.cjs');
const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs');
const DESC = path.join(__dirname, '..', 'capabilities', 'augment', 'capability.json');
const AUGMENT_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8'));
const AUGMENT_AXES = AUGMENT_CAP.runtime.hostIntegration;
// hooks/dist is gitignored and built (mirrors golden-install-parity harness).
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
});
test('Augment classifies as the declarative-cli profile (profileOf)', () => {
const desc = JSON.parse(fs.readFileSync(DESC, 'utf8'));
const axes = desc.runtime.hostIntegration;
assert.ok(axes && axes.embeddingMode, 'augment descriptor declares hostIntegration axes');
assert.equal(profileOf(axes), 'declarative-cli', 'Augment is a declarative-CLI host');
});
test('the public declarative adapter classifies Augment as a declarative host', () => {
const adapter = createDeclarativeAdapter({ runtime: 'augment' });
assert.equal(adapter.kind, 'declarative');
assert.equal(adapter.runtime, 'augment');
assert.equal(typeof adapter.install, 'function');
assert.equal(typeof adapter.uninstall, 'function');
});
test('a real Augment install emits a gsd command/skill surface (invocable)', () => {
const { configDir, root } = runMinimalInstall({ runtime: 'augment', scope: 'global' });
try {
const files = walk(configDir);
assert.ok(files.length > 0, 'install must emit artifacts');
const gsdSurface = files.filter((f) => /gsd/i.test(path.relative(configDir, f)));
assert.ok(gsdSurface.length > 0,
'install must emit a gsd command/skill surface (declarative reference)');
} finally {
cleanup(root);
}
});
// ---------------------------------------------------------------------------
// #2097 EoS/augment — fail-closed negotiation + folded dispatch sub-axes
// (mirrors the antigravity/kimi reference tests).
// ---------------------------------------------------------------------------
test('negotiateHostCapabilities never throws for augment, even fully corrupted', () => {
assert.doesNotThrow(() => negotiateHostCapabilities({}));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, embeddingMode: UNDOCUMENTED }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, embeddingMode: 'future-unknown' }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, dispatch: 'corrupted-not-an-object' }));
assert.doesNotThrow(() => negotiateHostCapabilities({ ...AUGMENT_AXES, dispatch: { ...AUGMENT_AXES.dispatch, maxDepth: 'not-a-number' } }));
});
// AC-style proof: the 3 still-`undocumented` dispatch sub-axes (nested/
// maxDepth/backgroundDispatch) must degrade to the most-restrictive KNOWN
// value, never their optimistic value — mirrors antigravity's equivalent
// test, but Augment documents namedDispatch/background (unlike antigravity),
// so only 3 sub-axes are undocumented here, not 4. Real values confirmed via:
// node -e "const {negotiateHostCapabilities}=require('./gsd-core/bin/lib/host-integration.cjs');
// const cap=require('./capabilities/augment/capability.json');
// console.log(negotiateHostCapabilities(cap.runtime.hostIntegration).effective.dispatch)"
// -> { namedDispatch:true, nested:false, maxDepth:0, background:true, subagentToolkit:'full', backgroundDispatch:false }
test("augment's 3 still-undocumented dispatch sub-axes (nested/maxDepth/backgroundDispatch) degrade to the most-restrictive known value, not their optimistic value", () => {
// Sanity: the descriptor itself still declares these 3 as the undocumented
// sentinel, while namedDispatch/background/subagentToolkit are documented.
assert.equal(AUGMENT_AXES.dispatch.nested, 'undocumented');
assert.equal(AUGMENT_AXES.dispatch.maxDepth, 'undocumented');
assert.equal(AUGMENT_AXES.dispatch.backgroundDispatch, 'undocumented');
assert.equal(AUGMENT_AXES.dispatch.namedDispatch, true, 'sanity: namedDispatch is documented, not part of the undocumented set');
assert.equal(AUGMENT_AXES.dispatch.background, true, 'sanity: background is documented, not part of the undocumented set');
assert.equal(AUGMENT_AXES.dispatch.subagentToolkit, 'full', 'sanity: subagentToolkit is documented, not part of the undocumented set');
const { effective, warnings } = negotiateHostCapabilities(AUGMENT_AXES);
assert.equal(effective.dispatch.nested, false, 'undocumented nested must degrade to false, never true');
assert.equal(effective.dispatch.maxDepth, 0, 'undocumented maxDepth must degrade to 0, never -1/unbounded');
assert.equal(effective.dispatch.backgroundDispatch, false, 'undocumented backgroundDispatch must degrade to false, never true');
// namedDispatch/background/subagentToolkit are documented — they are
// trusted and survive negotiation unchanged, in contrast to the 3
// undocumented sub-axes above. Augment's negotiation must NOT flatten
// dispatch the way a fully-undocumented descriptor (e.g. antigravity's
// namedDispatch) would.
assert.equal(effective.dispatch.namedDispatch, true, "documented 'true' namedDispatch is trusted, unlike the undocumented sub-axes");
assert.equal(effective.dispatch.background, true, "documented 'true' background is trusted — not capped to false, since namedDispatch did not degrade closed");
assert.equal(effective.dispatch.subagentToolkit, 'full', "documented 'full' subagentToolkit is trusted, unlike the undocumented sub-axes");
for (const axis of ['nested', 'backgroundDispatch']) {
assert.ok(
warnings.some((w) => w.includes(`dispatch.${axis}`) && w.includes('undocumented')),
`a warning must be raised for the undocumented dispatch.${axis} axis`,
);
}
assert.ok(
warnings.some((w) => w.includes('dispatch.maxDepth')),
'a warning must be raised for the undocumented dispatch.maxDepth axis (reported as missing/non-number)',
);
});
// ---------------------------------------------------------------------------
// #2097 AC2-equivalent — the hardcoded conversion-dispatch branches folded by
// this migration are retired from the folded modules.
//
// Unlike antigravity (#2096), where `isAntigravity` was eliminated from
// bin/install.js entirely, Augment's `isAugment` flag legitimately remains
// (destructured from runtimeFlags() and used for non-conversion verification
// logic — e.g. counting installed commands/ files). So this guard does NOT
// assert zero `isAugment` occurrences (that would be a false failure); it
// asserts no `isAugment` occurrence gates a call to an Augment *converter*
// function (the exact shape of the dead branch this migration deleted:
// `else if (isAugment) { content = convertClaudeAgentToAugmentAgent(content); }`).
// The switch `case 'augment':` label in _applyRuntimeRewrites is intentionally
// NOT flagged — it does plain dirName-derived path-prefix rewriting (same as
// the still-`case`-labeled trae/codebuddy/cursor/windsurf branches), not
// conversion-function dispatch, and antigravity's own guard tolerates its
// `case 'antigravity':` label for the identical reason (its regex only
// matches `runtime === 'x'`, never a `case` label).
// ---------------------------------------------------------------------------
test('no `runtime === "augment"` string-equality branch remains in the descriptor-migrated modules', () => {
const strip = (src) => src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\r\n]*/g, '')
.replace(/`[^`]*`/g, '');
const repoRoot = path.join(__dirname, '..');
const files = [
path.join(repoRoot, 'bin', 'install.js'),
path.join(repoRoot, 'src', 'runtime-artifact-conversion.cts'),
];
for (const file of files) {
const src = fs.readFileSync(file, 'utf8');
const stripped = strip(src);
const eqOffenders = stripped.match(/runtime\s*[!=]==\s*'augment'/g) || [];
assert.deepEqual(eqOffenders, [],
`no hardcoded runtime==='augment' branch may remain in ${path.relative(repoRoot, file)}; found: ${eqOffenders.join(', ')}`);
}
});
test('no `isAugment` occurrence gates a call to an Augment converter function (the deleted dead-code shape)', () => {
const converterNames = [
'convertClaudeAgentToAugmentAgent',
'convertClaudeToAugmentMarkdown',
'convertClaudeCommandToAugmentSkill',
'convertSlashCommandsToAugmentSkillMentions',
];
const converterCallPattern = new RegExp(`(${converterNames.join('|')})\\s*\\(`);
const file = path.join(__dirname, '..', 'bin', 'install.js');
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
const offenders = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Only condition-position occurrences count (`if (isAugment)` / `else if
// (isAugment)`), not the runtimeFlags() destructure or unrelated reads.
if (!/(?:if|else if)\s*\(\s*isAugment\s*\)/.test(line)) continue;
const window = lines.slice(i, i + 4).join('\n');
if (converterCallPattern.test(window)) {
offenders.push(`line ${i + 1}: ${line.trim()}`);
}
}
assert.deepEqual(offenders, [],
`no isAugment-gated call to an Augment converter may remain in bin/install.js; found: ${offenders.join(' | ')}`);
});
test('legitimate isAugment destructure/enumeration sites survive (not eliminated, unlike isAntigravity)', () => {
const file = path.join(__dirname, '..', 'bin', 'install.js');
const src = fs.readFileSync(file, 'utf8');
assert.ok(/isAugment/.test(src), 'isAugment must still be destructured from runtimeFlags() for non-conversion uses');
assert.ok(/_DESCRIPTOR_AGENTS_RUNTIMES\s*=\s*new Set\(\[[^\]]*'augment'/.test(src),
'augment must remain in _DESCRIPTOR_AGENTS_RUNTIMES (descriptor-driven agent layout)');
});