feat(#1575): complete agent-converter descriptor cutover for copilot/antigravity + surface path parity

- Teach applySurface to build agentCtx (pathPrefix + attribution) and pass it
  to kind.stage() for agents kind, mirroring createRuntimeArtifactInstallPlan
  (ADR-1235 §1). Surface-path agents now receive path-rewrite + attribution +
  converter + normalize, matching install output byte-for-byte.

- Pass skills:'*' sentinel for agents staging when no surface state modifications
  exist, so ALL agents are staged (not just those referenced by _calls_agents_).

- Declare converted agents kind in copilot and antigravity capability.json;
  add to _DESCRIPTOR_AGENTS_RUNTIMES in bin/install.js.

- Handle copilot .agent.md filename rename in both _copyStaged (install path)
  and _syncGsdDir (surface path).

- Ship golden-parity harness (ADR-1235 §0): tests/issue-1575-agent-descriptor-
  parity.test.cjs asserts applySurface output is byte-identical to
  installRuntimeArtifacts for all 7 descriptor-driven runtimes, plus stale-
  cleanup convergence and prune data-loss coverage.

- Update ADR-1235 with cutover progress.

Cline remains deferred (rules-only local branch + local/global complication).
This commit is contained in:
Tom Boucher
2026-07-06 10:04:41 -04:00
parent d78b29d6c2
commit d671171698
9 changed files with 336 additions and 40 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 0
---
**`/gsd:surface` and `--materialize` now produce byte-identical agent output to a fresh install** — surface-path agents for descriptor-driven runtimes (cursor, windsurf, augment, trae, codebuddy, copilot, antigravity) now receive the same path-prefix rewrite, Co-Authored-By attribution, runtime-specific conversion, and body normalization as the install path. Copilot and Antigravity agents are now installed via the descriptor-driven path (copilot agents get the `.agent.md` filename rename). Cline remains on the inline loop (rules-only local branch). (#1575)

View File

@@ -8992,9 +8992,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// (by installRuntimeArtifacts at line 8912), which also performs its own
// stale-file prune pass. The inline stale-removal + inline loop both skip them.
// Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
// cline is excluded: it takes a rules-only local branch and has a local/global
// complication that the descriptor-driven path does not handle correctly.
const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy']);
// #1575: copilot and antigravity cut over — copilot gets .agent.md filename
// rename via _copyStaged(runtime); antigravity uses scope-aware converter.
// cline remains excluded: rules-only local branch + local/global complication
// that the descriptor-driven path does not handle correctly.
const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity']);
// Always remove stale gsd-* agents first so re-installing with
// `--minimal` actually shrinks a previously-full install.

View File

@@ -35,6 +35,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAntigravityAgent"
}
],
"local": [
@@ -45,6 +53,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAntigravityAgent"
}
]
},

View File

@@ -29,6 +29,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCopilotAgent"
}
],
"local": [
@@ -39,6 +47,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCopilotAgent"
}
]
},

View File

