Make install + surface read the registry's derived profileMembership/ capabilityClusters so a capability's tier drives what installs + surfaces. resolveProfile (when given the registry) unions capability skills for the profiles its tier implies before the requires: closure; resolveSurface merges capabilityClusters into the cluster map. bin/install.js, /gsd:surface, and the capability-state resolver all thread the registry. Shipped as a proven no-op: the UI capability is reconciled to tier:full (its skills were full-only in the hand-authored profiles), so it contributes only to the full profile (already the '*' sentinel) and core/standard are unchanged. Equivalence tests prove resolveProfile/resolveSurface/listSurface/staging/ capability-state are identical with vs without the registry; the core-alias staging path is verified equivalent (empty manifest → raw PROFILES.core). Closes #949 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -330,6 +330,14 @@ function cmdCapabilityState(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load registry (ADR-857 phase 4c) ────────────────────────────────────────
|
||||
// Load BEFORE resolveProfile and resolveSurface so both calls receive the
|
||||
// registry and capability-contributed skills are reflected in installed/surfaced.
|
||||
// No-op today (UI capability is tier:full → only adds to 'full', which returns
|
||||
// '*' regardless) but cutover-ready for future tier:core/standard capabilities.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
|
||||
// ── Resolve installed skills (from install profile) ──────────────────────────
|
||||
// Distinguish "no profile marker → default full" (legitimate) from a thrown
|
||||
// error (surface as a warning and degrade gracefully — do NOT silently report
|
||||
@@ -342,6 +350,7 @@ function cmdCapabilityState(
|
||||
const resolvedInstall = resolveProfile({
|
||||
modes: profileName.split(',').map((s: string) => s.trim()),
|
||||
manifest,
|
||||
registry,
|
||||
});
|
||||
installedSkills = resolvedInstall.skills;
|
||||
} catch (err: unknown) {
|
||||
@@ -358,7 +367,7 @@ function cmdCapabilityState(
|
||||
try {
|
||||
const commandsGsdDir = _resolveCommandsGsdDir();
|
||||
const manifest = loadSkillsManifest(commandsGsdDir);
|
||||
const surfaceResult = resolveSurface(resolvedConfigDir, manifest);
|
||||
const surfaceResult = resolveSurface(resolvedConfigDir, manifest, undefined, registry);
|
||||
// resolveSurface returns { name, skills: Set<string>, agents: Set<string> }
|
||||
// (always a concrete Set — full profile is materialized)
|
||||
surfacedSkills = surfaceResult.skills instanceof Set
|
||||
@@ -380,9 +389,7 @@ function cmdCapabilityState(
|
||||
config = {};
|
||||
}
|
||||
|
||||
// ── Load registry and resolve state ─────────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
// ── Resolve state ────────────────────────────────────────────────────────────
|
||||
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
|
||||
@@ -163,16 +163,53 @@ interface ResolvedProfile {
|
||||
agents: Set<string>;
|
||||
}
|
||||
|
||||
interface CapabilityRegistry {
|
||||
capabilityClusters?: Record<string, string[]>;
|
||||
profileMembership?: Record<string, { tier: string; profiles: string[] }>;
|
||||
}
|
||||
|
||||
interface ResolveProfileOpts {
|
||||
modes?: string[];
|
||||
manifest?: Map<string, string[]>;
|
||||
_profilesOverride?: Record<string, string | readonly string[]>;
|
||||
/** ADR-857 phase 4c: optional capability registry; when present, capability
|
||||
* skills are unioned into the base set for each resolved mode before closure. */
|
||||
registry?: CapabilityRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the capability skills to add for a given profile mode from the registry.
|
||||
* Returns an array of skill stems contributed by capabilities whose profileMembership
|
||||
* includes the given mode. Guards against prototype pollution and malformed registry.
|
||||
*/
|
||||
function _capabilitySkillsForMode(mode: string, registry: CapabilityRegistry): string[] {
|
||||
const BANNED = ['__proto__', 'constructor', 'prototype'];
|
||||
const clusters = registry.capabilityClusters;
|
||||
const membership = registry.profileMembership;
|
||||
if (!clusters || typeof clusters !== 'object' || !membership || typeof membership !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const result: string[] = [];
|
||||
for (const capId of Object.keys(clusters)) {
|
||||
if (BANNED.includes(capId)) continue;
|
||||
const mem = membership[capId];
|
||||
if (!mem || typeof mem !== 'object') continue;
|
||||
const profiles = mem.profiles;
|
||||
if (!Array.isArray(profiles)) continue;
|
||||
if (!profiles.includes(mode)) continue;
|
||||
const skills = clusters[capId];
|
||||
if (!Array.isArray(skills)) continue;
|
||||
for (const s of skills) {
|
||||
if (typeof s === 'string' && s.length > 0) result.push(s);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a profile (or composed profiles) to a typed result object.
|
||||
*/
|
||||
function resolveProfile({ modes, manifest, _profilesOverride }: ResolveProfileOpts = {}): ResolvedProfile {
|
||||
function resolveProfile({ modes, manifest, _profilesOverride, registry }: ResolveProfileOpts = {}): ResolvedProfile {
|
||||
const profiles: Record<string, string | readonly string[]> = _profilesOverride || PROFILES;
|
||||
const activeModes = (modes && modes.length > 0) ? modes : ['full'];
|
||||
const normalizedModes = activeModes
|
||||
@@ -201,7 +238,11 @@ function resolveProfile({ modes, manifest, _profilesOverride }: ResolveProfileOp
|
||||
// This profile is full — sentinel short-circuit
|
||||
return { name: 'full', skills: '*', agents: new Set() };
|
||||
}
|
||||
const closure = computeClosure(base as Iterable<string>, man);
|
||||
// ADR-857 phase 4c: union capability skills for this mode BEFORE closure so
|
||||
// their requires: chains expand too.
|
||||
const capSkills = registry ? _capabilitySkillsForMode(mode, registry) : [];
|
||||
const baseWithCap: string[] = [...(base as Iterable<string>), ...capSkills];
|
||||
const closure = computeClosure(baseWithCap, man);
|
||||
for (const s of closure) unionSkills.add(s);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,18 @@
|
||||
* Exports:
|
||||
* readSurface(runtimeConfigDir)
|
||||
* writeSurface(runtimeConfigDir, surfaceState)
|
||||
* resolveSurface(runtimeConfigDir, manifest, clusterMap)
|
||||
* applySurface(runtimeConfigDir, layout, manifest, clusterMap)
|
||||
* listSurface(runtimeConfigDir, manifest, clusterMap)
|
||||
* resolveSurface(runtimeConfigDir, manifest, clusterMap?, registry?)
|
||||
* applySurface(runtimeConfigDir, layout, manifest, clusterMap?, registry?)
|
||||
* listSurface(runtimeConfigDir, manifest, clusterMap?, registry?)
|
||||
* pruneSkillDirs(skillsDir, retainedNames, prefix, manifest)
|
||||
*
|
||||
* The optional `registry` param (ADR-857 phase 4c) accepts the capability-registry
|
||||
* object. When present, capability clusters are merged into the effective cluster
|
||||
* map and the registry is threaded into resolveProfile so capability-contributed
|
||||
* skills participate in the base set and disable-ability. Absent or undefined
|
||||
* leaves behaviour identical to the pre-registry path (no-op for current registry
|
||||
* where UI=full and the full profile returns '*' regardless).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/surface.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
* from the prior hand-written .cjs; only types are added.
|
||||
@@ -124,9 +131,9 @@ function clustersToSkills(clusterNames: string[], clusterMap: ClusterMap | Recor
|
||||
const result = new Set<string>();
|
||||
for (const name of clusterNames) {
|
||||
const members = (clusterMap as Record<string, ReadonlyArray<string> | undefined>)[name];
|
||||
if (members) {
|
||||
for (const s of members) result.add(s);
|
||||
}
|
||||
// FIX 5: guard against non-iterable members — malformed registry must never throw
|
||||
if (!Array.isArray(members)) continue;
|
||||
for (const s of (members as string[])) result.add(s);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -174,9 +181,46 @@ function normalizeSkillManifest(runtimeConfigDir: string, manifest: Map<string,
|
||||
* 2. Remove skills in disabled clusters
|
||||
* 3. Add explicitAdds (and their transitive closure)
|
||||
* 4. Remove explicitRemoves (only the stem itself, no cascade)
|
||||
*
|
||||
* ADR-857 phase 4c: optional registry param. When present:
|
||||
* - capability clusters are merged into the effective cluster map so
|
||||
* capability-owned skill groups are disable-able.
|
||||
* - registry is threaded into resolveProfile so capability skills
|
||||
* participate in the base skill set and their requires: chains expand.
|
||||
*/
|
||||
function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
const cm = clusterMap || CLUSTERS;
|
||||
function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
// Merge capability clusters into the cluster map when registry is provided.
|
||||
// The ADR-857 phase 4a HARD gate guarantees that when a capId matches a CLUSTERS
|
||||
// key, the values are EQUAL — so the spread is idempotent for matching names.
|
||||
// Defense-in-depth: if a capId collides with a hand-authored CLUSTERS key AND
|
||||
// the values DIFFER (future drift bypassing the gate), prefer the hand-authored
|
||||
// value so disable behavior is never silently changed by a stale registry entry.
|
||||
// Also guard: skip entries whose value is not a string[] (malformed registry).
|
||||
let cm: ClusterMap | Record<string, string[]> = clusterMap || CLUSTERS;
|
||||
if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') {
|
||||
const baseCm = cm;
|
||||
const capClusters = registry.capabilityClusters;
|
||||
const merged: Record<string, string[]> = { ...(baseCm as Record<string, string[]>) };
|
||||
for (const capId of Object.keys(capClusters)) {
|
||||
const val = capClusters[capId];
|
||||
// FIX 5: skip malformed (non-array) entries — never throw on bad registry
|
||||
if (!Array.isArray(val)) continue;
|
||||
// FIX 4: if the capId matches an existing cluster key, only override when
|
||||
// the values are identical (guaranteed by 4a gate). If they differ, the
|
||||
// hand-authored value wins — prefer known-correct disable behavior over
|
||||
// a potentially stale registry entry.
|
||||
if (Object.prototype.hasOwnProperty.call(baseCm, capId)) {
|
||||
const existing = (baseCm as Record<string, readonly string[] | string[]>)[capId];
|
||||
if (!Array.isArray(existing)) { merged[capId] = val; continue; }
|
||||
// Values differ → hand-authored wins (skip the override)
|
||||
if (existing.length !== val.length || existing.some((v, i) => v !== val[i])) continue;
|
||||
}
|
||||
// Prototype-pollution guard (parity with _capabilitySkillsForMode in install-profiles.cts)
|
||||
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
|
||||
merged[capId] = val;
|
||||
}
|
||||
cm = merged;
|
||||
}
|
||||
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
|
||||
const surface = readSurface(runtimeConfigDir);
|
||||
|
||||
@@ -185,10 +229,11 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]
|
||||
? surface.baseProfile
|
||||
: (readActiveProfile(runtimeConfigDir) || 'full');
|
||||
|
||||
// Resolve base profile
|
||||
// Resolve base profile — thread registry so capability skills are included.
|
||||
const baseResolved = resolveProfile({
|
||||
modes: baseProfileName.split(',').map((s: string) => s.trim()),
|
||||
manifest: skillManifest,
|
||||
registry,
|
||||
});
|
||||
|
||||
// If full, we need to enumerate all skills from the manifest
|
||||
@@ -252,12 +297,12 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]
|
||||
* Re-stage the active surface using the resolved layout.
|
||||
* Iterates layout.kinds and syncs each artifact kind to its destination.
|
||||
*/
|
||||
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) {
|
||||
throw new TypeError('applySurface runtimeConfigDir must match layout.configDir');
|
||||
}
|
||||
const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
|
||||
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap);
|
||||
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry);
|
||||
// Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
|
||||
// so SKILL.md bodies reference the install target (pathPrefix), not the
|
||||
// converter's default ~/.claude paths (#813). Computed lazily so command-only
|
||||
@@ -461,9 +506,9 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st
|
||||
* Token cost = sum of description lengths ÷ 4 (mirrors audit script).
|
||||
* Descriptions are read from the install source (findInstallSourceRoot).
|
||||
*/
|
||||
function listSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { enabled: string[]; disabled: string[]; tokenCost: number } {
|
||||
function listSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { enabled: string[]; disabled: string[]; tokenCost: number } {
|
||||
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
|
||||
const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap);
|
||||
const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap, registry);
|
||||
|
||||
// All known stems from manifest (exclude _calls_agents_ meta keys)
|
||||
const allStems: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user