fix(#2018): applySurface empty manifest no longer deletes gsd-* agents (#2031)

* fix(#2018): applySurface empty manifest no longer deletes gsd-* agents

The agent-prune loop in _syncGsdDir deleted any gsd-*.md not in the staged
set. When the manifest resolved empty (null/non-object, no entries, no files
key, or unresolvable install source root), the staged set was empty → every
gsd-* agent was deleted. Skills were guarded by pruneSkillDirs' manifest-
membership check; agents had no equivalent.

- src/surface.cts: skip the agent-prune loop when the manifest is empty/absent
  (copy still runs — genuinely new agents are added).
- tests/surface-empty-manifest-agents.test.cjs: boundary matrix — empty
  manifest preserves agents (Map() + undefined); non-empty manifest + empty
  staged still prunes (boundary); empty manifest + new staged copies new +
  preserves existing.

Closes #2018

* docs(#2018): backfill changeset pr 2031
This commit is contained in:
Tom Boucher
2026-07-05 17:28:34 -04:00
committed by GitHub
parent 68a5258d45
commit 460956bfed
3 changed files with 86 additions and 5 deletions

View File

@@ -509,11 +509,20 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
// - agents: only gsd-* are GSD-owned
// - flat command dirs: only `${kindPrefix}`-prefixed are GSD-owned
// - namespaced command dirs: the whole dir is GSD-owned
for (const file of fs.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix)) continue;
if (!stagedDestNames.has(file)) {
try { fs.unlinkSync(path.join(destDir, file)); } catch { /* ignore */ }
//
// 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'))) {
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix)) continue;
if (!stagedDestNames.has(file)) {
try { fs.unlinkSync(path.join(destDir, file)); } catch { /* ignore */ }
}
}
}
}