@@ -80,6 +80,14 @@ Cross-cutting steps (a, b, c) are applied by the descriptor pipeline for the app
5. **Codex** — fold the `.toml` sidecar into the descriptor (or declare it an explicit companion artifact); the `.md` + `.toml` must both reach parity.
6. **Delete the inline loop** once every runtime is green; remove the now-dead `isKimi`/minimal special-casing that referenced it.
### Cutover progress (#1575)
- **Step 0 (parity harness):** shipped in `tests/issue-1575-agent-descriptor-parity.test.cjs`. Asserts `applySurface` output is byte-identical to `installRuntimeArtifacts` for all descriptor-driven runtimes. Covers stale-cleanup convergence (pre-existing legacy `.agent.md` pruned correctly).
- **Step 1 (trivial converters):** cursor, windsurf, augment, trae, codebuddy — install-path cutover complete (PR #1438); surface-path parity shipped (#1575: `applySurface` now builds `agentCtx` and passes it to `kind.stage()` for agents, applying path-rewrite + attribution + converter + normalize).
- **Step 2 (scope-aware):** copilot and antigravity — cutover complete (#1575: declared `agents` kind in `capability.json`, added to `_DESCRIPTOR_AGENTS_RUNTIMES`, copilot `.agent.md` rename handled in both `_copyStaged` and `_syncGsdDir`).
- **Cline:** deferred — rules-only local branch + local/global complication not handled by the descriptor-driven path.
- **Remaining:** steps 3–6 (config-reading, no-converter, codex, inline-loop deletion).
## Risks / trade-offs
- **Silent install regression** across ~15 runtimes is the dominant risk; the byte-for-byte golden gate is the mitigation, and per-runtime sequencing bounds the blast radius of any single step.

View File

@@ -97,6 +97,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAntigravityAgent"
}
],
"local": [
@@ -107,6 +115,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAntigravityAgent"
}
]
},
@@ -735,6 +751,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCopilotAgent"
}
],
"local": [
@@ -745,6 +769,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCopilotAgent"
}
]
},
@@ -3394,6 +3426,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAntigravityAgent"
}
],
"local": [
@@ -3404,6 +3444,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAntigravityAgent"
}
]
},
@@ -3888,6 +3936,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCopilotAgent"
}
],
"local": [
@@ -3898,6 +3954,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCopilotAgent"
}
]
},

View File

