From 847de596b8a586c36eb43421154e3b9ed9aff419 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 7 Jul 2026 08:11:44 -0400 Subject: [PATCH] fix: third-party capability skills surface correctly (#2045) A skills-only role:feature third-party capability installed 'active' but its skills never reached the runtime surface, capability enable/set rejected it as 'unknown capability', and capability list disagreed with capability state. Three defects, fix shape 1b (teach resolveSurface, no on-disk linking): D1 (materialization): resolveSurface built the surfaced skills Set only from the on-disk manifest; third-party cap skills live at ~/.gsd/capabilities//skills/ and never entered the Set -> surfaced:false. Fix: union registry.capabilityClusters values into the Set in the full-profile branch (idempotent for first-party, additive for third-party; prototype-pollution guarded). D2 (enable/set unknown): setCapabilityState validated the capId against the static first-party registry instead of the composed overlay-aware registry. Fix: validate against loadRegistry({includeInstalled}) like capability-state.cts does. D3 (list vs state): capability list derived status purely from ledger existence, never surface composition. Fix: add a 'surfaced' field to list rows sourced from the same resolver capability state uses, so list and state agree. Regression coverage: tests/issue-2045-third-party-skills-surface.test.cjs asserts all five acceptance criteria (resolveSurface union, state surfaced:true, enable/set not-unknown, list/state agreement, first-party + unknown-id regression). gsd-test: 23916/23916 green (linux-node22 + linux-node24). --- .changeset/clever-lemurs-march.md | 5 + gsd-core/bin/gsd-tools.cjs | 29 ++ src/capability-writer.cts | 11 +- src/surface.cts | 27 ++ .../golden-install-parity/antigravity.json | 2 +- .../golden-install-parity/augment.json | 2 +- .../golden-install-parity/claude.json | 2 +- .../fixtures/golden-install-parity/cline.json | 2 +- .../golden-install-parity/codebuddy.json | 2 +- .../fixtures/golden-install-parity/codex.json | 2 +- .../golden-install-parity/copilot.json | 2 +- .../golden-install-parity/cursor.json | 2 +- .../golden-install-parity/hermes.json | 2 +- .../fixtures/golden-install-parity/kilo.json | 2 +- .../fixtures/golden-install-parity/kimi.json | 2 +- .../golden-install-parity/opencode.json | 2 +- .../fixtures/golden-install-parity/qwen.json | 2 +- .../fixtures/golden-install-parity/trae.json | 2 +- .../golden-install-parity/windsurf.json | 2 +- .../fixtures/golden-install-parity/zcode.json | 2 +- ...e-2045-third-party-skills-surface.test.cjs | 265 ++++++++++++++++++ 21 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 .changeset/clever-lemurs-march.md create mode 100644 tests/issue-2045-third-party-skills-surface.test.cjs diff --git a/.changeset/clever-lemurs-march.md b/.changeset/clever-lemurs-march.md new file mode 100644 index 000000000..d585f6d7d --- /dev/null +++ b/.changeset/clever-lemurs-march.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2054 +--- +**Third-party capability skills now surface correctly after install** — a skills-only `role: feature` capability installed `active` but its skills never reached the runtime surface, `capability enable`/`set` rejected it as `unknown capability`, and `capability list` disagreed with `capability state`. `resolveSurface` now unions the composed registry's `capabilityClusters` into the surfaced skill set (no on-disk linking), the writer validates against the composed overlay-aware registry, and `capability list` carries a `surfaced` field matching `capability state`. diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 99c806ef3..ffc8ac912 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -2140,6 +2140,28 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } } } catch { /* best-effort — list still works without the inactive annotation */ } + // Issue #2045 (DEFECT 3): derive each capability's SURFACED state from the + // SAME resolver `capability state` uses (resolveCapabilityRuntimeState), so + // `list` and `state` stop disagreeing. `list` previously derived `status` + // purely from ledger-entry existence — an installed-but-not-surfaced cap + // reported active in `list` and absent in `state`. Surfaced is evaluated at + // the default runtime config dir (the resolver resolves it when undefined), + // matching `capability state ` with no --config-dir. Best-effort: a + // resolver failure leaves surfacedById empty (rows report surfaced:null). + const surfacedById = {}; + // surfacedById is keyed by capId only (NOT `${scope} ${capId}`): surface + // state is single-source — one runtime config dir → one .gsd-surface.json + // → one surfaced truth per capId — and the loader dedupes overlay caps to + // one registry entry per id (first-party-wins). So a cap installed in both + // scopes correctly shares one surfaced value across its list rows. + try { + const surfaceState = capabilityState.resolveCapabilityRuntimeState(cwd, undefined); + for (const cap of (surfaceState && surfaceState.capabilities) || []) { + if (cap && typeof cap.id === 'string') { + surfacedById[cap.id] = cap.surfaced === true; + } + } + } catch { /* best-effort — list still works without the surfaced annotation */ } for (const capId of Object.keys(fp)) { const cap = fp[capId] || {}; rows.push({ @@ -2150,6 +2172,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand source: 'first-party', scope: 'first-party', status: 'active', + surfaced: Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null, title: cap.title || null, }); } @@ -2199,6 +2222,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand scope: sc, status, reason, + // Issue #2045 (DEFECT 3): surfaced reflects surface composition, so + // list and state agree. An inactive (unconsented/incompatible) cap is + // surfaced:false by definition; otherwise defer to the resolver. + surfaced: status === 'active' + ? (Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null) + : false, title: manifest.title || null, }); } diff --git a/src/capability-writer.cts b/src/capability-writer.cts index 7605614f8..07d393309 100644 --- a/src/capability-writer.cts +++ b/src/capability-writer.cts @@ -151,8 +151,17 @@ function setCapabilityState( const resolvedConfigDir = before.runtimeConfigDir; // ── Load registry ───────────────────────────────────────────────────────── + // Issue #2045 (DEFECT 2): validate against the COMPOSED overlay-aware registry + // (first-party ∪ accepted overlays), mirroring capability-state.cts:547-551. + // The frozen capability-registry.cjs only knows first-party ids, so a third- + // party cap failed the membership check below → "unknown capability" even + // though resolveCapabilityRuntimeState (the `before` snapshot, line 150) already + // knew about it. loadRegistry is non-throwing and first-party-wins, so a + // malformed overlay is skipped (never crashes the writer); a truly-unknown id + // is STILL rejected because it is absent from the composed capabilities map. // eslint-disable-next-line @typescript-eslint/no-require-imports - const registry = require('./capability-registry.cjs') as Record; + const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record) => Record }; + const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }); const capabilitiesMap = ( registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities']) ? registry['capabilities'] diff --git a/src/surface.cts b/src/surface.cts index 6c93c61d0..263b773b8 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -261,6 +261,33 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map/skills//SKILL.md — NOT in the runtime skills + // dir → never in skillManifest → never in the Set → surfaced:false. The + // overlay-aware registry's `capabilityClusters` already covers accepted + // overlay caps (composed by loadRegistry({includeInstalled})), so union its + // values into the surfaced Set. This is IDEMPOTENT for first-party skills + // (their stems are already on disk → already in the Set) and ADDITIVE for + // third-party skills (the fix). The 'full' profile means everything, and + // every cap's profileMembership profiles-array includes 'full' (it is the + // suffix top), so no per-tier gate is needed here — this invariant is owned + // by gen-capability-registry.cjs deriveProfileMembership (PROFILE_RANK suffix) + // + deriveCapabilityClusters (same non-empty-skills scoping); revisit both if + // either derivation changes. Prototype-pollution guard mirrors the cluster- + // merge block above (lines 217-240). NOTE: third-party cap agents are NOT + // unioned here (skillManifest has no `_calls_agents_` companion for them) — + // v1 scopes to skills-only caps per issue #2045; agents are a follow-up. + if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') { + const BANNED = ['__proto__', 'constructor', 'prototype']; + for (const capId of Object.keys(registry.capabilityClusters)) { + if (BANNED.includes(capId)) continue; + const stems = (registry.capabilityClusters as Record)[capId]; + if (!Array.isArray(stems)) continue; + for (const s of stems) { + if (typeof s === 'string' && s.length > 0) skills.add(s); + } + } + } } else { skills = new Set(baseResolved.skills); } diff --git a/tests/fixtures/golden-install-parity/antigravity.json b/tests/fixtures/golden-install-parity/antigravity.json index db53e2eb3..37c8e5990 100644 --- a/tests/fixtures/golden-install-parity/antigravity.json +++ b/tests/fixtures/golden-install-parity/antigravity.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "f2dd7a6993dc12f1", + "gsd-core/bin/gsd-tools.cjs": "b518bc939b3e031e", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/augment.json b/tests/fixtures/golden-install-parity/augment.json index 8c916be89..0d3ffaa56 100644 --- a/tests/fixtures/golden-install-parity/augment.json +++ b/tests/fixtures/golden-install-parity/augment.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/claude.json b/tests/fixtures/golden-install-parity/claude.json index f71bdd663..f0eb6840c 100644 --- a/tests/fixtures/golden-install-parity/claude.json +++ b/tests/fixtures/golden-install-parity/claude.json @@ -37,7 +37,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/cline.json b/tests/fixtures/golden-install-parity/cline.json index d113ecf6e..f56a42ddb 100644 --- a/tests/fixtures/golden-install-parity/cline.json +++ b/tests/fixtures/golden-install-parity/cline.json @@ -41,7 +41,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf", - "gsd-core/bin/gsd-tools.cjs": "c6818560fbd9f8e1", + "gsd-core/bin/gsd-tools.cjs": "3dce0048d7aed8f4", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/codebuddy.json b/tests/fixtures/golden-install-parity/codebuddy.json index 02e1d09aa..d213081fb 100644 --- a/tests/fixtures/golden-install-parity/codebuddy.json +++ b/tests/fixtures/golden-install-parity/codebuddy.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/codex.json b/tests/fixtures/golden-install-parity/codex.json index a956fe3b2..ac9aa3935 100644 --- a/tests/fixtures/golden-install-parity/codex.json +++ b/tests/fixtures/golden-install-parity/codex.json @@ -73,7 +73,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/copilot.json b/tests/fixtures/golden-install-parity/copilot.json index ee8cb176b..8d8bb2f6b 100644 --- a/tests/fixtures/golden-install-parity/copilot.json +++ b/tests/fixtures/golden-install-parity/copilot.json @@ -39,7 +39,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74", - "gsd-core/bin/gsd-tools.cjs": "f2dd7a6993dc12f1", + "gsd-core/bin/gsd-tools.cjs": "b518bc939b3e031e", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/cursor.json b/tests/fixtures/golden-install-parity/cursor.json index fda7e51db..835644697 100644 --- a/tests/fixtures/golden-install-parity/cursor.json +++ b/tests/fixtures/golden-install-parity/cursor.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828", - "gsd-core/bin/gsd-tools.cjs": "2493822b0ae27980", + "gsd-core/bin/gsd-tools.cjs": "7fd5fbaf1bbd9db5", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/hermes.json b/tests/fixtures/golden-install-parity/hermes.json index 6ded77059..8b226a2b9 100644 --- a/tests/fixtures/golden-install-parity/hermes.json +++ b/tests/fixtures/golden-install-parity/hermes.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f", - "gsd-core/bin/gsd-tools.cjs": "3d032fe88d2dc09b", + "gsd-core/bin/gsd-tools.cjs": "e666de93d4fd49fc", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/kilo.json b/tests/fixtures/golden-install-parity/kilo.json index c3073c72b..7e737fb76 100644 --- a/tests/fixtures/golden-install-parity/kilo.json +++ b/tests/fixtures/golden-install-parity/kilo.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/kimi.json b/tests/fixtures/golden-install-parity/kimi.json index 89be6cc93..feedd61a8 100644 --- a/tests/fixtures/golden-install-parity/kimi.json +++ b/tests/fixtures/golden-install-parity/kimi.json @@ -74,7 +74,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/opencode.json b/tests/fixtures/golden-install-parity/opencode.json index 3546e38df..2a8b8fbd8 100644 --- a/tests/fixtures/golden-install-parity/opencode.json +++ b/tests/fixtures/golden-install-parity/opencode.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/qwen.json b/tests/fixtures/golden-install-parity/qwen.json index 01d4c9511..3c2e758f5 100644 --- a/tests/fixtures/golden-install-parity/qwen.json +++ b/tests/fixtures/golden-install-parity/qwen.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2", - "gsd-core/bin/gsd-tools.cjs": "6e2a3fff91bacb3e", + "gsd-core/bin/gsd-tools.cjs": "1caaaac4e81c7105", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/trae.json b/tests/fixtures/golden-install-parity/trae.json index 6e591557f..3c43712ee 100644 --- a/tests/fixtures/golden-install-parity/trae.json +++ b/tests/fixtures/golden-install-parity/trae.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527", - "gsd-core/bin/gsd-tools.cjs": "2ed2d11d5be017e2", + "gsd-core/bin/gsd-tools.cjs": "172d8fce13b74b2d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/windsurf.json b/tests/fixtures/golden-install-parity/windsurf.json index b7761e74d..6dd136a20 100644 --- a/tests/fixtures/golden-install-parity/windsurf.json +++ b/tests/fixtures/golden-install-parity/windsurf.json @@ -38,7 +38,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2", - "gsd-core/bin/gsd-tools.cjs": "ba7396b5e12d9b70", + "gsd-core/bin/gsd-tools.cjs": "d4fb7a7431f2b7d2", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/fixtures/golden-install-parity/zcode.json b/tests/fixtures/golden-install-parity/zcode.json index d394aab6c..36a418191 100644 --- a/tests/fixtures/golden-install-parity/zcode.json +++ b/tests/fixtures/golden-install-parity/zcode.json @@ -109,7 +109,7 @@ "gsd-core/VERSION": "ef0deccd81a6723c", "gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74", "gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62", - "gsd-core/bin/gsd-tools.cjs": "f1a1a58072e7c35d", + "gsd-core/bin/gsd-tools.cjs": "2c08276ad3758a7d", "gsd-core/bin/gsd_run": "62d9b647ede212e6", "gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16", "gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a", diff --git a/tests/issue-2045-third-party-skills-surface.test.cjs b/tests/issue-2045-third-party-skills-surface.test.cjs new file mode 100644 index 000000000..09917e620 --- /dev/null +++ b/tests/issue-2045-third-party-skills-surface.test.cjs @@ -0,0 +1,265 @@ +'use strict'; + +/** + * issue-2045-third-party-skills-surface.test.cjs — regression for bug #2045. + * + * A skills-only `role: feature` third-party capability installs "active" but its + * skills never surface, `capability enable`/`set` reject it as "unknown", and + * `capability list` disagrees with `capability state`. Three defects, fix shape + * "1b" (teach resolveSurface, no on-disk linking): + * + * D1 (materialization): resolveSurface built the `skills` Set only from the + * on-disk skill manifest; third-party cap skills live at + * ~/.gsd/capabilities//skills/ and never entered the Set → surfaced:false. + * FIX: union registry.capabilityClusters values into the Set. + * D2 (enable/set unknown): setCapabilityState validated the capId against the + * STATIC first-party registry instead of the composed overlay-aware registry. + * FIX: validate against loadRegistry({ includeInstalled }). + * D3 (list vs state): `capability list` derived `status` purely from ledger + * existence, never surface composition. FIX: add a `surfaced` field so list + * reflects the same surface state `capability state` reports. + * + * Acceptance criteria (each is a release blocker, per the issue's "I'd expect" + * table): + * AC1 [D1]: resolveSurface includes a third-party cap's skill stems. + * AC2: capability state reports the cap present with surfaced:true. + * AC3 [D2]: capability enable/set on an installed third-party cap does NOT + * error "unknown capability". + * AC4 [D3]: capability list reflects surface state (list/state agree). + * AC5: first-party caps unaffected; writer still rejects truly-unknown ids. + */ + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +const { resolveCapabilityRuntimeState } = require('../gsd-core/bin/lib/capability-state.cjs'); +const { setCapabilityState } = require('../gsd-core/bin/lib/capability-writer.cjs'); +const { resolveSurface } = require('../gsd-core/bin/lib/surface.cjs'); +const { loadRegistry } = require('../gsd-core/bin/lib/capability-loader.cjs'); + +// ─── Fixture helpers ───────────────────────────────────────────────────────── + +const CAP_ID = 'demo-2045-cap'; +const CAP_SKILLS = ['demo-2045-alpha', 'demo-2045-beta']; +const HOST_RANGE = '>=1.0.0'; + +const tmps = []; +function tmpDir(prefix) { + const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmps.push(d); + return d; +} +after(() => { for (const d of tmps) cleanup(d); }); + +/** A conformant skills-only feature capability manifest (the reporter's shape). */ +function skillsOnlyCap(id, skills) { + return { + id, + role: 'feature', + version: '1.0.0', + title: id, + description: 'skills-only third-party capability (issue #2045 fixture)', + tier: 'full', + requires: [], + engines: { gsd: HOST_RANGE }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills, + agents: [], + hooks: [], + config: {}, + steps: [], + contributions: [], + gates: [], + }; +} + +/** Write a global-scope overlay bundle at /.gsd/capabilities//. */ +function writeGlobalBundle(home, id, skills) { + const dir = path.join(home, '.gsd', 'capabilities', id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(skillsOnlyCap(id, skills)), 'utf8'); + // Materialize each declared skill so the bundle mirrors a real install. + for (const stem of skills) { + const skillDir = path.join(dir, 'skills', stem); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\ndescription: ${stem}\n---\n# ${stem}\n`, 'utf8'); + } + return dir; +} + +/** A temp runtime config dir with NO .gsd-profile marker → default 'full' profile. */ +function makeRcd() { + return tmpDir('issue2045-rcd-'); +} + +/** A temp cwd with .planning/config.json so project-root resolution is hermetic. */ +function makeCwd() { + const cwd = tmpDir('issue2045-cwd-'); + fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{}'); + return cwd; +} + +/** GSD_HOME-sandboxed env that also neutralizes ambient GSD_ vars (hermeticity). */ +function scopeEnv(home) { + return { GSD_HOME: home, GSD_WORKSTREAM: '', GSD_PROJECT: '' }; +} + +const savedGsdHome = process.env.GSD_HOME; + +// ─── AC1 [D1]: resolveSurface unions registry.capabilityClusters ───────────── + +describe('issue #2045 AC1 — resolveSurface includes third-party cap skills [D1]', () => { + test('a composed registry surfaces third-party cap skills without on-disk linking', () => { + const home = tmpDir('issue2045-home-'); + writeGlobalBundle(home, CAP_ID, CAP_SKILLS); + const rcd = makeRcd(); + const cwd = makeCwd(); + process.env.GSD_HOME = home; + try { + // Composed overlay-aware registry (the loader composes ACTIVE overlay caps). + const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: home }); + const clusters = (registry && registry.capabilityClusters) || {}; + assert.ok(Array.isArray(clusters[CAP_ID]), `fixture cap "${CAP_ID}" must own skills in capabilityClusters (loader did not accept the overlay — check engines/validation)`); + + // Empty manifest + composed registry: in the 'full' profile the skills Set + // is materialized from the manifest (empty here) THEN, after the fix, unioned + // with capabilityClusters values. Third-party skills are NOT on disk in rcd, + // so they can only appear via the registry union. + const surface = resolveSurface(rcd, new Map(), undefined, registry); + assert.ok(surface.skills instanceof Set, 'resolveSurface returns a skills Set'); + for (const stem of CAP_SKILLS) { + assert.ok( + surface.skills.has(stem), + `AC1: third-party skill "${stem}" must be in the surfaced Set (no on-disk linking) — got [${[...surface.skills].join(', ')}]`, + ); + } + } finally { + process.env.GSD_HOME = savedGsdHome; + } + }); +}); + +// ─── AC2: capability state reports present + surfaced:true ─────────────────── + +describe('issue #2045 AC2 — capability state reports surfaced:true', () => { + test('resolveCapabilityRuntimeState surfaces an installed third-party skills cap', () => { + const home = tmpDir('issue2045-home-'); + writeGlobalBundle(home, CAP_ID, CAP_SKILLS); + const rcd = makeRcd(); + const cwd = makeCwd(); + process.env.GSD_HOME = home; + try { + const state = resolveCapabilityRuntimeState(cwd, rcd); + const cap = state.capabilities.find((c) => c.id === CAP_ID); + assert.ok(cap, `AC2: capability state must list "${CAP_ID}" as present`); + assert.equal(cap.surfaced, true, 'AC2: surfaced must be true'); + assert.equal(cap.installed, true, 'AC2: installed must be true (default full profile)'); + // No activationKey on the fixture → active === enabled. + assert.equal(cap.active, true, 'AC2: active must be true (no config gate)'); + } finally { + process.env.GSD_HOME = savedGsdHome; + } + }); +}); + +// ─── AC3 [D2]: enable/set does NOT error "unknown capability" ──────────────── + +describe('issue #2045 AC3 — enable/set accepts an installed third-party cap [D2]', () => { + test('setCapabilityState({enabled:true}) on an installed third-party cap is not "unknown"', () => { + const home = tmpDir('issue2045-home-'); + writeGlobalBundle(home, CAP_ID, CAP_SKILLS); + const rcd = makeRcd(); + const cwd = makeCwd(); + process.env.GSD_HOME = home; + try { + const result = setCapabilityState(cwd, rcd, [{ id: CAP_ID, enabled: true }]); + const unknownErr = (result.errors || []).find((e) => /unknown capability/i.test(String(e))); + assert.ok(!unknownErr, `AC3: must not error "unknown capability" for installed third-party cap — got errors: ${JSON.stringify(result.errors)}`); + const cap = (result.capabilities || []).find((c) => c.id === CAP_ID); + assert.ok(cap, 'AC3: result must include the third-party cap'); + } finally { + process.env.GSD_HOME = savedGsdHome; + } + }); + + test('AC5 (regression): a truly-unknown id is STILL rejected as "unknown capability"', () => { + const rcd = makeRcd(); + const cwd = makeCwd(); + const home = tmpDir('issue2045-home-empty-'); + process.env.GSD_HOME = home; + try { + const result = setCapabilityState(cwd, rcd, [{ id: 'nonexistent-cap-2045', enabled: true }]); + const unknownErr = (result.errors || []).find((e) => /unknown capability/i.test(String(e))); + assert.ok(unknownErr, 'AC5: truly-unknown id must still be rejected (writer validates against composed registry, not "accept all")'); + } finally { + process.env.GSD_HOME = savedGsdHome; + } + }); + + test('AC5 (regression): a first-party cap still resolves and surfaces', () => { + const rcd = makeRcd(); + const cwd = makeCwd(); + const home = tmpDir('issue2045-home-empty-'); + process.env.GSD_HOME = home; + try { + const state = resolveCapabilityRuntimeState(cwd, rcd); + // 'ui' is a first-party skill-owning capability; it must still be present. + const ui = state.capabilities.find((c) => c.id === 'ui'); + assert.ok(ui, 'AC5: first-party "ui" capability still present'); + } finally { + process.env.GSD_HOME = savedGsdHome; + } + }); +}); + +// ─── AC4 [D3]: capability list reflects surface state (list/state agree) ───── + +describe('issue #2045 AC4 — capability list reflects surface state [D3]', () => { + test('an installed skills cap: list `surfaced` agrees with `capability state`', () => { + const home = tmpDir('issue2045-home-'); + const cwd = makeCwd(); + + // Build a local source dir that declares skills AND materializes them, then + // install it globally — the real end-to-end path the reporter used. + const src = tmpDir('issue2045-src-'); + const cap = skillsOnlyCap(CAP_ID, CAP_SKILLS); + fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap), 'utf8'); + for (const stem of CAP_SKILLS) { + const skillDir = path.join(src, 'skills', stem); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\ndescription: ${stem}\n---\n# ${stem}\n`, 'utf8'); + } + + const installRes = runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], cwd, scopeEnv(home)); + assert.equal(installRes.success, true, `install failed: ${installRes.error || installRes.output}`); + + // capability list --json must include a `surfaced` field for the overlay row. + const listRes = runGsdTools(['capability', 'list', '--json'], cwd, scopeEnv(home)); + assert.equal(listRes.success, true, `list failed: ${listRes.error || listRes.output}`); + const listRows = JSON.parse(listRes.output); + const listRow = listRows.find((r) => r.id === CAP_ID); + assert.ok(listRow, 'AC4: installed cap present in list'); + assert.ok( + Object.prototype.hasOwnProperty.call(listRow, 'surfaced'), + `AC4: list row must carry a 'surfaced' field reflecting surface composition — got keys: ${Object.keys(listRow).join(', ')}`, + ); + assert.equal(listRow.surfaced, true, 'AC4: list surfaced === true (skills resolved via registry union)'); + + // capability state --json must agree. + const stateRes = runGsdTools(['capability', 'state', CAP_ID, '--json'], cwd, scopeEnv(home)); + assert.equal(stateRes.success, true, `state failed: ${stateRes.error || stateRes.output}`); + const stateObj = JSON.parse(stateRes.output); + const stateCap = (stateObj.capabilities || []).find((c) => c.id === CAP_ID); + assert.ok(stateCap, 'AC4: capability state must list the cap'); + assert.equal(stateCap.surfaced, true, 'AC4: state surfaced === true'); + + // The agreement the reporter asked for: list.surfaced === state.surfaced. + assert.equal(listRow.surfaced, stateCap.surfaced, 'AC4: list and state must AGREE on surfaced'); + }); +});