feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2) (#1440)

* feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2)

Promote the registry from a frozen data file to loadRegistry({includeInstalled}),
composing the first-party registry with a validated installed overlay (ADR-1244 D2):

- Extract the conformance validator to a shared runtime-callable module
  (gsd-core/bin/lib/capability-validator.cjs); the generator re-exports it
  verbatim, guarded by a generative-parity test (no build-time/runtime drift).
- capability-loader.cts: loadRegistry({includeInstalled}) composes first-party
  ∪ validated overlay from $GSD_HOME/.gsd/capabilities (global) and
  <root>/.gsd/capabilities (project) via the canonical buildRegistry. First-party
  always wins (id/skill/agent/config/command-family + reserved gsd-/anthropic-
  prefixes); full merged-set cross-capability validation; engines.gsd load-time
  re-gate (skip-with-warning); gate-kind capabilities FAIL CLOSED; fragment-path
  escapes rejected.
- semverSatisfies (hand-written, no dep) for the engines.gsd gate, fail-closed.
- Wire surface/state + loop to the overlay; loop injects a blocking gate for each
  skipped gate-kind overlay (fail-closed).
- cwd-aware overlay config-key federation: config-loader _federatedConfigSchema(cwd)
  + config-schema isValidConfigKey(key, cwd) compose the overlay per loadConfig/
  config-set call (never eager at module load, never wrong-cwd); first-party path
  unchanged with no cwd.
- run-tests.cjs sandboxes GSD_HOME (idempotent — nested spawns reuse it) for test
  hermeticity; capability-loader.cjs git+eslint-ignored (tsc artifact);
  capability-validator.cjs stays linted (#551 migration coverage).

Closes #1431

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1431): add changeset for runtime capability registry overlay

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1431): kill config-schema cwd-aware federation mutants (Stryker ≥52)

The cwd-aware overlay config-key federation added to config-schema.cts
(_capabilityConfigSchema(cwd) + isCapabilityConfigKey/isValidConfigKey cwd
threading) introduced mutable surface uncovered by config-schema's mutation
test set, dropping its score to 39.58% (below the 52 break threshold). Add a
real-overlay-fixture describe block exercising every branch (cwd guard, overlay
loadRegistry, found-branch, first-party fallback, cwd threading); local Stryker
score 39.58% -> 77.08%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-18 14:41:20 -04:00
committed by GitHub
parent 2421cf1b4a
commit 353f63d170
25 changed files with 3389 additions and 1929 deletions

367
src/capability-loader.cts Normal file
View File

