Merge pull request #2040 from open-gsd/feat/1575-agent-converter-descriptor-cutover

feat(#1575): agent-converter descriptor cutover for copilot/antigravity + surface path parity
This commit is contained in:
Tom Boucher
2026-07-06 12:26:08 -04:00
committed by GitHub
14 changed files with 421 additions and 53 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 2040
---
**`/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

@@ -570,11 +570,13 @@ function main() {
// progress (verified: no leaked handle / hang; --test-force-exit exits leaks
// cleanly, so the timeout was pure slowness, NOT the leak the kill message guesses).
// The per-chunk timeout is sized for a "healthy chunk (~4-5 min)"; keep chunks at
// roughly half a shard so each gets its own fresh 600s budget and a fresh node
// process (also relieving per-process memory pressure from 170+ files at once).
// roughly a third of a shard so each gets its own fresh 600s budget and a fresh
// node process (also relieving per-process memory pressure from 170+ files at once).
// Lowered from 90 to 60 after #1575 — macOS Node 22 shard 2/3 chunk 2 (~80 files
// including state.test.cjs, perf-*, worktree-cleanup) exceeded 600s with 90.
const MAX_FILES_PER_CHUNK = process.env.RUN_TESTS_MAX_FILES_PER_CHUNK
? Number(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK)
: 90;
: 60;
// node:test does not exit until the event loop drains. A unit test that leaks
// an open handle (un-terminated Worker, un-killed child_process, ref'd timer)

View File

@@ -92,7 +92,7 @@ interface DesiredCapability {
}
interface SetCapabilityStateOptions {
materialize?: { runtime: string; scope: string };
materialize?: { runtime: string; scope: string; resolveAttribution?: (runtime: string) => string | null | undefined };
}
/**
@@ -352,8 +352,18 @@ function setCapabilityState(
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope);
const commandsGsdDir = _resolveCommandsGsdDir();
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
// #1575: applySurface now accepts opts.resolveAttribution so surface-path
// agents get the same Co-Authored-By trailer as the install path. The
// resolver is not threaded here yet — the CLI command handler does not have
// access to getCommitAttribution (which lives in bin/install.js). Until that
// is refactored into a shared module, surface-path agents for descriptor-
// driven runtimes will lack the Co-Authored-By trailer that install adds.
// Parity is proven when resolveAttribution IS provided (see
// tests/issue-1575-agent-descriptor-parity.test.cjs).
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
applySurface(resolvedConfigDir, layout, manifest, undefined, registry);
applySurface(resolvedConfigDir, layout, manifest, undefined, registry, opts?.materialize?.resolveAttribution
? { resolveAttribution: opts.materialize.resolveAttribution }
: undefined);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// Fix C: materialise was explicitly requested — a failure is an error (non-zero exit),

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,54 @@ 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 AND the base profile is
// 'full', 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. For tiered profiles (core/standard) or when
// surface mods exist, pass the resolved set so only the filtered subset stages.
const _surfaceState = readSurface(layout.configDir);
const _baseProfileName = (_surfaceState && _surfaceState.baseProfile)
? _surfaceState.baseProfile
: (readActiveProfile(layout.configDir) || 'full');
const _hasSurfaceMods = !!_surfaceState && (
_surfaceState.disabledClusters.length > 0 ||
_surfaceState.explicitAdds.length > 0 ||
_surfaceState.explicitRemoves.length > 0
);
const _isUnmodifiedFull = _baseProfileName === 'full' && !_hasSurfaceMods;
try {
for (const kind of layout.kinds) {
let staged: string = kind.stage(resolved);
let staged: string;
if (kind.kind === 'agents') {
const agentProfile = _isUnmodifiedFull ? { ...resolved, skills: '*' as const } : resolved;
staged = kind.stage(agentProfile, agentCtx);
} else {
staged = kind.stage(resolved);
}
if (kind.kind === 'skills') {
runtimeArtifactConversion.rewriteStagedSkillBodies(staged, {
runtime: layout.runtime,
@@ -345,7 +380,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 +486,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 +494,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,24 +537,20 @@ 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'))) {

View File

@@ -0,0 +1,176 @@
'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('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => {
// M2 regression guard: verify parity holds when resolveAttribution returns
// a real value. Source agents don't carry Co-Authored-By, so processAttribution
// is a no-op (it replaces existing lines, doesn't add new ones). But this test
// proves the agentCtx threading is correct for both paths regardless.
const attrResolver = () => 'Test Bot <test@example.com>';
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-attr-'));
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
installRuntimeArtifacts('cursor', configDir, 'global', profile, attrResolver);
const agentsDir = path.join(configDir, 'agents');
const installSnap = snapshotAgents(agentsDir);
assert.ok(installSnap.size > 0, 'install must produce agents');
const layout = resolveRuntimeArtifactLayout('cursor', configDir, 'global');
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution: attrResolver });
const surfaceSnap = snapshotAgents(agentsDir);
for (const [fileName, installContent] of installSnap) {
assert.strictEqual(surfaceSnap.get(fileName), installContent,
`cursor/${fileName}: content must be byte-identical with non-undefined attribution`);
}
});
});
}
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');
});
});

View File

@@ -20,22 +20,36 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
test('repo-layout: root AGENTS.md is absent — no ad-hoc AI instruction file committed alongside CONTEXT.md', () => {
const agentsMdPath = path.join(ROOT, 'AGENTS.md');
test('repo-layout: root AGENTS.md is not git-tracked — no ad-hoc AI instruction file committed alongside CONTEXT.md', () => {
// The installer legitimately writes AGENTS.md to process.cwd() when
// `gsd install copilot` runs inside a repo checkout (issue #786). The file
// may exist on disk — that's expected after a local install. What must NOT
// happen is committing it to git, where editors and AI tools would silently
// pick up the installer-generated stub instead of CONTEXT.md.
let tracked;
try {
execFileSync('git', ['ls-files', '--error-unmatch', 'AGENTS.md'], {
cwd: ROOT, encoding: 'utf8', stdio: 'pipe',
});
tracked = true;
} catch {
tracked = false;
}
assert.equal(
fs.existsSync(agentsMdPath),
tracked,
false,
[
'root AGENTS.md must not be committed.',
'root AGENTS.md must not be git-tracked.',
'This file is written by `gsd install copilot` (bin/install.js, local Copilot path, issue #786)',
'when the installer runs inside a repo checkout.',
'when the installer runs inside a repo checkout — its presence on disk is fine,',
'but it must never be committed.',
'The repository source of truth for architecture and contributor guidance is',
'CONTEXT.md and docs/adr/ — not an installer-generated instruction stub.',
'Run `gsd uninstall copilot` to remove the artefact, then verify it is gitignored',
'before re-running the install in this checkout.',
'Run `git rm --cached AGENTS.md` to untrack it if accidentally staged.',
].join(' '),
);
});

View File

@@ -83,20 +83,26 @@ const GOLDEN = {
// ── copilot ──────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
// #1575: agents kind added (copilot cutover — .agent.md rename handled by _copyStaged).
'copilot/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
'copilot/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── antigravity ──────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
// #1575: agents kind added (antigravity cutover — scope-aware converter).
'antigravity/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
'antigravity/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── windsurf ─────────────────────────────────────────────────────────────────

View File

@@ -108,11 +108,16 @@ describe('resolveRuntimeArtifactLayout — copilot', () => {
const layout = resolveRuntimeArtifactLayout('copilot', FAKE_DIR);
assert.strictEqual(layout.runtime, 'copilot');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
// #1575: agents kind added (copilot cutover)
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'skills');
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(layout.kinds[1].kind, 'agents');
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
});
});
@@ -121,11 +126,16 @@ describe('resolveRuntimeArtifactLayout — antigravity', () => {
const layout = resolveRuntimeArtifactLayout('antigravity', FAKE_DIR);
assert.strictEqual(layout.runtime, 'antigravity');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
// #1575: agents kind added (antigravity cutover)
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds[0].kind, 'skills');
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(layout.kinds[1].kind, 'agents');
assert.strictEqual(layout.kinds[1].destSubpath, 'agents');
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
});
});