From fdac556746c571c0a680dec46f3bc8e7786ed389 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 14 Jun 2026 08:22:43 -0400 Subject: [PATCH] fix(#1160): resolve capability surface from installed skill layouts (#1206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#1160): resolve capability surface from installed skill layouts In a global skills-runtime install (e.g. Codex at ~/.codex), gsd-tools.cjs runs from /gsd-core/bin/ and the commands/gsd source tree is absent — only /skills/gsd-/SKILL.md files exist. _resolveCommandsGsdDir() returned a path that does not exist there, so loadSkillsManifest returned an empty Map. resolveSurface then materialised the '*' (full) profile sentinel by enumerating that empty manifest → empty surfaced Set → every capability reported surfaced=false/enabled=false regardless of project config. As a result `loop render-hooks verify:post` returned activeHooks:[] even with workflow.security_enforcement and workflow.nyquist_validation enabled, silently disabling the security and Nyquist gates. Fix: add _loadInstalledSkillsManifest(configDir) that scans configDir/skills/ for gsd-/SKILL.md dirs and builds the same Map shape, and _resolveManifest(commandsGsdDir, configDir) that prefers the source tree when present (preserving repo-checkout behaviour) and falls back to the installed skills layout otherwise. Both resolveCapabilityRuntimeState call sites use _resolveManifest. Both helpers are exported for direct unit-testing. Tests: capability-state.test.cjs gains a faithful installed-runtime e2e block that copies gsd-core/bin + scripts + package.json into a temp install root with no reachable commands/gsd, then runs the real gsd-tools.cjs against an installed skills/ layout. It asserts capability state reports security & nyquist enabled and verify:post includes security->secure-phase and nyquist->validate-phase; a disabled-config negative confirms no over-activation. This block FAILS before the fix (activeHooks:[]) and PASSES after. Plus unit coverage for the two new helpers and the empty-surface pre-fix scenario. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changeset): backfill PR number Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...60-installed-runtime-capability-surface.md | 5 + src/capability-state.cts | 95 +++- tests/capability-state.test.cjs | 434 ++++++++++++++++++ 3 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 .changeset/1160-installed-runtime-capability-surface.md diff --git a/.changeset/1160-installed-runtime-capability-surface.md b/.changeset/1160-installed-runtime-capability-surface.md new file mode 100644 index 000000000..34be4e9d4 --- /dev/null +++ b/.changeset/1160-installed-runtime-capability-surface.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1206 +--- +**Installed runtimes no longer silently disable `verify:post` gates** — in a global skills-runtime install (e.g. Codex at `~/.codex`), the `commands/gsd` source tree is absent, so capability-state resolved an empty skill manifest. The full-profile `*` sentinel then materialized to an empty surfaced set, marking every capability `surfaced=false` → `enabled=false`. The result: `gsd-tools loop render-hooks verify:post` returned `activeHooks: []` even with `security_enforcement` and `nyquist_validation` enabled, so the security and Nyquist gates never fired. Capability-state now falls back to the installed `/skills/gsd-*/SKILL.md` layout when the source tree is unreachable, so `verify:post` again includes `security -> secure-phase` and `nyquist -> validate-phase`. (#1206) diff --git a/src/capability-state.cts b/src/capability-state.cts index 9c3397200..c410782d3 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -29,6 +29,7 @@ */ import path from 'node:path'; +import fs from 'node:fs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import core = require('./core.cjs'); @@ -44,7 +45,7 @@ const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import installProfilesMod = require('./install-profiles.cjs'); -const { readActiveProfile, loadSkillsManifest, resolveProfile } = installProfilesMod; +const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires } = installProfilesMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import surfaceMod = require('./surface.cjs'); @@ -278,6 +279,88 @@ function _resolveCommandsGsdDir(): string { return path.join(repoRoot, 'commands', 'gsd'); } +/** + * Build a skill dependency manifest from an INSTALLED runtime's skills directory. + * + * In an installed runtime (e.g. Codex at ~/.codex), gsd skills live as + * configDir/skills/gsd-STEM/SKILL.md. There is no commands/gsd source tree. + * This function scans that installed layout and builds the same + * Map shape that loadSkillsManifest produces from sources. + * + * Stem extraction: a directory named gsd-secure-phase maps to stem secure-phase. + * Only directories whose names start with gsd- are included so user-created + * skills (without the gsd- prefix) are not accidentally pulled in. + * + * The requires: field is parsed via the shared parseRequires helper (the same + * parser loadSkillsManifest uses), so the two paths cannot drift. + * + * Returns an empty Map when the skills dir does not exist. + */ +function _loadInstalledSkillsManifest(configDir: string): Map { + const manifest = new Map(); + const skillsDir = path.join(configDir, 'skills'); + if (!fs.existsSync(skillsDir)) return manifest; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return manifest; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.startsWith('gsd-')) continue; + // Strip the 'gsd-' prefix to get the skill stem + const stem = entry.name.slice(4); // 'gsd-'.length === 4 + if (!stem) continue; + const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); + // Parity with loadSkillsManifest: a stem exists only when its artifact + // file is present. loadSkillsManifest registers a stem per .md FILE (and + // tolerates an unreadable file as []), but never invents a stem for which + // no file exists. Mirror that here: a stale gsd-/ directory with no + // SKILL.md must NOT register the stem — otherwise the capability would be + // wrongly reported surfaced/enabled and a verify:post hook would render + // for a skill that cannot run. + if (!fs.existsSync(skillMdPath)) continue; + let content = ''; + try { + content = fs.readFileSync(skillMdPath, 'utf8'); + } catch { + // SKILL.md present but unreadable — register with no deps (parity with + // loadSkillsManifest's readFileSync catch branch). + } + // Parse requires: via the SAME shared parser loadSkillsManifest uses, so + // installed-runtime dependency resolution can never silently diverge from + // the source-tree path (single source of truth — no duplicated regex). + manifest.set(stem, content ? parseRequires(content) : []); + // Mirror loadSkillsManifest's Map shape: it always sets a companion + // `_calls_agents_` key. Installed SKILL.md bodies carry no + // recoverable agent-call refs, so [] (the no-agents case) keeps the two + // manifest shapes identical and prevents undefined-vs-[] drift for any + // consumer that reads the agent-refs companion key. + manifest.set(`_calls_agents_${stem}`, []); + } + 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. + * + * 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. + */ +function _resolveManifest(commandsGsdDir: string, configDir: string): Map { + if (fs.existsSync(commandsGsdDir)) { + return loadSkillsManifest(commandsGsdDir); + } + return _loadInstalledSkillsManifest(configDir); +} + /** * Command entry point: resolve install profile, surface, and config; compute * capability state; emit the envelope via core.output. @@ -357,7 +440,9 @@ function resolveCapabilityRuntimeState( let installedSkills: Set | '*'; try { const commandsGsdDir = _resolveCommandsGsdDir(); - const manifest = loadSkillsManifest(commandsGsdDir); + // Fix #1160: use _resolveManifest so installed-runtime layouts (where + // commands/gsd is absent) fall back to /skills/gsd-*/SKILL.md. + const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir); const profileName = readActiveProfile(resolvedConfigDir) ?? 'full'; const resolvedInstall = resolveProfile({ modes: profileName.split(',').map((s: string) => s.trim()), @@ -377,7 +462,9 @@ function resolveCapabilityRuntimeState( let surfacedSkills: Set; try { const commandsGsdDir = _resolveCommandsGsdDir(); - const manifest = loadSkillsManifest(commandsGsdDir); + // Fix #1160: use _resolveManifest so installed-runtime layouts (where + // commands/gsd is absent) fall back to /skills/gsd-*/SKILL.md. + const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir); const surfaceResult = resolveSurface(resolvedConfigDir, manifest, undefined, registry); // resolveSurface returns { name, skills: Set, agents: Set } // (always a concrete Set — full profile is materialized) @@ -452,5 +539,7 @@ export = { cmdCapabilityState, // Exported for tests _resolveCommandsGsdDir, + _loadInstalledSkillsManifest, + _resolveManifest, _isSafePropKey, }; diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 062cd58ac..acde0af36 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -20,6 +20,8 @@ const { cleanup } = require('./helpers.cjs'); const { resolveCapabilityState, _isSafePropKey, + _loadInstalledSkillsManifest, + _resolveManifest, } = require('../gsd-core/bin/lib/capability-state.cjs'); // The real capability registry @@ -743,3 +745,435 @@ describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => { assert.strictEqual(planPreStep.active, false, 'effective hook activity must match workflow dispatch'); }); }); + +// ─── regressions: installed-runtime capability surface (#1160) ──────────────── +// +// In an installed runtime (e.g. Codex) the commands/gsd source tree is absent. +// Only /skills/gsd-*/SKILL.md files exist. Prior to this fix, +// loadSkillsManifest returned an empty map → resolveSurface materialized '*' +// to an empty Set → security.enabled=false / nyquist.enabled=false even when +// the project config had security_enforcement=true / nyquist_validation=true. +// +// These tests directly exercise the new _loadInstalledSkillsManifest and +// _resolveManifest functions with a non-existent commandsGsdDir path so they +// FAIL before the fix and PASS after, regardless of whether commands/gsd +// happens to exist in the current checkout. + +describe('regressions: installed-runtime capability surface (#1160)', () => { + // Minimal valid SKILL.md content (frontmatter only — matches what install emits) + function makeSkillMd(stem) { + return [ + '---', + `name: gsd:${stem}`, + `description: ${stem} skill`, + 'argument-hint: "[phase number]"', + 'allowed-tools:', + ' - Read', + 'requires: [phase]', + '---', + 'Execute end-to-end.', + ].join('\n') + '\n'; + } + + // ── Unit tests for _loadInstalledSkillsManifest ────────────────────────────── + + test('_loadInstalledSkillsManifest: returns empty map when skills dir absent', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-empty-')); + try { + const manifest = _loadInstalledSkillsManifest(tmpDir); + assert.ok(manifest instanceof Map, 'should return a Map'); + assert.strictEqual(manifest.size, 0, 'should be empty when no skills/ dir'); + } finally { + cleanup(tmpDir); + } + }); + + test('_loadInstalledSkillsManifest: scans gsd-/SKILL.md dirs and produces stems', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-scan-')); + try { + // Create installed skill dirs + const secureDir = path.join(tmpDir, 'skills', 'gsd-secure-phase'); + const validateDir = path.join(tmpDir, 'skills', 'gsd-validate-phase'); + fs.mkdirSync(secureDir, { recursive: true }); + fs.mkdirSync(validateDir, { recursive: true }); + fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8'); + fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8'); + // Add a non-gsd- dir that should be ignored + fs.mkdirSync(path.join(tmpDir, 'skills', 'user-custom'), { recursive: true }); + + const manifest = _loadInstalledSkillsManifest(tmpDir); + assert.ok(manifest instanceof Map); + assert.ok(manifest.has('secure-phase'), 'should have secure-phase stem'); + assert.ok(manifest.has('validate-phase'), 'should have validate-phase stem'); + assert.ok(!manifest.has('user-custom'), 'non-gsd- dir must not appear'); + } finally { + cleanup(tmpDir); + } + }); + + test('_loadInstalledSkillsManifest: parses requires from SKILL.md frontmatter', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-req-')); + try { + const skillDir = path.join(tmpDir, 'skills', 'gsd-my-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: gsd:my-skill\nrequires: [dep-a, dep-b]\n---\nbody\n', + 'utf8', + ); + const manifest = _loadInstalledSkillsManifest(tmpDir); + assert.deepStrictEqual(manifest.get('my-skill'), ['dep-a', 'dep-b']); + } finally { + cleanup(tmpDir); + } + }); + + test('_loadInstalledSkillsManifest: directory with no SKILL.md is NOT registered (parity with loadSkillsManifest)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-nodoc-')); + try { + fs.mkdirSync(path.join(tmpDir, 'skills', 'gsd-nodoc'), { recursive: true }); + // No SKILL.md written — a stale/empty skill dir must not invent a stem, + // mirroring loadSkillsManifest which only registers stems for files that exist. + const manifest = _loadInstalledSkillsManifest(tmpDir); + assert.ok(!manifest.has('nodoc'), 'stem must NOT be registered when SKILL.md is absent'); + assert.ok(!manifest.has('_calls_agents_nodoc'), 'companion agents key must also be absent'); + } finally { + cleanup(tmpDir); + } + }); + + // ── Unit tests for _resolveManifest ───────────────────────────────────────── + + test('_resolveManifest: uses commandsGsdDir when it exists', () => { + const realCommandsGsdDir = path.resolve(__dirname, '..', 'commands', 'gsd'); + if (!fs.existsSync(realCommandsGsdDir)) { + // Skip if not in a repo checkout + return; + } + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-src-')); + try { + // No skills/ dir — if _resolveManifest uses source, it returns real skills + const manifest = _resolveManifest(realCommandsGsdDir, tmpDir); + assert.ok(manifest instanceof Map); + // The real commands/gsd has many skills; manifest should be non-empty + assert.ok(manifest.size > 0, 'should load skills from real source dir'); + } finally { + cleanup(tmpDir); + } + }); + + test('_resolveManifest: falls back to installed skills when commandsGsdDir absent', () => { + const nonExistentDir = path.join(os.tmpdir(), 'cap-rm-nonexistent-' + Date.now()); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-installed-')); + try { + // Create installed skill layout under tmpDir + const secureDir = path.join(tmpDir, 'skills', 'gsd-secure-phase'); + const validateDir = path.join(tmpDir, 'skills', 'gsd-validate-phase'); + fs.mkdirSync(secureDir, { recursive: true }); + fs.mkdirSync(validateDir, { recursive: true }); + fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8'); + fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8'); + + const manifest = _resolveManifest(nonExistentDir, tmpDir); + assert.ok(manifest instanceof Map); + assert.ok(manifest.has('secure-phase'), 'must have secure-phase from installed skills'); + assert.ok(manifest.has('validate-phase'), 'must have validate-phase from installed skills'); + } finally { + cleanup(tmpDir); + } + }); + + // ── Integration tests: capability state with installed-layout config dir ──── + // These tests use a config dir that has NO .gsd-source and where _resolveCommandsGsdDir + // would return the real repo path. To force the installed-path, we call + // resolveCapabilityState directly with manifests derived from _resolveManifest + // using a non-existent commandsGsdDir. + + test('resolveCapabilityState: security enabled when secure-phase in installedSkills+surfacedSkills', () => { + const securitySkills = ['secure-phase']; + const result = resolveCapabilityState({ + registry: realRegistry, + installedSkills: new Set(securitySkills), + surfacedSkills: new Set(securitySkills), + config: { workflow: { security_enforcement: true } }, + }); + const secCap = result.capabilities.find((c) => c.id === 'security'); + assert.ok(secCap, 'security capability must be present'); + assert.strictEqual(secCap.installed, true, 'secure-phase in installedSkills → installed'); + assert.strictEqual(secCap.surfaced, true, 'secure-phase in surfacedSkills → surfaced'); + assert.strictEqual(secCap.enabled, true, 'installed+surfaced → enabled'); + }); + + test('resolveCapabilityState: security NOT enabled when surfacedSkills empty (pre-fix scenario)', () => { + // Simulates the pre-fix behavior: manifest empty → surface materializes to empty Set + const result = resolveCapabilityState({ + registry: realRegistry, + installedSkills: '*', // full profile → installed=true + surfacedSkills: new Set(), // empty set (what empty manifest causes) + config: { workflow: { security_enforcement: true } }, + }); + const secCap = result.capabilities.find((c) => c.id === 'security'); + assert.ok(secCap, 'security capability must be present'); + assert.strictEqual(secCap.installed, true); + assert.strictEqual(secCap.surfaced, false, 'empty surfacedSkills → surfaced=false (pre-fix bug)'); + assert.strictEqual(secCap.enabled, false, 'surfaced=false → enabled=false (pre-fix bug)'); + }); + + // ── End-to-end CLI tests: capability state + loop render-hooks ─────────────── + + describe('end-to-end CLI with installed skill layout', () => { + let tmpInstalledConfigDir; + let tmpInstalledProjectDir; + + before(() => { + tmpInstalledConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installed-')); + fs.writeFileSync(path.join(tmpInstalledConfigDir, '.gsd-profile'), 'full\n', 'utf8'); + const secureDir = path.join(tmpInstalledConfigDir, 'skills', 'gsd-secure-phase'); + const validateDir = path.join(tmpInstalledConfigDir, 'skills', 'gsd-validate-phase'); + fs.mkdirSync(secureDir, { recursive: true }); + fs.mkdirSync(validateDir, { recursive: true }); + fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8'); + fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8'); + + tmpInstalledProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installed-proj-')); + fs.mkdirSync(path.join(tmpInstalledProjectDir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(tmpInstalledProjectDir, '.planning', 'config.json'), + JSON.stringify({ workflow: { security_enforcement: true, nyquist_validation: true } }), + 'utf8', + ); + }); + + after(() => { + cleanup(tmpInstalledConfigDir); + cleanup(tmpInstalledProjectDir); + }); + + test('security capability: enabled=true in installed runtime with security_enforcement=true', () => { + const result = runCapabilityState(tmpInstalledProjectDir, tmpInstalledConfigDir); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + const envelope = JSON.parse(result.stdout); + const secCap = envelope.capabilities.find((c) => c.id === 'security'); + assert.ok(secCap, 'security capability must be present in output'); + assert.strictEqual(secCap.installed, true, 'security skill secure-phase is installed'); + assert.strictEqual(secCap.surfaced, true, 'security skill secure-phase is surfaced (full profile)'); + assert.strictEqual(secCap.enabled, true, 'security capability must be enabled'); + }); + + test('nyquist capability: enabled=true in installed runtime with nyquist_validation=true', () => { + const result = runCapabilityState(tmpInstalledProjectDir, tmpInstalledConfigDir); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + const envelope = JSON.parse(result.stdout); + const nyqCap = envelope.capabilities.find((c) => c.id === 'nyquist'); + assert.ok(nyqCap, 'nyquist capability must be present in output'); + assert.strictEqual(nyqCap.installed, true, 'nyquist skill validate-phase is installed'); + assert.strictEqual(nyqCap.surfaced, true, 'nyquist skill validate-phase is surfaced (full profile)'); + assert.strictEqual(nyqCap.enabled, true, 'nyquist capability must be enabled'); + }); + + test('security hook at verify:post is active in installed runtime', () => { + const result = spawnSync( + process.execPath, + [ + gsdToolsPath, + 'loop', 'render-hooks', 'verify:post', + '--config-dir', tmpInstalledConfigDir, + '--cwd', tmpInstalledProjectDir, + ], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + const envelope = JSON.parse(result.stdout.trim()); + assert.strictEqual(envelope.point, 'verify:post'); + assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array'); + const securityHook = envelope.activeHooks.find( + (h) => h.capId === 'security' && h.kind === 'step', + ); + assert.ok( + securityHook, + 'verify:post must include security step hook when security_enforcement=true. Got: ' + + JSON.stringify(envelope.activeHooks), + ); + assert.ok( + securityHook.ref && securityHook.ref.skill === 'secure-phase', + 'security hook ref.skill must be secure-phase', + ); + }); + + test('nyquist hook at verify:post is active in installed runtime', () => { + const result = spawnSync( + process.execPath, + [ + gsdToolsPath, + 'loop', 'render-hooks', 'verify:post', + '--config-dir', tmpInstalledConfigDir, + '--cwd', tmpInstalledProjectDir, + ], + { encoding: 'utf8', timeout: 15000 }, + ); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + const envelope = JSON.parse(result.stdout.trim()); + const nyquistHook = envelope.activeHooks.find( + (h) => h.capId === 'nyquist' && h.kind === 'step', + ); + assert.ok( + nyquistHook, + 'verify:post must include nyquist step hook when nyquist_validation=true. Got: ' + + JSON.stringify(envelope.activeHooks), + ); + assert.ok( + nyquistHook.ref && nyquistHook.ref.skill === 'validate-phase', + 'nyquist hook ref.skill must be validate-phase', + ); + }); + }); + + // ── TRUE installed-runtime layout: gsd-tools runs where commands/gsd is unreachable ── + // The block above runs the repo's gsd-tools.cjs, where commands/gsd source IS + // reachable by walk-up, so it cannot exercise the bug. This block copies the + // runtime executable tree (gsd-core/bin + scripts + package.json) into a temp + // install root that has NO commands/ sibling — faithfully reproducing a global + // skills-runtime install (e.g. Codex at ~/.codex). There, the source manifest + // is genuinely empty, so pre-fix the '*' profile materialized to an empty + // surfaced set → enabled=false → verify:post activeHooks: []. This test FAILS + // before the fix and PASSES after. + describe('true installed layout (commands/gsd unreachable)', () => { + let installRoot; + let installedConfigDir; + let installedProjectDir; + let installedGsdTools; + + before(() => { + const repoRoot = path.resolve(__dirname, '..'); + // 1. Faithful install root: copy the executable runtime WITHOUT commands/. + installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installroot-')); + fs.mkdirSync(path.join(installRoot, 'gsd-core'), { recursive: true }); + fs.cpSync( + path.join(repoRoot, 'gsd-core', 'bin'), + path.join(installRoot, 'gsd-core', 'bin'), + { recursive: true }, + ); + fs.cpSync( + path.join(repoRoot, 'scripts'), + path.join(installRoot, 'scripts'), + { recursive: true }, + ); + fs.copyFileSync( + path.join(repoRoot, 'package.json'), + path.join(installRoot, 'package.json'), + ); + installedGsdTools = path.join(installRoot, 'gsd-core', 'bin', 'gsd-tools.cjs'); + + // 2. Installed runtime config dir: full profile + skills/gsd-*/SKILL.md only. + installedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instcfg-')); + fs.writeFileSync(path.join(installedConfigDir, '.gsd-profile'), 'full\n', 'utf8'); + for (const stem of ['secure-phase', 'validate-phase']) { + const skillDir = path.join(installedConfigDir, 'skills', `gsd-${stem}`); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), makeSkillMd(stem), 'utf8'); + } + + // 3. Project enabling both gates. + installedProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instproj-')); + fs.mkdirSync(path.join(installedProjectDir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(installedProjectDir, '.planning', 'config.json'), + JSON.stringify({ workflow: { security_enforcement: true, nyquist_validation: true } }), + 'utf8', + ); + }); + + after(() => { + cleanup(installRoot); + cleanup(installedConfigDir); + cleanup(installedProjectDir); + }); + + test('commands/gsd is genuinely unreachable from the installed gsd-tools', () => { + // Sanity: no commands/gsd anywhere under the install root. + const probe = path.join(installRoot, 'commands', 'gsd'); + assert.strictEqual(fs.existsSync(probe), false, 'install root must have no commands/gsd'); + }); + + test('capability state: security & nyquist enabled in true installed runtime', () => { + const result = spawnSync( + process.execPath, + [ + installedGsdTools, + 'capability', 'state', + '--config-dir', installedConfigDir, + '--cwd', installedProjectDir, + '--raw', + ], + { encoding: 'utf8', timeout: 20000 }, + ); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + const envelope = JSON.parse(result.stdout); + const secCap = envelope.capabilities.find((c) => c.id === 'security'); + const nyqCap = envelope.capabilities.find((c) => c.id === 'nyquist'); + assert.ok(secCap && nyqCap, 'security and nyquist capabilities must be present'); + assert.strictEqual(secCap.surfaced, true, 'security surfaced from installed skills (pre-fix: false)'); + assert.strictEqual(secCap.enabled, true, 'security enabled in installed runtime (pre-fix: false)'); + assert.strictEqual(nyqCap.surfaced, true, 'nyquist surfaced from installed skills (pre-fix: false)'); + assert.strictEqual(nyqCap.enabled, true, 'nyquist enabled in installed runtime (pre-fix: false)'); + }); + + test('loop render-hooks verify:post: includes security & nyquist in true installed runtime', () => { + const result = spawnSync( + process.execPath, + [ + installedGsdTools, + 'loop', 'render-hooks', 'verify:post', + '--config-dir', installedConfigDir, + '--cwd', installedProjectDir, + ], + { encoding: 'utf8', timeout: 20000 }, + ); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + const envelope = JSON.parse(result.stdout.trim()); + assert.strictEqual(envelope.point, 'verify:post'); + assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array'); + const sec = envelope.activeHooks.find((h) => h.capId === 'security' && h.kind === 'step'); + const nyq = envelope.activeHooks.find((h) => h.capId === 'nyquist' && h.kind === 'step'); + assert.ok( + sec && sec.ref && sec.ref.skill === 'secure-phase', + 'verify:post must include security -> secure-phase (pre-fix: activeHooks was []). Got: ' + JSON.stringify(envelope.activeHooks), + ); + assert.ok( + nyq && nyq.ref && nyq.ref.skill === 'validate-phase', + 'verify:post must include nyquist -> validate-phase (pre-fix: activeHooks was []). Got: ' + JSON.stringify(envelope.activeHooks), + ); + }); + + test('negative: when both gates disabled, hooks stay inactive (no over-activation)', () => { + const disabledProj = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instproj-off-')); + try { + fs.mkdirSync(path.join(disabledProj, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(disabledProj, '.planning', 'config.json'), + JSON.stringify({ workflow: { security_enforcement: false, nyquist_validation: false } }), + 'utf8', + ); + const result = spawnSync( + process.execPath, + [ + installedGsdTools, + 'loop', 'render-hooks', 'verify:post', + '--config-dir', installedConfigDir, + '--cwd', disabledProj, + ], + { encoding: 'utf8', timeout: 20000 }, + ); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstderr: ${result.stderr}`); + const envelope = JSON.parse(result.stdout.trim()); + const sec = (envelope.activeHooks || []).find((h) => h.capId === 'security' && h.kind === 'step'); + const nyq = (envelope.activeHooks || []).find((h) => h.capId === 'nyquist' && h.kind === 'step'); + assert.ok(!sec, 'security step must NOT be active when security_enforcement=false'); + assert.ok(!nyq, 'nyquist step must NOT be active when nyquist_validation=false'); + } finally { + cleanup(disabledProj); + } + }); + }); + +});