@@ -0,0 +1,367 @@
/**
* capability-loader.cts — runtime Capability Registry overlay (ADR-1244 D2).
*
* Promotes the registry from a frozen data file to a module with an interface:
*
* loadRegistry({ includeInstalled }) -> composed registry
*
* It composes the **first-party frozen registry** (the committed, generated
* `capability-registry.cjs`) with a **validated installed overlay** — third-party
* capability manifests read at runtime from per-scope install roots:
* - global: $GSD_HOME/.gsd/capabilities/<id>/capability.json (GSD_HOME defaults to ~)
* - project: <projectRoot>/.gsd/capabilities/<id>/capability.json
*
* Invariants enforced over the merged set (first-party ∪ overlay):
* - First-party always wins: an overlay whose `id`, owned skill/agent stem, or
* federated config key collides with first-party (or uses a reserved `gsd-` /
* `gsd-core-` / `anthropic-` id prefix) is rejected.
* - Load-time re-gate (default-resilient): an overlay that fails validation or
* whose `engines.gsd` does not satisfy the running GSD version is SKIPPED
* with a warning — it never crashes the loop. EXCEPTION (per-hook-kind
* policy): a skipped capability that declares a `gate` is recorded in
* `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for
* that gate rather than silently proceeding as if it had passed.
*
* The merged registry is materialized by the canonical `buildRegistry`
* (re-exported from the generator, which ships) over a cap-map reconstructed
* from the frozen registry's capability objects plus the accepted overlay
* capabilities — so every derived view (bySkill, byLoopPoint, configSchema,
* capabilityClusters, profileMembership, …) is computed by exactly one builder
* and cannot drift from the first-party path.
*
* Install never executes capability code here (staging/exec belongs to ADR-1244
* D3/D5); this module only READS and VALIDATES declarations.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
type Registry = Record<string, unknown>;
interface CapManifest {
id: string;
role?: string;
version?: string;
skills?: string[];
agents?: string[];
commands?: Array<Record<string, unknown>>;
config?: Record<string, unknown>;
gates?: unknown[];
engines?: { gsd?: string };
}
interface ValidatorModule {
validateCapability: (cap: unknown, id: string) => string[];
/** Returns an error array (e.g. fragment path escapes the capability dir) — NOT a throw. */
materializeHookFragments: (cap: unknown, capDir: string) => string[];
validateAgainstContract: (cap: unknown, capId: string) => string[];
validateConsumesGlobal: (capMap: Map<string, unknown>) => string[];
validateCrossCapability: (capMap: Map<string, unknown>, centralKeys: Set<string>) => string[];
}
interface SemverModule {
semverSatisfies: (version: unknown, range: unknown) => boolean;
}
interface ProjectRootModule {
findProjectRoot: (startDir: string) => string | null;
}
interface GeneratorModule {
buildRegistry: (capMap: Map<string, unknown>) => Registry;
loadCentralConfigKeys: () => Set<string>;
}
export interface LoadRegistryOptions {
/** When true, compose the validated installed overlay on top of first-party. */
includeInstalled?: boolean;
/** Working directory used to locate the project-scoped overlay root. */
cwd?: string;
/** Override the global overlay home (defaults to GSD_HOME env or os.homedir()). */
gsdHome?: string;
/** Override the running GSD version used for engines.gsd satisfaction. */
hostVersion?: string;
}
export interface OverlaySkip {
id: string;
scope: 'global' | 'project';
reason: string;
}
export interface BlockedGate {
/** Loop extension point the skipped capability declared a gate at. */
point: string;
/** The skipped capability's id. */
capId: string;
/** Why the capability was skipped. */
reason: string;
}
export interface OverlayMeta {
/** Capabilities skipped at load, with the reason (surfaced to the user). */
warnings: OverlaySkip[];
/** Skipped capabilities that declared a gate — the loop must fail CLOSED for these. */
incompatibleGateCapIds: string[];
/**
* Per-point fail-closed records: for each gate a skipped capability declared at
* a known loop point, the loop resolver must inject a blocking gate at that
* point rather than proceeding as if the gate had passed.
*/
blockedGates: BlockedGate[];
}
const RESERVED_ID_PREFIX = /^(gsd-|gsd-core-|anthropic-)/;
const GSD_HOME_DIRNAME = '.gsd';
function errMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** Resolve the running GSD version; fail-closed to '0.0.0' if it cannot be read. */
function readHostVersion(): string {
try {
// gsd-core/bin/lib/ -> repo/package root is three levels up.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const pkg: { version?: string } = require('../../../package.json');
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
} catch {
return '0.0.0';
}
}
/**
* The ordered overlay install roots (global first, then project), deduped by
* resolved absolute path so a single directory is never scanned twice (which
* would otherwise self-report a spurious id collision when the project lives
* under the GSD home, or in tests where both resolve to the same fixture).
*/
function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope: 'global' | 'project' }> {
const roots: Array<{ dir: string; scope: 'global' | 'project' }> = [];
const seen = new Set<string>();
const add = (dir: string, scope: 'global' | 'project'): void => {
const resolved = path.resolve(dir);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push({ dir: resolved, scope });
};
const home = gsdHome || process.env['GSD_HOME'] || os.homedir();
add(path.join(home, GSD_HOME_DIRNAME, 'capabilities'), 'global');
let projectRoot: string | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const projectRootMod: ProjectRootModule = require('./project-root.cjs');
projectRoot = projectRootMod.findProjectRoot(cwd);
} catch {
projectRoot = null;
}
if (projectRoot) {
add(path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities'), 'project');
}
return roots;
}
/** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */
function withOverlayMeta(reg: Registry, meta: OverlayMeta): Registry {
return Object.assign({}, reg, { _overlay: meta });
}
/**
* Load the capability registry, optionally composing the installed overlay.
*
* @returns the registry object (same shape as `capability-registry.cjs`). When
* overlays are considered, an `_overlay` field carries skip warnings and the
* fail-closed gate list. With `includeInstalled` falsy, the frozen first-party
* registry is returned unchanged (identity-stable).
*/
export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const base: Registry = require('./capability-registry.cjs');
if (!options.includeInstalled) return base;
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const validator: ValidatorModule = require('./capability-validator.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const semver: SemverModule = require('./semver-compare.cjs');
const cwd = options.cwd || process.cwd();
const hostVersion = options.hostVersion || readHostVersion();
const warnings: OverlaySkip[] = [];
const incompatibleGateCapIds: string[] = [];
const blockedGates: BlockedGate[] = [];
const overlayCaps: CapManifest[] = [];
// First-party reservations — first-party always wins.
const fpCaps = (base.capabilities ?? {}) as Record<string, unknown>;
const fpBySkill = (base.bySkill ?? {}) as Record<string, unknown>;
const fpByAgent = (base.byAgent ?? {}) as Record<string, unknown>;
const fpConfigKeys = (base.configKeys ?? {}) as Record<string, unknown>;
const fpConfigSchema = (base.configSchema ?? {}) as Record<string, unknown>;
const fpFamilies = (base.commandFamilies ?? {}) as Record<string, unknown>;
const fpIds = new Set(Object.keys(fpCaps));
const claimedSkills = new Set(Object.keys(fpBySkill));
const claimedAgents = new Set(Object.keys(fpByAgent));
const claimedConfig = new Set([...Object.keys(fpConfigKeys), ...Object.keys(fpConfigSchema)]);
const claimedFamilies = new Set(Object.keys(fpFamilies));
const acceptedIds = new Set<string>();
// Running merged cap-map (first-party ∪ accepted overlays). A candidate is
// accepted only if the FULL cross-capability suite stays clean after adding it
// (first-party alone is clean, so any new error is the candidate's fault) — the
// overlay can never violate the same invariants the build-time generator enforces.
const acceptedMap = new Map<string, unknown>(Object.entries(fpCaps));
// Generator (buildRegistry + central config keys) loaded lazily — only when at
// least one overlay candidate exists, so the no-overlay fast path stays cheap.
let generatorMod: GeneratorModule | null = null;
const getGenerator = (): GeneratorModule => {
if (generatorMod) return generatorMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const mod: GeneratorModule = require('../../../scripts/gen-capability-registry.cjs');
generatorMod = mod;
return mod;
};
let centralKeys: Set<string> | null = null;
const getCentralKeys = (): Set<string> => {
if (!centralKeys) {
try {
centralKeys = getGenerator().loadCentralConfigKeys();
} catch {
centralKeys = new Set<string>();
}
}
return centralKeys;
};
for (const root of overlayRoots(cwd, options.gsdHome)) {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(root.dir, { withFileTypes: true });
} catch {
continue; // no overlay dir at this scope — normal
}
for (const ent of entries) {
if (!ent.isDirectory()) continue;
const id = ent.name;
const capDir = path.join(root.dir, id);
const manifestPath = path.join(capDir, 'capability.json');
let cap: CapManifest;
try {
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as CapManifest;
} catch (e) {
warnings.push({ id, scope: root.scope, reason: 'unreadable or invalid capability.json: ' + errMessage(e) });
continue;
}
// Points at which this capability declares a gate — used to fail CLOSED if
// the capability is skipped (a skipped deploy gate must block, not pass).
const gatePoints: string[] = Array.isArray(cap.gates)
? (cap.gates as Array<Record<string, unknown>>)
.map((g) => (g && typeof g === 'object' && typeof g.point === 'string' ? g.point : null))
.filter((p): p is string => typeof p === 'string')
: [];
const declaresGate = gatePoints.length > 0;
const skip = (reason: string): void => {
warnings.push({ id, scope: root.scope, reason });
if (declaresGate) {
incompatibleGateCapIds.push(id);
for (const point of gatePoints) blockedGates.push({ point, capId: id, reason });
}
};
// 1. Reserved namespace — third-party may not impersonate first-party.
if (RESERVED_ID_PREFIX.test(id)) {
skip('id uses a reserved first-party prefix (gsd-/gsd-core-/anthropic-)');
continue;
}
// 2. Per-capability structural + version-envelope validation.
const errs = validator.validateCapability(cap, id);
if (errs.length) {
skip('failed validation: ' + errs.join('; '));
continue;
}
// 3. First-party wins + overlay/overlay de-dup on id, skill, agent, config key.
if (fpIds.has(id) || acceptedIds.has(id)) {
skip('id collides with an already-registered capability');
continue;
}
const skills: string[] = Array.isArray(cap.skills) ? cap.skills : [];
const agents: string[] = Array.isArray(cap.agents) ? cap.agents : [];
const cfgKeys: string[] = cap.config && typeof cap.config === 'object' && !Array.isArray(cap.config)
? Object.keys(cap.config) : [];
const skillClash = skills.find((s) => claimedSkills.has(s));
if (skillClash) { skip('owns skill "' + skillClash + '" already owned by another capability'); continue; }
const agentClash = agents.find((a) => claimedAgents.has(a));
if (agentClash) { skip('owns agent "' + agentClash + '" already owned by another capability'); continue; }
const cfgClash = cfgKeys.find((k) => claimedConfig.has(k));
if (cfgClash) { skip('owns config key "' + cfgClash + '" already owned by another capability'); continue; }
const families: string[] = Array.isArray(cap.commands)
? cap.commands
.map((c) => (c && typeof c === 'object' && typeof c.family === 'string' ? c.family : null))
.filter((f): f is string => typeof f === 'string')
: [];
const familyClash = families.find((f) => claimedFamilies.has(f));
if (familyClash) { skip('owns command family "' + familyClash + '" already owned by another capability'); continue; }
// 4. Load-time engines.gsd re-gate.
const range = cap.engines?.gsd;
if (typeof range === 'string' && range && !semver.semverSatisfies(hostVersion, range)) {
skip('incompatible with GSD ' + hostVersion + ' (requires engines.gsd "' + range + '")');
continue;
}
// 5. Materialize path-based hook fragments (resolved against the overlay dir).
// materializeHookFragments RETURNS errors (e.g. a fragment path escaping the
// capability dir) — capture them; an un-materializable fragment is a skip.
let fragErrs: string[];
try {
fragErrs = validator.materializeHookFragments(cap, capDir) || [];
} catch (e) {
skip('hook fragment could not be materialized: ' + errMessage(e));
continue;
}
if (fragErrs.length) {
skip('invalid hook fragment: ' + fragErrs.join('; '));
continue;
}
// 6. Full cross-capability validation over the merged set (the same invariants
// the build-time generator enforces): contract roles, consumes-satisfiability,
// owner-uniqueness, config-key exclusivity vs central schema, requires acyclicity
// + tier-monotone. Incremental: add the candidate, validate, drop on any error.
acceptedMap.set(id, cap);
const crossErrs = [
...validator.validateAgainstContract(cap, id),
...validator.validateConsumesGlobal(acceptedMap),
...validator.validateCrossCapability(acceptedMap, getCentralKeys()),
];
if (crossErrs.length) {
acceptedMap.delete(id);
skip('cross-capability validation failed: ' + crossErrs.slice(0, 3).join('; '));
continue;
}
// Accepted.
overlayCaps.push(cap);
acceptedIds.add(id);
for (const s of skills) claimedSkills.add(s);
for (const a of agents) claimedAgents.add(a);
for (const k of cfgKeys) claimedConfig.add(k);
for (const f of families) claimedFamilies.add(f);
}
}
const meta: OverlayMeta = { warnings, incompatibleGateCapIds, blockedGates };
if (overlayCaps.length === 0) {
// Nothing to compose. Return the frozen registry unchanged when there is
// also nothing to report (identity-stable); otherwise attach diagnostics.
if (warnings.length === 0) return base;
return withOverlayMeta(base, meta);
}
// Compose via the canonical builder so every derived view matches first-party.
// acceptedMap already holds first-party ∪ accepted overlays (validated above).
const merged = getGenerator().buildRegistry(acceptedMap);
return withOverlayMeta(merged, meta);
}
module.exports = { loadRegistry };

