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:
5
.changeset/clever-lemurs-march.md
Normal file
5
.changeset/clever-lemurs-march.md
Normal file
@@ -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`.
|
||||||
@@ -2140,6 +2140,28 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* best-effort — list still works without the inactive annotation */ }
|
} 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 <id>` 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)) {
|
for (const capId of Object.keys(fp)) {
|
||||||
const cap = fp[capId] || {};
|
const cap = fp[capId] || {};
|
||||||
rows.push({
|
rows.push({
|
||||||
@@ -2150,6 +2172,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|||||||
source: 'first-party',
|
source: 'first-party',
|
||||||
scope: 'first-party',
|
scope: 'first-party',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
surfaced: Object.prototype.hasOwnProperty.call(surfacedById, capId) ? surfacedById[capId] === true : null,
|
||||||
title: cap.title || null,
|
title: cap.title || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2199,6 +2222,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|||||||
scope: sc,
|
scope: sc,
|
||||||
status,
|
status,
|
||||||
reason,
|
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,
|
title: manifest.title || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,8 +151,17 @@ function setCapabilityState(
|
|||||||
const resolvedConfigDir = before.runtimeConfigDir;
|
const resolvedConfigDir = before.runtimeConfigDir;
|
||||||
|
|
||||||
// ── Load registry ─────────────────────────────────────────────────────────
|
// ── 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
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
|
||||||
|
const registry = loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] });
|
||||||
const capabilitiesMap = (
|
const capabilitiesMap = (
|
||||||
registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
|
registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
|
||||||
? registry['capabilities']
|
? registry['capabilities']
|
||||||
|
|||||||
@@ -261,6 +261,33 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]
|
|||||||
for (const [key] of skillManifest) {
|
for (const [key] of skillManifest) {
|
||||||
if (!key.startsWith('_calls_agents_')) skills.add(key);
|
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 {
|
} else {
|
||||||
skills = new Set(baseResolved.skills);
|
skills = new Set(baseResolved.skills);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
"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/gsd_run": "62d9b647ede212e6",
|
||||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||||
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
"gsd-core/bin/shared/config-schema.manifest.json": "1318633d27964e7a",
|
||||||
|
|||||||
265
tests/issue-2045-third-party-skills-surface.test.cjs
Normal file
265
tests/issue-2045-third-party-skills-surface.test.cjs
Normal file
@@ -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/<id>/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 <home>/.gsd/capabilities/<id>/. */
|
||||||
|
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 <id> --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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user