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:
5
.changeset/plucky-jays-dart.md
Normal file
5
.changeset/plucky-jays-dart.md
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'))) {
|
||||
|
||||
176
tests/issue-1575-agent-descriptor-parity.test.cjs
Normal file
176
tests/issue-1575-agent-descriptor-parity.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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(' '),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user