View File

@@ -477,13 +477,13 @@ function resolveCapabilityRuntimeState(
}
}
// ── 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.
// ── Load registry (ADR-1244 D2 wiring) ──────────────────────────────────────
// Load overlay-aware registry BEFORE resolveProfile and resolveSurface so both
// calls receive the composed registry and installed third-party capabilities are
// reflected in installed/surfaced state exactly like first-party capabilities.
// 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 });
// ── Resolve installed skills (from install profile) ──────────────────────────
// Distinguish "no profile marker → default full" (legitimate) from a thrown

View File

@@ -42,6 +42,10 @@ import federatedConfigModule = require('./federated-config.cjs');
const { mergeFederatedConfig } = federatedConfigModule;
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
// This is the FROZEN first-party registry — used as the test-seam default and the
// fallback. Overlay (installed third-party) config-key federation is cwd-dependent
// and composed PER loadConfig CALL by _federatedConfigSchema(cwd) below (ADR-1244 D2),
// never eagerly at module load.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const _capabilityRegistryReal: { configSchema?: Record<string, unknown> } = require('./capability-registry.cjs');
@@ -349,11 +353,33 @@ function _applyFederatedValues(
* When validKeys is non-empty, applies values into a shallow clone to avoid
* mutating shared CONFIG_DEFAULTS/module constants.
*/
// Resolve the federated capability config-schema for a project (ADR-1244 D2).
// A test override (via _setFederatedRegistryForTests) wins; otherwise, when a
// project cwd is available, compose the installed overlay for THAT project —
// LAZILY (never at module load, so a bare require never scans the filesystem and
// the result is never cached for the wrong cwd) — falling back to the frozen
// first-party schema when there is no cwd or the loader is unavailable.
function _federatedConfigSchema(cwd?: string): Record<string, unknown> | undefined {
if (_capabilityRegistry !== _capabilityRegistryReal) {
return _capabilityRegistry.configSchema; // explicit test override
}
if (typeof cwd === 'string' && cwd) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema;
if (schema && typeof schema === 'object') return schema;
} catch { /* fall back to first-party */ }
}
return _capabilityRegistryReal.configSchema;
}
function _applyFederatedOverlay(
baseConfig: Record<string, unknown>,
userConfig: Record<string, unknown>,
cwd?: string,
): Record<string, unknown> {
const _fedRegistrySchema = _capabilityRegistry.configSchema;
const _fedRegistrySchema = _federatedConfigSchema(cwd);
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
@@ -508,7 +534,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
let _preWarningFedValidKeys: string[] = [];
try {
const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema;
const _fedRegistrySchemaEarly = _federatedConfigSchema(cwd);
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
const _earlyOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchemaEarly,
@@ -613,7 +639,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
// ADR-857 phase 3b: federated config overlay
try {
if (_preWarningFedValidKeys.length > 0) {
const _fedRegistrySchema = _capabilityRegistry.configSchema;
const _fedRegistrySchema = _federatedConfigSchema(cwd);
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
@@ -651,7 +677,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
}
// Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
try {
return { config: _applyFederatedOverlay(defaults, {}), source: 'builtin-defaults', degraded: false };
return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
} catch {
return { config: defaults, source: 'builtin-defaults', degraded: false };
}
@@ -692,14 +718,14 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
};
// Branch D: global-defaults
try {
return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults), source: 'global-defaults', degraded: false };
return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false };
} catch {
return { config: _globalBaseCfg, source: 'global-defaults', degraded: false };
}
} catch {
// Branch E: no global defaults
try {
return { config: _applyFederatedOverlay(defaults, {}), source: 'builtin-defaults', degraded: false };
return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
} catch {
return { config: defaults, source: 'builtin-defaults', degraded: false };
}

View File

@@ -21,16 +21,33 @@ import {
DYNAMIC_KEY_PATTERNS,
} from './configuration.cjs';
// Frozen first-party capability config-schema — the fallback when no project cwd
// is available (cwd-agnostic call sites).
// eslint-disable-next-line @typescript-eslint/no-require-imports
const capabilityRegistry = require('./capability-registry.cjs') as {
configSchema?: Record<string, unknown>;
};
function isCapabilityConfigKey(keyPath: string): boolean {
// Resolve the capability config-schema for a project (ADR-1244 D2). When a cwd is
// supplied, compose installed overlay capabilities for THAT project — LAZILY (never
// at module load: a bare require of this module never scans the filesystem) —
// falling back to the frozen first-party schema. Without a cwd, first-party only.
function _capabilityConfigSchema(cwd?: string): Record<string, unknown> {
if (typeof cwd === 'string' && cwd) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema;
if (schema && typeof schema === 'object') return schema;
} catch { /* fall back to first-party */ }
}
const fp = capabilityRegistry.configSchema;
return fp && typeof fp === 'object' ? fp : {};
}
function isCapabilityConfigKey(keyPath: string, cwd?: string): boolean {
if (typeof keyPath !== 'string') return false;
const schema = capabilityRegistry.configSchema;
if (!schema || typeof schema !== 'object') return false;
return Object.prototype.hasOwnProperty.call(schema, keyPath);
return Object.prototype.hasOwnProperty.call(_capabilityConfigSchema(cwd), keyPath);
}
/**
@@ -48,9 +65,9 @@ function isCentralConfigKey(keyPath: string): boolean {
* Returns true if keyPath is a valid central, runtime-state, dynamic, or
* federated Capability config key.
*/
function isValidConfigKey(keyPath: string): boolean {
function isValidConfigKey(keyPath: string, cwd?: string): boolean {
if (isCentralConfigKey(keyPath)) return true;
return isCapabilityConfigKey(keyPath);
return isCapabilityConfigKey(keyPath, cwd);
}
export = {

View File

@@ -560,7 +560,7 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
validateKnownConfigKeyPath(kp);
if (!isValidConfigKey(kp)) {
if (!isValidConfigKey(kp, cwd)) {
error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
}

View File

@@ -492,9 +492,11 @@ function cmdLoopRenderHooks(
warnings?: string[];
capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>;
};
// Registry is the static generated module — same object capability-state uses internally.
// Load overlay-aware registry (ADR-1244 D2 wiring) so installed third-party
// capabilities are visible to loop rendering exactly like first-party ones.
// 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 });
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
for (const cap of state.capabilities || []) {
capabilityStatesById.set(cap.id, cap);
@@ -509,6 +511,27 @@ function cmdLoopRenderHooks(
return;
}
// ── ADR-1244 D2 fail-closed gate injection ────────────────────────────────────
// For every skipped overlay capability that declared a gate at this point,
// inject a synthetic BLOCKING gate into the resolved output so the loop HALTS
// rather than silently proceeding as if the gate had passed. step/contribution
// overlays that were skipped are left open (skip-open is correct for them).
const overlayMeta = (registry as { _overlay?: { blockedGates?: Array<{ point: string; capId: string; reason: string }> } })['_overlay'];
if (overlayMeta && Array.isArray(overlayMeta.blockedGates)) {
for (const blocked of overlayMeta.blockedGates) {
if (blocked.point === point) {
const syntheticGate: ActiveHook = {
capId: blocked.capId,
kind: 'gate',
blocking: true,
onError: 'halt',
check: `capability "${blocked.capId}" was skipped at load (${blocked.reason}); its gate at ${point} cannot be evaluated — failing closed`,
};
resolved.activeHooks.push(syntheticGate);
}
}
}
// --active-cap mode: print exactly 'true' or 'false' with no envelope
if (activeCapId !== undefined) {
const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);

View File

@@ -49,3 +49,126 @@ export function isSemverNewer(a: VersionInput, b: VersionInput): boolean {
export function isStableTripletSemver(v: VersionInput): boolean {
return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, ''));
}
// ─── Range satisfaction (ADR-1244 D2 — engines.gsd load-time gate) ────────────
//
// A minimal, hand-written `semverSatisfies(version, range)` — deliberately NOT
// the `semver` npm package (no new dependency / supply-chain surface in core,
// consistent with this module's hand-written heritage). It supports the operator
// subset capability `engines.gsd` ranges actually use: `>= <= > < =` (exact),
// caret `^`, tilde `~`, OR via `||`, AND via whitespace, partials (`1`, `1.2`)
// and wildcards (`*`, `1.x`). Satisfaction is computed on the numeric
// major.minor.patch core (prerelease-insensitive), matching this module's
// existing `toNumericTuple` policy. CRITICAL: any comparator it cannot parse
// makes the whole check FAIL CLOSED (returns false) — an unparseable engines
// range must never silently pass the load-time gate.
type RangeOp = '>=' | '<=' | '>' | '<' | '=';
interface Primitive { op: RangeOp; t: SemverTuple; }
function compareTuples(a: SemverTuple, b: SemverTuple): CompareResult {
if (a[0] !== b[0]) return a[0] > b[0] ? 1 : -1;
if (a[1] !== b[1]) return a[1] > b[1] ? 1 : -1;
if (a[2] !== b[2]) return a[2] > b[2] ? 1 : -1;
return 0;
}
// Parse a version-ish token into a tuple + how many leading numeric parts were
// specified (0 = bare wildcard "*"/"x", 1 = "1", 2 = "1.2", 3 = "1.2.3").
// Returns null if the token is not a parseable partial/full version.
function parseVersionToken(token: string): { tuple: SemverTuple; specified: 0 | 1 | 2 | 3 } | null {
const clean = token.trim().replace(/^v/, '').replace(/[-+].*$/, '');
if (clean === '' || clean === '*' || clean === 'x' || clean === 'X') return { tuple: [0, 0, 0], specified: 0 };
const parts = clean.split('.');
if (parts.length > 3) return null;
const nums: number[] = [];
let sawWildcard = false;
for (const p of parts) {
if (p === 'x' || p === 'X' || p === '*') { sawWildcard = true; continue; }
// A concrete segment after a wildcard ("1.x.2", "1.*.2") is malformed → fail closed.
if (sawWildcard) return null;
if (!/^\d+$/.test(p)) return null;
nums.push(Number.parseInt(p, 10));
}
if (nums.length === 0) return { tuple: [0, 0, 0], specified: 0 };
return { tuple: [nums[0] || 0, nums[1] || 0, nums[2] || 0], specified: nums.length as 1 | 2 | 3 };
}
// Expand a single comparator into primitive (op, tuple) constraints, or null if
// unparseable (→ fail closed).
function expandComparator(c: string): Primitive[] | null {
const trimmed = c.trim();
if (trimmed === '' || trimmed === '*' || trimmed === 'x' || trimmed === 'X') return [{ op: '>=', t: [0, 0, 0] }];
const m = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(trimmed);
if (!m) return null;
const op = m[1] || '';
const pv = parseVersionToken(m[2]);
if (!pv) return null;
const { tuple, specified } = pv;
const [maj, min, pat] = tuple;
if (op === '^') {
let upper: SemverTuple;
if (maj > 0) upper = [maj + 1, 0, 0];
else if (min > 0) upper = [0, min + 1, 0];
else upper = [0, 0, pat + 1];
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
}
if (op === '~') {
const upper: SemverTuple = specified >= 2 ? [maj, min + 1, 0] : [maj + 1, 0, 0];
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
}
if (op === '' || op === '=') {
if (specified === 0) return [{ op: '>=', t: [0, 0, 0] }]; // "*" → any
if (specified === 3) return [{ op: '=', t: tuple }];
const upper: SemverTuple = specified === 1 ? [maj + 1, 0, 0] : [maj, min + 1, 0];
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
}
// >= <= > < with an explicit version
if (specified === 0) return null; // e.g. ">=*" is meaningless → fail closed
return [{ op: op as RangeOp, t: tuple }];
}
function satisfiesPrimitive(v: SemverTuple, prim: Primitive): boolean {
const cmp = compareTuples(v, prim.t);
switch (prim.op) {
case '>=': return cmp >= 0;
case '<=': return cmp <= 0;
case '>': return cmp > 0;
case '<': return cmp < 0;
case '=': return cmp === 0;
default: return false;
}
}
// One whitespace-separated comparator set (ANDed). Fail closed if any comparator
// is unparseable.
function satisfiesSet(v: SemverTuple, set: string): boolean {
const trimmed = set.trim();
if (trimmed === '') return false;
const comparators = trimmed.split(/\s+/).filter(Boolean);
if (comparators.length === 0) return false;
for (const c of comparators) {
const prims = expandComparator(c);
if (prims === null) return false; // unparseable → fail closed
for (const prim of prims) {
if (!satisfiesPrimitive(v, prim)) return false;
}
}
return true;
}
/**
* Does `version` satisfy the semver `range`? OR-composed across `||`, AND-composed
* across whitespace. Fail-closed: an empty range, or any comparator this minimal
* implementation cannot parse, returns false. Comparison is on the numeric
* major.minor.patch core (prerelease tags are stripped, per `toNumericTuple`).
*/
export function semverSatisfies(version: VersionInput, range: VersionInput): boolean {
const r = String(range == null ? '' : range).trim();
if (r === '') return false;
const v = toNumericTuple(version);
const orSets = r.split('||').map((s) => s.trim()).filter((s) => s.length > 0);
if (orSets.length === 0) return false;
return orSets.some((set) => satisfiesSet(v, set));
}