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

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