fix(#947): restore gsd- prefix on Hermes skills for canonical dispatch (#955)

* test(#947): add regression tests and update stale Hermes assertions

- Add bug-947-hermes-gsd-prefix.test.cjs: 12 TDD tests covering fresh
  install canonical layout, bare-stem migration, manifest key format,
  and non-Hermes runtime isolation
- Update hermes-skills-migration.test.cjs: bare-stem → gsd-prefixed
  path and name assertions (#947 canonical layout)
- Update install-nested-layout.test.cjs: Hermes NEST matrix prefix ''
  → 'gsd-'
- Update install-regressions.test.cjs: Defect #1 now seeds bare-stem
  dirs (help/, quick/) and asserts gsd-help/ canonical output; use
  real GSD stems so readGsdCommandNames() migration finds them
- Update install-runtime-artifacts.test.cjs: Hermes nested layout and
  legacy migration assertions align with gsd- prefix
- Update install.test.cjs: Hermes install test uses gsd- prefixed paths
- Update runtime-artifact-layout.test.cjs: prefix '' → 'gsd-'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#947): restore gsd- prefix on Hermes skills for canonical dispatch

Hermes skills were installing under bare-stem paths
(skills/gsd/<stem>/SKILL.md, name: <stem>) due to prefix: '' set in
ADR-3660 / #3664. This broke /gsd-<stem> dispatch and forced users to
invoke skills without the gsd- namespace prefix.

- src/runtime-artifact-layout.cts: change Hermes skillsKind prefix
  from '' to 'gsd-'; skills now land at skills/gsd/gsd-<stem>/SKILL.md
  with name: gsd-<stem>
- bin/install.js _runLegacyInstallMigrations: invert the #3664
  migration — remove stale bare-stem dirs (using readGsdCommandNames()
  to distinguish GSD-owned stems from user content), keep gsd-* dirs
  which are now canonical
- bin/install.js _runLegacyUninstallCleanup: also remove bare-stem
  dirs on uninstall for clean teardown
- bin/install.js uninstallRuntimeArtifacts: post-cleanup removes
  DESCRIPTION.md and empty skills/gsd/ category dir on Hermes
- bin/install.js: remove skillListPrefix Hermes exception (now uses
  shared 'gsd-' path)
- docs/adr/3660-runtime-artifact-layout-module.md: document #947
  reversal of the bare-stem sub-decision

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add changeset for #947 fix (#955)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#947): remove ALL pre-migration bare-stem Hermes skills on reinstall (adversarial review)

Replace readGsdCommandNames()-based bare-stem cleanup (which missed skills
not in the commands source tree, e.g. dev-preferences) with
_removeHermesBareStemDirs(), called AFTER the install loop when the exact
set of installed gsd-<stem>/ dirs is authoritative. For every gsd-<stem>/
written this run, the corresponding bare skills/gsd/<stem>/ is removed.
User-owned bare dirs with no gsd-<stem> counterpart are preserved.

Add two adversarial-review regression tests that FAIL on old code:
- bare skills/gsd/dev-preferences/ removed when gsd-dev-preferences/ installed
- user-owned bare dir with no gsd-<stem> counterpart is preserved (no over-deletion)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-10 00:22:36 -04:00
committed by GitHub
parent a313a7e304
commit 1c86368785
11 changed files with 650 additions and 104 deletions

View File

@@ -139,7 +139,7 @@ describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill d
});
describe('installRuntimeArtifacts — hermes nested layout', () => {
test('hermes: skills/gsd/<stem>/SKILL.md, no gsd- prefix in name', (t) => {
test('hermes: skills/gsd/gsd-<stem>/SKILL.md with gsd- prefix in name (#947)', (t) => {
const configDir = createTempDir('gsd-ial-hermes-');
t.after(() => cleanup(configDir));
@@ -147,9 +147,11 @@ describe('installRuntimeArtifacts — hermes nested layout', () => {
const nestedDir = path.join(configDir, 'skills', 'gsd');
assert.ok(fs.existsSync(nestedDir));
assert.ok(fs.existsSync(path.join(nestedDir, 'help', 'SKILL.md')));
assert.ok(!fs.existsSync(path.join(nestedDir, 'gsd-help')),
'hermes must NOT have gsd-help prefix');
// #947: Hermes now uses canonical gsd- prefix — skills/gsd/gsd-<stem>/SKILL.md
assert.ok(fs.existsSync(path.join(nestedDir, 'gsd-help', 'SKILL.md')),
'skills/gsd/gsd-help/SKILL.md must exist (canonical gsd- prefix, #947)');
assert.ok(!fs.existsSync(path.join(nestedDir, 'help')),
'bare-stem skills/gsd/help/ must NOT exist (#947 fix)');
});
});
@@ -323,17 +325,23 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for
if (runtime === 'hermes') {
const kind = layout.kinds[0];
const destDir = path.join(configDir, kind.destSubpath);
const destDir = path.join(configDir, kind.destSubpath); // skills/gsd
// Seed a gsd-* prefixed skill (canonical #947 layout) and a bare-stem skill (#3664 era)
fs.mkdirSync(path.join(destDir, 'gsd-help'), { recursive: true });
fs.writeFileSync(path.join(destDir, 'gsd-help', 'SKILL.md'), '# gsd-help\n');
fs.mkdirSync(path.join(destDir, 'help'), { recursive: true });
fs.writeFileSync(path.join(destDir, 'help', 'SKILL.md'), '# help\n');
fs.writeFileSync(path.join(destDir, 'help', 'SKILL.md'), '# bare-stem help (#3664)\n');
const siblingDir = path.join(configDir, 'skills', 'user-skill');
fs.mkdirSync(siblingDir, { recursive: true });
fs.writeFileSync(path.join(siblingDir, 'SKILL.md'), '# user\n');
uninstallRuntimeArtifacts(runtime, configDir, 'global');
assert.ok(!fs.existsSync(destDir));
assert.ok(fs.existsSync(path.join(siblingDir, 'SKILL.md')));
// skills/gsd/ removed (gsd-* removed by _removeGsdEntries, bare-stem by legacy cleanup,
// then DESCRIPTION.md removed, category dir removed as empty)
assert.ok(!fs.existsSync(destDir), 'skills/gsd/ must be removed after uninstall');
// User skill outside skills/gsd/ preserved
assert.ok(fs.existsSync(path.join(siblingDir, 'SKILL.md')), 'user-skill must be preserved');
return;
}
@@ -436,7 +444,7 @@ describe('installRuntimeArtifacts — legacy migrations run before layout copy',
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')));
});
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/<stem>/ written', (t) => {
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/gsd-<stem>/ written (#947)', (t) => {
const configDir = createTempDir('gsd-legacy-hermes-install-');
t.after(() => cleanup(configDir));
@@ -446,13 +454,15 @@ describe('installRuntimeArtifacts — legacy migrations run before layout copy',
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
assert.ok(!fs.existsSync(legacyFlatHelp));
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'help', 'SKILL.md')));
assert.ok(!fs.existsSync(legacyFlatHelp), 'legacy flat skill must be removed');
// #947: canonical path is skills/gsd/gsd-<stem>/ not skills/gsd/<stem>/
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'gsd-help', 'SKILL.md')),
'skills/gsd/gsd-help/SKILL.md must exist after install (#947)');
});
});
describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout removal', () => {
test('hermes: both flat and nested layouts removed', (t) => {
test('hermes: both flat and nested layouts removed (#947: bare-stem dirs cleaned on uninstall)', (t) => {
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
const configDir = createTempDir('gsd-legacy-uninstall-hermes-');
t.after(() => cleanup(configDir));
@@ -463,8 +473,9 @@ describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout remova
fs.writeFileSync(path.join(flatHelp, 'SKILL.md'), '# legacy flat\n');
const nestedGsd = path.join(skillsDir, 'gsd');
// Seed a pre-#947 bare-stem GSD skill (no gsd- prefix, from #3664 era)
fs.mkdirSync(path.join(nestedGsd, 'help'), { recursive: true });
fs.writeFileSync(path.join(nestedGsd, 'help', 'SKILL.md'), '# nested help\n');
fs.writeFileSync(path.join(nestedGsd, 'help', 'SKILL.md'), '# nested help (bare-stem)\n');
const userSkill = path.join(skillsDir, 'user-skill');
fs.mkdirSync(userSkill, { recursive: true });
@@ -472,9 +483,12 @@ describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout remova
uninstallRuntimeArtifacts('hermes', configDir, 'global');
assert.ok(!fs.existsSync(flatHelp));
assert.ok(!fs.existsSync(nestedGsd));
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
// Pre-#2841 flat skills/gsd-help/ removed by legacy cleanup
assert.ok(!fs.existsSync(flatHelp), 'flat gsd-help must be removed');
// skills/gsd/ removed: bare-stem dirs cleaned + no gsd-* dirs remain → empty → removed
assert.ok(!fs.existsSync(nestedGsd), 'skills/gsd/ must be removed after uninstall');
// User content outside skills/gsd/ preserved
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')), 'user-skill must be preserved');
});
test('claude: legacy commands/gsd/ cleaned AND new skills/ entries removed', (t) => {