feat(#949): install/surface consume derived profiles/clusters (ADR-857 phase 4c) (#954)

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:
Tom Boucher
2026-06-09 22:35:42 -04:00
committed by GitHub
parent e006ff74fd
commit dc139b38e0
9 changed files with 982 additions and 80 deletions

View File

@@ -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,

View File

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

View File

@@ -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[] = [];