fix(#1160): resolve capability surface from installed skill layouts (#1206)

* 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 <configDir>/gsd-core/bin/ and the commands/gsd source tree is
absent — only <configDir>/skills/gsd-<stem>/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-<stem>/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) <noreply@anthropic.com>

* docs(changeset): backfill PR number

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 08:22:43 -04:00
committed by GitHub
parent e9f9ae49c8
commit fdac556746
3 changed files with 531 additions and 3 deletions

View File

@@ -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 `<configDir>/skills/gsd-*/SKILL.md` layout when the source tree is unreachable, so `verify:post` again includes `security -> secure-phase` and `nyquist -> validate-phase`. (#1206)

View File

@@ -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<string, string[]> {
const manifest = new Map<string, string[]>();
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-<stem>/ 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_<stem>` 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<string, string[]> {
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<string> | '*';
try {
const commandsGsdDir = _resolveCommandsGsdDir();
const manifest = loadSkillsManifest(commandsGsdDir);
// Fix #1160: use _resolveManifest so installed-runtime layouts (where
// commands/gsd is absent) fall back to <configDir>/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<string>;
try {
const commandsGsdDir = _resolveCommandsGsdDir();
const manifest = loadSkillsManifest(commandsGsdDir);
// Fix #1160: use _resolveManifest so installed-runtime layouts (where
// commands/gsd is absent) fall back to <configDir>/skills/gsd-*/SKILL.md.
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
const surfaceResult = resolveSurface(resolvedConfigDir, manifest, undefined, registry);
// resolveSurface returns { name, skills: Set<string>, agents: Set<string> }
// (always a concrete Set — full profile is materialized)
@@ -452,5 +539,7 @@ export = {
cmdCapabilityState,
// Exported for tests
_resolveCommandsGsdDir,
_loadInstalledSkillsManifest,
_resolveManifest,
_isSafePropKey,
};

View File

@@ -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 <configDir>/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-<stem>/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);
}
});
});
});