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

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