@@ -244,7 +244,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string
* - agents: write as-is (files already carry their own `gsd-` prefix).
* For kimi-agents kind: recursively copy generated YAML/prompt files.
*/
function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: string): void {
function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: string, runtime?: string): void {
// Defense-in-depth: verify destDir is within the install root even if the
// upstream assertDestWithinConfigHome check was somehow bypassed. This guards
// the actual write site against any future call-site drift.
@@ -302,8 +302,11 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
let destName: string;
if (kind.kind === 'agents') {
// Agent files already carry the gsd- prefix in the source dir
destName = entry.name;
// Agent files already carry the gsd- prefix in the source dir.
// #1575: copilot agents get .agent.md suffix (mirrors inline loop line ~9118).
destName = runtime === 'copilot'
? entry.name.replace(/\.md$/, '.agent.md')
: entry.name;
} else if (namespacedByDir) {
// Directory is the namespace; don't double-prefix the filename
destName = entry.name;
@@ -619,7 +622,7 @@ function installRuntimeArtifacts(
}
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
// Restore user-owned dirs after the prune+copy
for (const [dirName, snap] of toPreserve) {
@@ -629,7 +632,7 @@ function installRuntimeArtifacts(
// For non-skills kinds (commands, agents): no user content to preserve;
// just prune stale gsd-* entries and copy new ones.
_removeGsdEntries(dest, kind);
_copyStaged(item.sourceDir, dest, kind, configDir);
_copyStaged(item.sourceDir, dest, kind, configDir, runtime);
}
}
} finally {

View File

@@ -29,6 +29,7 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -55,11 +56,17 @@ const SURFACE_FILE_NAME = '.gsd-surface.json';
// Types
// ---------------------------------------------------------------------------
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
}
interface ArtifactKind {
kind: string;
destSubpath: string;
prefix: string;
stage: (resolvedProfile: { name: string; skills: Set<string> | '*'; agents: Set<string> }) => string;
stage: (resolvedProfile: { name: string; skills: Set<string> | '*'; agents: Set<string> }, agentCtx?: AgentCtx) => string;
}
interface Layout {
@@ -69,6 +76,12 @@ interface Layout {
kinds: ArtifactKind[];
}
interface ApplySurfaceOptions {
resolveAttribution?: (runtime: string) => string | null | undefined;
homedir?: () => string;
platform?: string;
}
// ---------------------------------------------------------------------------
// State IO
// ---------------------------------------------------------------------------
@@ -301,32 +314,49 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]
* Re-stage the active surface using the resolved layout.
* Iterates layout.kinds and syncs each artifact kind to its destination.
*/
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { name: string; skills: Set<string>; agents: Set<string> } {
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }, opts?: ApplySurfaceOptions): { name: string; skills: Set<string>; agents: Set<string> } {
if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) {
throw new TypeError('applySurface runtimeConfigDir must match layout.configDir');
}
const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
// Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
// so SKILL.md bodies reference the install target (pathPrefix), not the
// converter's default ~/.claude paths (#813). Delegated to the conversion
// module's deep seam (ADR-1508 / #1511 Phase 2) — no attribution resolver
// needed here (proven: Co-Authored-By never appears in staged content; see
// brief PROVEN KEY FACT). No getInstallExports() call required.
// #1615 adversarial review (PR #1622): commands kind was previously skipped,
// leaving raw @~/.claude/... references in Windsurf workflow bodies after a
// /gsd-surface profile change. Same gap affected any runtime with commands
// kinds (windsurf, opencode, kilo, cursor, augment, codebuddy, gemini).
//
// Asymmetry note: rewriteStagedSkillBodies mutates in place (returns void),
// but rewriteStagedCommandBodies copies to a fresh mkdtemp dir and returns
// its path (commands .md files are flat; mutating the staged source would
// corrupt the package source on full-profile runs). Caller MUST sync from
// the returned dir and clean it up.
// #1575: agents kind now mirrors createRuntimeArtifactInstallPlan — build
// agentCtx (pathPrefix + attribution) and pass it to kind.stage() so
// stageAgentsForRuntimeWithConverter applies the full inline-loop pipeline
// (pathRewrites -> attribution -> converter -> normalize). Without this,
// surface-path agents lack path-prefix rewrites and Co-Authored-By trailers,
// diverging from a fresh install.
const _homedirFn: () => string = opts?.homedir ?? (() => os.homedir());
const _resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
const _homeDir = _homedirFn().replace(/\\/g, '/');
const _isGlobal = (layout.scope ?? 'global') === 'global';
const _isOpencode = layout.runtime === 'opencode';
const _isWindowsHost = (opts?.platform ?? process.platform) === 'win32';
const _pathPrefix = runtimeArtifactConversion._computePathPrefix({ isGlobal: _isGlobal, isOpencode: _isOpencode, isWindowsHost: _isWindowsHost, resolvedTarget: _resolvedTarget, homeDir: _homeDir });
const _attribution = opts?.resolveAttribution ? opts.resolveAttribution(layout.runtime) : undefined;
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix: _pathPrefix, attribution: _attribution };
const tempDirsToClean: string[] = [];
// #1575: When the surface has no state modifications (no .gsd-surface.json or
// it has no disabled clusters / explicit changes), pass the '*' sentinel for
// agents staging so ALL agents are staged — matching the install path which
// uses { skills: '*' }. Without this, agents not referenced by any skill's
// _calls_agents_ manifest entry would be silently dropped from the surface path.
const _surfaceState = readSurface(layout.configDir);
const _hasSurfaceMods = !!_surfaceState && (
_surfaceState.disabledClusters.length > 0 ||
_surfaceState.explicitAdds.length > 0 ||
_surfaceState.explicitRemoves.length > 0
);
try {
for (const kind of layout.kinds) {
let staged: string = kind.stage(resolved);
let staged: string;
if (kind.kind === 'agents') {
const agentProfile = _hasSurfaceMods ? resolved : { ...resolved, skills: '*' as const };
staged = kind.stage(agentProfile, agentCtx);
} else {
staged = kind.stage(resolved);
}
if (kind.kind === 'skills') {
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
runtime: layout.runtime,
@@ -345,7 +375,7 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
}
}
const dest = assertDestWithinConfigHome(layout.configDir, kind.destSubpath);
_syncGsdDir(staged, dest, kind, skillManifest);
_syncGsdDir(staged, dest, kind, skillManifest, layout.runtime);
}
} finally {
for (const dir of tempDirsToClean) {
@@ -451,7 +481,7 @@ function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: s
* user-owned dirs. GSD-owned = stem in manifest; removal targets = in manifest AND
* not in staged set. User-owned (not in manifest) are always preserved.
*/
function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | string, manifest?: Map<string, string[]>): void {
function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | string, manifest?: Map<string, string[]>, runtime?: string): void {
if (!fs.existsSync(stagedDir)) return;
fs.mkdirSync(destDir, { recursive: true });
@@ -459,6 +489,11 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
const kindName = (typeof kind === 'string') ? kind : kind.kind;
const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
// #1575: copilot agents are renamed .md -> .agent.md at copy time, mirroring
// the inline agent loop in bin/install.js (line ~9118). Other runtimes keep
// the staged filename verbatim.
const isCopilotAgents = runtime === 'copilot' && kindName === 'agents';
if (kindName === 'skills') {
// Skills kind: work with directories, not files.
// Each staged entry is a directory named ${prefix}${stem}.
@@ -497,27 +532,23 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
const stagedFiles = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
const stagedDestNames = new Set<string>();
for (const file of stagedFiles) {
const destName = (kindName === 'agents' || namespacedByDir)
? file
: `${kindPrefix}${file.slice(0, -3)}.md`;
const destName = isCopilotAgents
? file.replace(/\.md$/, '.agent.md')
: (kindName === 'agents' || namespacedByDir)
? file
: `${kindPrefix}${file.slice(0, -3)}.md`;
fs.copyFileSync(path.join(stagedDir, file), path.join(destDir, destName));
stagedDestNames.add(destName);
}
// Prune stale GSD-owned files not in the staged set, preserving user-owned files
// (mirrors install's prefix-scoped _removeGsdEntries):
// - agents: only gsd-* are GSD-owned
// - agents: only gsd-* are GSD-owned (copilot: gsd-*.agent.md)
// - flat command dirs: only `${kindPrefix}`-prefixed are GSD-owned
// - namespaced command dirs: the whole dir is GSD-owned
//
// Manifest gate (#2018): when the manifest is empty/absent (e.g. an unresolvable
// install source root yields an empty staged dir), the staged set is untrustworthy.
// Skills are guarded by pruneSkillDirs' manifest-membership check; agents must be
// guarded here — skip the prune loop entirely so an empty manifest never deletes
// every gsd-* agent. Copying (above) still runs so genuinely new agents are added.
const shouldPruneAgents = !(kindName === 'agents' && (!manifest || manifest.size === 0));
if (shouldPruneAgents) {
for (const file of fs.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
for (const file of fs.readdirSync(destDir).filter(f => f.endsWith('.md') || (isCopilotAgents && f.endsWith('.agent.md')))) {
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix)) continue;
if (!stagedDestNames.has(file)) {

View File

@@ -0,0 +1,151 @@
'use strict';
// #1575 — Golden-parity harness (ADR-1235 §0).
//
// Asserts that the surface path (applySurface) produces byte-for-byte identical
// agent output to the install path (installRuntimeArtifacts) for every
// descriptor-driven runtime. Both paths run against the SAME configDir so
// pathPrefix, attribution, and converter outputs match.
//
// The harness:
// 1. installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution)
// 2. Snapshot every gsd-* agent file in configDir/agents/
// 3. applySurface(configDir, layout, manifest, ..., opts)
// 4. Compare every agent file byte-for-byte: snapshot === current
process.env.GSD_TEST_MODE = '1';
const { describe, 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 ROOT = path.join(__dirname, '..');
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
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');
// The 7 descriptor-driven agent runtimes (cline deferred per code comment:
// rules-only local branch + local/global complication).
const DESCRIPTOR_RUNTIMES = [
'cursor',
'windsurf',
'augment',
'trae',
'codebuddy',
'copilot',
'antigravity',
];
function snapshotAgents(agentsDir) {
const snap = new Map();
if (!fs.existsSync(agentsDir)) return snap;
for (const name of fs.readdirSync(agentsDir)) {
if (!name.startsWith('gsd-')) continue;
if (!name.endsWith('.md') && !name.endsWith('.agent.md')) continue;
snap.set(name, fs.readFileSync(path.join(agentsDir, name), 'utf8'));
}
return snap;
}
// Shared manifest + profile so both paths see the same source agents.
const manifest = loadSkillsManifest(COMMANDS_GSD);
const profile = resolveProfile({ modes: ['full'], manifest });
// Same attribution resolver for both paths (undefined → no Co-Authored-By mutation).
const resolveAttribution = () => undefined;
describe('#1575 — golden-parity: surface path matches install path for descriptor-driven agents', () => {
for (const runtime of DESCRIPTOR_RUNTIMES) {
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 */ } });
// Step 1: install path writes agents
installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution);
// Step 2: snapshot agent files
const agentsDir = path.join(configDir, 'agents');
const installSnap = snapshotAgents(agentsDir);
assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`);
// Step 3: surface path re-materializes into the SAME configDir
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution });
// Step 4: compare byte-for-byte
const surfaceSnap = snapshotAgents(agentsDir);
// File lists must match
const installFiles = [...installSnap.keys()].sort();
const surfaceFiles = [...surfaceSnap.keys()].sort();
assert.deepEqual(
surfaceFiles,
installFiles,
`${runtime}: file lists must match after surface. Install: [${installFiles.join(', ')}] Surface: [${surfaceFiles.join(', ')}]`,
);
// Content must match byte-for-byte
for (const [fileName, installContent] of installSnap) {
const surfaceContent = surfaceSnap.get(fileName);
assert.strictEqual(
surfaceContent,
installContent,
`${runtime}/${fileName}: surface content must be byte-identical to install content`,
);
}
});
}
test('copilot: agents installed as .agent.md (filename rename parity)', (t) => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-'));
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution);
const agentsDir = path.join(configDir, 'agents');
assert.ok(fs.existsSync(agentsDir), 'copilot agents dir must exist');
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-'));
assert.ok(agentFiles.length > 0, 'copilot must have installed agents');
assert.ok(
agentFiles.every((f) => f.endsWith('.agent.md')),
`copilot agents must be .agent.md, got: [${agentFiles.slice(0, 3).join(', ')}]`,
);
});
});
describe('#1575 — surface path: no prune data-loss over pre-existing legacy agents', () => {
test('pre-existing gsd-* agents not in staged set are pruned; user agents preserved', (t) => {
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-prune-'));
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
// Seed a pre-existing legacy .agent.md (simulating a prior install)
const agentsDir = path.join(configDir, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
fs.writeFileSync(path.join(agentsDir, 'gsd-old-defunct.agent.md'), '# Old\n');
fs.writeFileSync(path.join(agentsDir, 'user-custom.md'), '# User\n');
// Install (should prune stale gsd-*, preserve user agents)
installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution);
const afterInstall = fs.readdirSync(agentsDir);
assert.ok(!afterInstall.includes('gsd-old-defunct.agent.md'), 'stale gsd-* agent must be pruned');
assert.ok(afterInstall.includes('user-custom.md'), 'user agent must be preserved');
// Now surface over the install — must converge to the same state
const layout = resolveRuntimeArtifactLayout('copilot', configDir, 'global');
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution });
const afterSurface = fs.readdirSync(agentsDir);
// Same set of agent files as after install
const installAgents = afterInstall.filter((f) => f.startsWith('gsd-')).sort();
const surfaceAgents = afterSurface.filter((f) => f.startsWith('gsd-')).sort();
assert.deepEqual(surfaceAgents, installAgents, 'surface must converge to same agent set as install');
assert.ok(afterSurface.includes('user-custom.md'), 'user agent still preserved after surface');
});
});