fix(#1858): detect flat commands/gsd-<stem>.md layout in _resolveManifest

_resolveManifest only recognized the nested source layout (commands/gsd/*.md)
and the installed-runtime skills layout (skills/gsd-<stem>/SKILL.md). A flat
source install (Claude local project shape: commands/gsd-<stem>.md, no
commands/gsd/ subdir) matched neither branch, so the manifest came back empty
and resolveSurface materialized the full profile to an empty Set — silently
reporting every skill-bearing capability as surfaced:false / enabled:false /
active:false. The nyquist/code-review/security/ui verify:post and execute:post
hooks never fired even with their workflow.* toggles on.

Add a third branch: when commandsGsdDir is absent, scan dirname(commandsGsdDir)
for gsd-<stem>.md files, strip the gsd- prefix, and build the same Map shape
the nested loader produces (requires via shared parseRequires, companion
_calls_agents_<stem> via shared parseCallsAgents). Falls through to the
installed-skills branch when the flat dir has no gsd-*.md files (precedence:
nested > flat-source > installed).

Also export parseCallsAgents from install-profiles so capability-state reuses
the SAME parser the nested loader uses (no drift; mirrors the existing
parseRequires export+reuse pattern).
This commit is contained in:
Tom Boucher
2026-07-06 21:13:42 -04:00
parent 4b1825e5f8
commit 9dcd370332
2 changed files with 68 additions and 8 deletions

View File

@@ -46,7 +46,7 @@ const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installProfilesMod = require('./install-profiles.cjs');
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires } = installProfilesMod;
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires, parseCallsAgents } = installProfilesMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import surfaceMod = require('./surface.cjs');
@@ -384,22 +384,80 @@ function _loadInstalledSkillsManifest(configDir: string): Map<string, string[]>
return manifest;
}
/**
* #1858 — Build a skill dependency manifest from a FLAT commands/gsd-<stem>.md
* source layout (the Claude local project install shape, where the `gsd-`
* prefix is baked into each filename at the commands/ level and there is no
* commands/gsd/ subdir). Strips the `gsd-` prefix so stems match the nested
* loader's output (gsd-validate-phase.md → validate-phase, same as nested
* validate-phase.md).
*
* Map shape is identical to loadSkillsManifest: each stem maps to its
* `requires` deps (parsed via the same shared parseRequires) and carries a
* companion `_calls_agents_<stem>` key (parsed via parseCallsAgents) so the
* flat and nested paths cannot drift.
*
* Returns an empty Map when the parent directory does not exist or contains
* no gsd-*.md files (so _resolveManifest can use size>0 as the "flat layout
* present" signal and fall through to the installed-skills branch otherwise).
*/
function _loadFlatCommandsGsdManifest(commandsParentDir: string): Map<string, string[]> {
const manifest = new Map<string, string[]>();
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(commandsParentDir, { withFileTypes: true });
} catch {
return manifest;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('gsd-')) continue;
if (!entry.name.endsWith('.md')) continue;
// Strip 'gsd-' prefix (4 chars) and '.md' suffix (3 chars) → stem.
const stem = entry.name.slice(4, -3);
if (!stem) continue;
let content = '';
try {
content = fs.readFileSync(path.join(commandsParentDir, entry.name), 'utf8');
} catch {
// Unreadable file — register with empty deps + agents (parity with
// loadSkillsManifest's readFileSync catch branch).
}
manifest.set(stem, content ? parseRequires(content) : []);
manifest.set(`_calls_agents_${stem}`, content ? parseCallsAgents(content) : []);
}
return manifest;
}
/**
* Resolve the skill dependency manifest for capability-state resolution.
*
* Resolution order (fixes #1160 — installed-runtime capability surface):
* 1. If commandsGsdDir exists, load from source (repo-checkout behavior).
* 2. Otherwise, fall back to installed skills at configDir/skills/gsd-[stem]/SKILL.md.
* Resolution order:
* 1. If commandsGsdDir exists, load from the nested source layout
* (repo-checkout behavior: <repo>/commands/gsd/*.md).
* 2. #1858 — otherwise, if the flat source layout is present (gsd-<stem>.md
* files in dirname(commandsGsdDir)), load from there. This is the Claude
* local project install shape where commands/gsd/ does not exist but
* commands/gsd-<stem>.md files do.
* 3. #1160 — otherwise, fall back to installed skills at
* configDir/skills/gsd-[stem]/SKILL.md.
*
* In an installed runtime the commands/gsd source tree is absent; only the
* skills/ layout exists. Returning an empty manifest caused resolveSurface to
* materialise the full-sentinel to an empty Set, making every capability appear
* unsurfaced even when the skill was physically installed.
* In an installed runtime both source trees are absent; only the skills/
* layout exists. Returning an empty manifest caused resolveSurface to
* materialise the full-sentinel to an empty Set, making every skill-bearing
* capability appear unsurfaced even when the skill was physically installed
* (#1160) or authored as a flat command file (#1858).
*/
function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string, string[]> {
if (fs.existsSync(commandsGsdDir)) {
return loadSkillsManifest(commandsGsdDir);
}
// #1858: flat source layout — gsd-<stem>.md files at dirname(commandsGsdDir).
// Only claim the flat branch when it actually has gsd-*.md files; otherwise
// fall through to the installed-skills branch (a commands/ dir with no gsd
// files must not shadow an installed skills/ tree).
const flat = _loadFlatCommandsGsdManifest(path.dirname(commandsGsdDir));
if (flat.size > 0) return flat;
return _loadInstalledSkillsManifest(configDir);
}
@@ -619,6 +677,7 @@ export = {
// Exported for tests
_resolveCommandsGsdDir,
_loadInstalledSkillsManifest,
_loadFlatCommandsGsdManifest,
_resolveManifest,
_isSafePropKey,
};

View File

@@ -875,6 +875,7 @@ export = {
writeActiveProfile,
// Shared internals
parseRequires,
parseCallsAgents,
cleanupStagedSkills,
// Back-compat / deprecated
MINIMAL_SKILL_ALLOWLIST,