From 9dcd37033254e05ac267288e2776d1a4dd8c099f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 21:13:42 -0400 Subject: [PATCH] fix(#1858): detect flat commands/gsd-.md layout in _resolveManifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolveManifest only recognized the nested source layout (commands/gsd/*.md) and the installed-runtime skills layout (skills/gsd-/SKILL.md). A flat source install (Claude local project shape: commands/gsd-.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-.md files, strip the gsd- prefix, and build the same Map shape the nested loader produces (requires via shared parseRequires, companion _calls_agents_ 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). --- src/capability-state.cts | 75 +++++++++++++++++++++++++++++++++++----- src/install-profiles.cts | 1 + 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/capability-state.cts b/src/capability-state.cts index a43b16f3b..cead2c8b5 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -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 return manifest; } +/** + * #1858 — Build a skill dependency manifest from a FLAT commands/gsd-.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_` 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 { + const manifest = new Map(); + 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: /commands/gsd/*.md). + * 2. #1858 — otherwise, if the flat source layout is present (gsd-.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-.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 { if (fs.existsSync(commandsGsdDir)) { return loadSkillsManifest(commandsGsdDir); } + // #1858: flat source layout — gsd-.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, }; diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 90674c367..474327ae5 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -875,6 +875,7 @@ export = { writeActiveProfile, // Shared internals parseRequires, + parseCallsAgents, cleanupStagedSkills, // Back-compat / deprecated MINIMAL_SKILL_ALLOWLIST,