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/<id>/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).
This commit is contained in:
@@ -261,6 +261,33 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]
|
||||
for (const [key] of skillManifest) {
|
||||
if (!key.startsWith('_calls_agents_')) skills.add(key);
|
||||
}
|
||||
// Issue #2045 (DEFECT 1): third-party capability skills live at
|
||||
// ~/.gsd/capabilities/<id>/skills/<stem>/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<string, unknown>)[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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user