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

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1440
---
**Runtime capability registry overlay** — installed third-party capabilities (under `~/.gsd/capabilities/` or a project's `.gsd/capabilities/`) are now composed into the registry at runtime via `loadRegistry({ includeInstalled })`: validated against the same conformance invariants as first-party, first-party-wins on any collision, skipped-with-a-warning when incompatible with the running GSD version (`engines.gsd`), with gate-kind capabilities failing closed. Installed overlays are toggable via surface and federate their config keys (cwd-aware) exactly like first-party. Foundation (ADR-1244 Phase 2) for capability install/upgrade/remove.

1
.gitignore vendored
View File

@@ -67,6 +67,7 @@ build/
# by `npm run build:lib`). Source of truth is src/; these are emitted, never edited.
# Published via prepublishOnly; built before test via pretest. Grows as modules migrate.
/tsconfig.build.tsbuildinfo
/gsd-core/bin/lib/capability-loader.cjs
/gsd-core/bin/lib/markdown-sectionizer.cjs
/gsd-core/bin/lib/resolution.cjs
/gsd-core/bin/lib/research-store.cjs

View File

@@ -175,6 +175,12 @@ Generated central manifest projecting all co-located Capability declarations int
### Federated Config
ADR-857 phase 3b seam that merges capability-declared config slices into the `loadConfig` return value. Implemented in `src/federated-config.cts` → `gsd-core/bin/lib/federated-config.cjs`. Exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }`. Rules: central-schema keys are skipped with a `pending-migration` warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal `__proto__`/`constructor`/`prototype` key checks. ADR-857 phase 6 made the channel live for migrated Capability keys: `config-schema.cjs` exposes `isCentralConfigKey()` for central ownership and `isValidConfigKey()` accepts central + runtime + dynamic + Capability-owned registry keys. `loadConfig` exposes `_setFederatedRegistryForTests`/`_resetFederatedRegistryForTests` seams for injecting a synthetic registry in tests.
### Capability Registry Overlay
Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that composes the frozen first-party Capability Registry (`capability-registry.cjs`) with a validated installed overlay of third-party capability manifests discovered at load time. Install roots are global (`$GSD_HOME/.gsd/capabilities/<id>/capability.json`, where `GSD_HOME` defaults to `~`) and project (`<projectRoot>/.gsd/capabilities/<id>/capability.json`). Primary interface: `loadRegistry({ includeInstalled }) → registry` — when `includeInstalled` is true the overlay is merged via the canonical `buildRegistry` so all derived views (bySkill, byAgent, byLoopPoint, configKeys) cover first-party and overlay entries identically. First-party always wins: any overlay entry whose id, owned skill/agent stem, or federated config key collides with first-party, or whose id uses a reserved `gsd-`/`gsd-core-`/`anthropic-` prefix, is rejected at load time. Load-time re-gate: an overlay failing schema validation or whose `engines.gsd` semver range does not satisfy the running GSD version is skipped with a warning and never crashes the load loop. Per-hook-kind policy: a skipped capability that declared a `gate`-kind hook fails CLOSED (the loop resolver injects a blocking gate); skipped `step` or `contribution` capabilities skip open. Consumers wired to the overlay-aware registry: `config-loader.cjs`, `config-schema.cjs`, `capability-state.cjs`, `loop-resolver.cjs`.
### Capability Validator
Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`.
### Loop Extension Point
A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks <point>` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing.

View File

@@ -373,9 +373,11 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core
| `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning |
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
| `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` |
| `capability-loader.cjs` | Runtime registry overlay loader (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay of third-party capability manifests read from global `$GSD_HOME/.gsd/capabilities/` and project `<projectRoot>/.gsd/capabilities/`; first-party always wins; load-time `engines.gsd` re-gate skips incompatible overlays with a warning; gate-kind hooks on skipped capabilities fail CLOSED |
| `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) |
| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; consumes resolved Capability State, filters `byLoopPoint` by capability enablement plus config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks <point> [--config-dir <path>]` |
| `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b/6; composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, I/O `cmdCapabilityState`, and convenience predicate `isCapabilityActive(capId, cwd)`; `gsd-tools capability state [--config-dir <path>]` emits `{ runtimeConfigDir, capabilities[] }` where each entry carries `enabled` (installed && surfaced) and `active` (enabled && configActivation via the capability's `activationKey`; absent key → active===enabled) |
| `capability-validator.cjs` | Shared capability conformance validator (ADR-1244 D2) — extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one `validateCapability(manifest)` implementation; generative-parity is CI-guarded |
| `graphify-command-router.cjs` | ADR-959 capability command router — first real capability command cutover (phase 4d-impl-2); extracted from the `case 'graphify':` arm in `gsd-tools.cjs`; dispatches build/query/status/diff subcommands; discovered via `commandFamilies` in the capability registry |
| `audit-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-3); extracted from the `case 'audit-uat':` and `case 'audit-open':` arms in `gsd-tools.cjs`; `routeAuditUat` → `uat.cjs:cmdAuditUat`, `routeAuditOpen` → `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; discovered via `commandFamilies` in the capability registry |
| `intel-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-4, last first-party cutover); extracted from the `case 'intel':` arm in `gsd-tools.cjs`; `routeIntelCommand` → all 9 intel subcommands via lazy `require('./intel.cjs')`; preserves non-raw `timeAgo` transform on `status.files[*].updated_at`; discovered via `commandFamilies` in the capability registry |

View File

@@ -657,6 +657,41 @@ The `features.*` namespace is a dynamic key pattern — new feature flags can be
---
## Capability Overlay (installed third-party capabilities)
GSD supports an **installed overlay** of third-party capability manifests that are composed with the frozen first-party registry at runtime via `loadRegistry({ includeInstalled: true })` (ADR-1244; see [`docs/reference/capability-manifest.md`](reference/capability-manifest.md) and [`docs/how-to/import-a-capability-from-a-url.md`](how-to/import-a-capability-from-a-url.md)).
### Install roots
Capability manifests (`capability.json`) are discovered from two scoped roots:
| Scope | Path |
|-------|------|
| Global | `$GSD_HOME/.gsd/capabilities/<id>/capability.json` |
| Project | `<projectRoot>/.gsd/capabilities/<id>/capability.json` |
`GSD_HOME` defaults to your home directory (`~`) when unset. Both roots are scanned on every `loadRegistry` call; neither requires config changes to activate.
### Composition and first-party-wins invariant
Installed overlay capabilities are merged via the same `buildRegistry` pipeline as first-party capabilities, so all derived views (`bySkill`, `byAgent`, `byLoopPoint`, `configKeys`) cover first-party and overlay entries identically. **First-party always wins**: an overlay entry is rejected at load time if its `id`, any owned skill or agent stem, or any federated config key collides with a first-party entry, or if its `id` uses a reserved prefix (`gsd-`, `gsd-core-`, `anthropic-`). Rejected entries emit a warning and are skipped; they never crash the load loop.
### Load-time `engines.gsd` compatibility gate
Each overlay manifest may declare an `engines.gsd` semver range. At load time GSD evaluates this range against the running GSD version. An overlay that does not satisfy the range is **skipped with a warning** — it is never loaded and never crashes the loop. Manifests without an `engines.gsd` field are accepted unconditionally.
### Gate-kind fail-closed policy
If a skipped overlay capability declared a `gate`-kind loop hook, the loop resolver **injects a blocking gate** at that hook point (fail CLOSED). Skipped capabilities whose hooks are `step` or `contribution` kind skip open — the loop proceeds without them.
### Overlay config federation
Config keys declared in an overlay capability's `.config` slice federate into the `loadConfig` return value via the same Federated Config channel as first-party capability keys. They appear as valid keys in `config-schema.cjs` (`isValidConfigKey`) and in the runtime config schema, so overlay capabilities can declare project-local config toggles without editing the central config schema.
> **See also:** [`docs/reference/capability-manifest.md`](reference/capability-manifest.md) for the full `capability.json` schema, [`docs/how-to/import-a-capability-from-a-url.md`](how-to/import-a-capability-from-a-url.md) for installation steps, and [ADR-1244](adr/1244-runtime-capability-registry-overlay.md) for the design record.
---
## Parallelization Settings
| Setting | Type | Default | Description |

View File

@@ -279,8 +279,10 @@
"audit-command-router.cjs",
"audit.cjs",
"capability-activation.cjs",
"capability-loader.cjs",
"capability-registry.cjs",
"capability-state.cjs",
"capability-validator.cjs",
"capability-writer.cjs",
"check-command-router.cjs",
"cjs-command-router-adapter.cjs",

View File

@@ -390,8 +390,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 |
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
| `capability-activation.cjs` | Capability activation resolver shared by config validation and capability-state consumers — resolves registry-owned config keys from raw runtime config without re-centralizing migrated settings |
| `capability-loader.cjs` | Runtime Capability Registry overlay (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay read from `$GSD_HOME/.gsd/capabilities/<id>/` (global) and `<projectRoot>/.gsd/capabilities/<id>/` (project); first-party-wins on id/skill/agent/config collisions, reserved-namespace rejection, load-time `engines.gsd` re-gate (skip-with-warning), and gate-kind fail-closed via `_overlay.blockedGates`; composes through the canonical `buildRegistry` so derived views never drift |
| `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities/<id>/capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) |
| `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b/6) — composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; exports pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir <path>]` emitting `{ runtimeConfigDir, capabilities[] }` |
| `capability-validator.cjs` | Shared runtime-callable capability validator (ADR-1244 D2) — extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share ONE validation implementation (generative-parity guarded); exports `validateCapability`/`validateCrossCapability`/`validateVersionEnvelope`/`validateConsumesGlobal`/… plus the closed-vocabulary sets and `SEMVER_RE` |
| `capability-writer.cjs` | Capability State Writer (ADR-1213) — write-side inverse of the resolver; projects desired per-capability enabled/gates onto surface + config substrates, then re-resolves (assert-and-report); exports `setCapabilityState` and I/O handler `cmdCapabilitySet`; command surface: `gsd-tools capability set <id> [--on\|--off] [--gate <key>=<true\|false>]` |
| `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` |
| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly |

View File

@@ -39,6 +39,7 @@ export default tseslint.config(
'**/*.generated.cjs',
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs.
'gsd-core/bin/lib/semver-compare.cjs',
'gsd-core/bin/lib/capability-loader.cjs',
'gsd-core/bin/lib/resolution.cjs',
'gsd-core/bin/lib/plan-drift-guard.cjs',
'gsd-core/bin/lib/cli-exit.cjs',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -462,6 +462,20 @@ function main() {
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
// Sandbox the overlay home so the loader's global scan ($GSD_HOME/.gsd/capabilities)
// cannot read a developer's real installed capabilities during tests (ADR-1244 D2).
// IDEMPOTENT: a nested run-tests spawn (e.g. tests/run-tests-harness.test.cjs)
// inherits this sandbox via env — it must REUSE it, never mkdtemp a fresh dir per
// invocation (that churned ~20+ temp dirs per harness run and amplified Docker load).
{
const { mkdtempSync } = require('fs');
const { join: _join, basename: _basename } = require('path');
const { tmpdir } = require('os');
const _gh = process.env.GSD_HOME;
if (!_gh || !_basename(_gh).startsWith('gsd-test-home-')) {
process.env.GSD_HOME = mkdtempSync(_join(tmpdir(), 'gsd-test-home-'));
}
}
// Log selected files to stderr for CI / harness-test visibility.
// node:test default reporter doesn't echo filenames, so this gives

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

View File

@@ -0,0 +1,275 @@
'use strict';
/**
* capability-loader.test.cjs — ADR-1244 D2 runtime registry overlay.
*
* Behavioral tests for loadRegistry({ includeInstalled }): first-party ∪
* validated overlay composition, first-party-wins collisions, reserved
* namespace, engines.gsd load-time re-gate (skip-with-warning), gate-kind
* fail-closed tracking, and parity with the canonical builder.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { loadRegistry } = require('../gsd-core/bin/lib/capability-loader.cjs');
const baseRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { buildRegistry } = require('../scripts/gen-capability-registry.cjs');
const HOST = '1.6.0';
function featureCap(id, extra) {
return {
id, role: 'feature', version: '1.0.0', title: id, description: 'overlay cap',
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
...extra,
};
}
// Build a temp GSD home containing .gsd/capabilities/<id>/capability.json for each cap.
function makeOverlayHome(caps) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-overlay-'));
for (const cap of caps) {
const dir = path.join(home, '.gsd', 'capabilities', cap.id);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(cap), 'utf8');
}
return home;
}
// Always pass cwd === home so the project-root probe cannot wander into the
// real repo; root-dedup makes the project scope a no-op there.
function load(home, opts) {
return loadRegistry({ includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST, ...opts });
}
describe('loadRegistry — base behavior', () => {
test('without includeInstalled returns the frozen registry (identity-stable)', () => {
assert.strictEqual(loadRegistry(), baseRegistry);
assert.strictEqual(loadRegistry({ includeInstalled: false }), baseRegistry);
});
test('includeInstalled with no overlay directory returns the frozen registry unchanged', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-empty-'));
try {
assert.strictEqual(load(home), baseRegistry);
} finally {
cleanup(home);
}
});
});
describe('loadRegistry — accepting valid overlays', () => {
test('a valid overlay capability appears in every derived view (toggable + federated)', (t) => {
const home = makeOverlayHome([
featureCap('deploy-gate', {
skills: ['deploy-review'],
agents: ['gsd-deploy-checker'],
config: { 'workflow.deploy_gate': { type: 'boolean', default: true, description: 'Enable the deploy gate.' } },
steps: [{ point: 'execute:wave:post', ref: { skill: 'deploy-review' }, produces: ['DEPLOY.md'], consumes: [], when: 'workflow.deploy_gate', onError: 'skip' }],
}),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(reg.capabilities['deploy-gate'], 'overlay in capabilities');
assert.equal(reg.bySkill['deploy-review'], 'deploy-gate', 'overlay skill indexed (surface)');
assert.equal(reg.byAgent['gsd-deploy-checker'], 'deploy-gate', 'overlay agent indexed');
assert.ok(reg.configSchema['workflow.deploy_gate'], 'overlay config federated');
assert.equal(reg.configKeys['workflow.deploy_gate'], 'deploy-gate', 'overlay config key owned');
assert.ok(reg.capabilityClusters['deploy-gate'], 'overlay in capabilityClusters (surface toggle)');
assert.ok(reg.profileMembership['deploy-gate'], 'overlay in profileMembership (surface toggle)');
const wavePost = reg.byLoopPoint['execute:wave:post'];
assert.ok(wavePost && Array.isArray(wavePost.steps) &&
wavePost.steps.some((h) => h.capId === 'deploy-gate'), 'overlay step wired into the loop');
// First-party is preserved.
assert.equal(reg.capabilities['ui'].title, 'UI design contracts');
assert.equal(reg._overlay.warnings.length, 0, 'no warnings when all overlays accepted');
assert.deepEqual(reg._overlay.incompatibleGateCapIds, []);
});
test('composed registry equals buildRegistry over the same merged cap-map (no drift / no dropped caps)', (t) => {
const overlay = featureCap('extra-cap', { skills: ['extra-skill'] });
const home = makeOverlayHome([overlay]);
t.after(() => cleanup(home));
const reg = load(home);
const mergedMap = new Map(Object.entries(baseRegistry.capabilities));
mergedMap.set('extra-cap', overlay);
const expected = buildRegistry(mergedMap);
assert.deepEqual(Object.keys(reg.capabilities).sort(), Object.keys(expected.capabilities).sort());
assert.deepEqual(reg.bySkill, expected.bySkill);
assert.deepEqual(Object.keys(reg.configSchema).sort(), Object.keys(expected.configSchema).sort());
assert.deepEqual(reg.capabilityClusters['extra-cap'], expected.capabilityClusters['extra-cap']);
});
});
describe('loadRegistry — first-party always wins', () => {
test('overlay whose id collides with a first-party id is rejected; first-party preserved', (t) => {
const home = makeOverlayHome([featureCap('ui', { skills: ['hijacked'] })]);
t.after(() => cleanup(home));
const reg = load(home);
assert.equal(reg.capabilities['ui'].title, 'UI design contracts', 'first-party ui untouched');
assert.ok(reg._overlay.warnings.some((w) => w.id === 'ui' && /collide/i.test(w.reason)));
});
test('overlay claiming a first-party skill stem is rejected', (t) => {
const home = makeOverlayHome([featureCap('skill-thief', { skills: ['ui-phase'] })]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['skill-thief']);
assert.ok(reg._overlay.warnings.some((w) => w.id === 'skill-thief' && /skill/i.test(w.reason)));
});
test('reserved id prefix (gsd-/gsd-core-/anthropic-) is rejected', (t) => {
const home = makeOverlayHome([
featureCap('gsd-impostor'),
featureCap('anthropic-impostor'),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['gsd-impostor']);
assert.ok(!reg.capabilities['anthropic-impostor']);
assert.equal(reg._overlay.warnings.filter((w) => /reserved/i.test(w.reason)).length, 2);
});
});
describe('loadRegistry — load-time re-gate (engines.gsd) + fail-closed gates', () => {
test('incompatible engines.gsd is skipped with a warning', (t) => {
const home = makeOverlayHome([featureCap('future-cap', { engines: { gsd: '>=99.0.0' } })]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['future-cap']);
assert.ok(reg._overlay.warnings.some((w) => w.id === 'future-cap' && /incompatible/i.test(w.reason)));
assert.deepEqual(reg._overlay.incompatibleGateCapIds, [], 'no gate declared → not a fail-closed blocker');
});
test('a skipped capability that DECLARES a gate is recorded for fail-closed handling', (t) => {
const home = makeOverlayHome([
featureCap('incompat-gate', {
engines: { gsd: '>=99.0.0' },
gates: [{ point: 'execute:wave:post', check: { query: 'x.deploy' }, blocking: true, onError: 'halt' }],
}),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['incompat-gate'], 'incompatible cap not loaded');
assert.ok(reg._overlay.incompatibleGateCapIds.includes('incompat-gate'), 'gate-kind tracked as fail-closed');
assert.ok(
reg._overlay.blockedGates.some((g) => g.point === 'execute:wave:post' && g.capId === 'incompat-gate'),
'declared gate point recorded for per-point fail-closed injection',
);
});
test('compatible engines.gsd is accepted', (t) => {
const home = makeOverlayHome([featureCap('compat-cap', { engines: { gsd: '>=1.6.0 <3.0.0' } })]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(reg.capabilities['compat-cap']);
});
});
describe('loadRegistry — malformed overlays are skipped, never crash', () => {
test('manifest failing validation is skipped with a warning', (t) => {
const home = makeOverlayHome([
// missing required version → validateCapability error
(() => { const c = featureCap('no-version'); delete c.version; return c; })(),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['no-version']);
assert.ok(reg._overlay.warnings.some((w) => w.id === 'no-version' && /version/i.test(w.reason)));
});
test('unreadable / invalid JSON is skipped with a warning (no throw)', (t) => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-badjson-'));
t.after(() => cleanup(home));
const dir = path.join(home, '.gsd', 'capabilities', 'broken');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'capability.json'), '{ not valid json', 'utf8');
let reg;
assert.doesNotThrow(() => { reg = load(home); });
assert.ok(!reg.capabilities['broken']);
assert.ok(reg._overlay.warnings.some((w) => w.id === 'broken'));
});
test('the loop never crashes — first-party registry remains fully intact alongside bad overlays', (t) => {
const home = makeOverlayHome([
featureCap('gsd-reserved'),
(() => { const c = featureCap('bad'); c.role = 'nonsense'; return c; })(),
featureCap('good', { skills: ['good-only-skill'] }),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.equal(Object.keys(baseRegistry.capabilities).length + 1, Object.keys(reg.capabilities).length,
'exactly the one good overlay is added; first-party count preserved');
assert.ok(reg.capabilities['good']);
});
});
describe('loadRegistry — full merged-set cross-capability validation', () => {
test('overlay claiming a first-party command family is rejected (first-party wins)', (t) => {
const firstPartyFamily = Object.keys(baseRegistry.commandFamilies || {})[0];
assert.ok(firstPartyFamily, 'precondition: first-party owns at least one command family');
const home = makeOverlayHome([
featureCap('cmd-thief', { commands: [{ family: firstPartyFamily, module: 'thief.cjs', router: 'route' }] }),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['cmd-thief'], 'overlay hijacking a first-party command family is not loaded');
assert.ok(reg._overlay.warnings.some((w) => w.id === 'cmd-thief' && /command family/i.test(w.reason)));
assert.ok(reg.commandFamilies[firstPartyFamily], 'first-party command family preserved');
});
test('overlay with an unsatisfiable consumes is rejected by cross-capability validation', (t) => {
const home = makeOverlayHome([
featureCap('bad-consumes', {
skills: ['bad-consumes-skill'],
config: { 'workflow.bad_consumes': { type: 'boolean', default: true, description: 'x' } },
steps: [{ point: 'plan:pre', ref: { skill: 'bad-consumes-skill' }, produces: [], consumes: ['NONEXISTENT-ARTIFACT.md'], when: 'workflow.bad_consumes', onError: 'skip' }],
}),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['bad-consumes'], 'overlay failing consumes-satisfiability is not loaded');
assert.ok(reg._overlay.warnings.some((w) => w.id === 'bad-consumes' && /cross-capability/i.test(w.reason)));
});
test('an invalid hook fragment path (escaping the capability dir) is rejected', (t) => {
const home = makeOverlayHome([
featureCap('frag-escape', {
contributions: [{ point: 'plan:pre', into: 'planner', fragment: { path: '../../../etc/passwd' }, when: 'workflow.frag', onError: 'skip' }],
config: { 'workflow.frag': { type: 'boolean', default: true, description: 'x' } },
}),
]);
t.after(() => cleanup(home));
const reg = load(home);
assert.ok(!reg.capabilities['frag-escape'], 'overlay with an escaping fragment path is not loaded');
assert.ok(reg._overlay.warnings.some((w) => w.id === 'frag-escape' && /fragment/i.test(w.reason)));
});
});
describe('loadRegistry — project-scoped overlay root', () => {
test('reads an overlay from <projectRoot>/.gsd/capabilities when cwd is inside a project', (t) => {
const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-proj-'));
t.after(() => cleanup(proj));
fs.mkdirSync(path.join(proj, '.planning'), { recursive: true }); // project-root marker
const dir = path.join(proj, '.gsd', 'capabilities', 'proj-cap');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(featureCap('proj-cap', { skills: ['proj-skill'] })), 'utf8');
// Point the global home elsewhere (empty) so only the project scope contributes.
const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-emptyhome-'));
t.after(() => cleanup(emptyHome));
const reg = loadRegistry({ includeInstalled: true, gsdHome: emptyHome, cwd: proj, hostVersion: HOST });
assert.ok(reg.capabilities['proj-cap'], 'project-scoped overlay loaded');
});
});

View File

@@ -69,6 +69,11 @@ const {
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
// ADR-1244 D2: the validator was extracted to a shared runtime-callable module.
// The generator must re-export it verbatim — the parity suite below proves no drift.
const capValidatorModule = require('../gsd-core/bin/lib/capability-validator.cjs');
const generatorModule = require('../scripts/gen-capability-registry.cjs');
const fc = require('fast-check');
const ROOT = path.resolve(__dirname, '..');
@@ -5035,3 +5040,61 @@ describe('activationKey validation', () => {
);
});
});
// ─── ADR-1244 D2: validator extraction generative parity ──────────────────────
//
// The validator now lives in gsd-core/bin/lib/capability-validator.cjs and is
// re-exported by the generator. These assertions guarantee the build-time
// generator and the runtime overlay share ONE validator implementation — no
// divergent copy can drift between them, because the generator re-exports the
// very same object references.
describe('ADR-1244 D2: validator extraction generative parity', () => {
const CORE = [
'validateCapability', 'validateCrossCapability', 'validateVersionEnvelope',
'validateConsumesGlobal', 'validateAgainstContract', 'validateConfigSliceEntry',
'validateRuntimeBody', 'classifyCrossErrors',
];
test('the runtime validator module exposes the full validator surface', () => {
for (const sym of [...CORE, 'SEMVER_RE', 'SEMVER_RANGE_RE', 'POINT_ORDER', 'VALID_LOOP_POINTS', 'VALID_TIERS']) {
assert.ok(sym in capValidatorModule, `validator module must export ${sym}`);
}
assert.strictEqual(typeof capValidatorModule.validateCapability, 'function');
assert.ok(capValidatorModule.SEMVER_RE instanceof RegExp);
});
test('every generator-re-exported validator symbol is the SAME object as the validator module (no drift)', () => {
const shared = Object.keys(capValidatorModule).filter((k) => Object.prototype.hasOwnProperty.call(generatorModule, k));
assert.ok(shared.length >= 20, `expected the generator to re-export the validator surface, got ${shared.length}`);
for (const k of shared) {
assert.strictEqual(
generatorModule[k],
capValidatorModule[k],
`generator export "${k}" must be the SAME reference as the validator module's (drift detected)`,
);
}
});
test('core validators are re-exported identically by the generator', () => {
for (const sym of CORE) {
assert.strictEqual(
generatorModule[sym], capValidatorModule[sym],
`${sym} must be re-exported by the generator as the validator module's reference`,
);
}
});
test('the extracted validator runs standalone (no generator/build-time deps required)', () => {
// Proves the module is genuinely runtime-callable: a clean require + validate
// with no install-profiles/clusters/config-schema machinery present.
const { validateCapability } = capValidatorModule;
const cap = {
id: 'demo', role: 'feature', version: '1.0.0', title: 'Demo', description: 'demo',
tier: 'standard', requires: [], runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
};
assert.deepEqual(validateCapability(cap, 'demo'), []);
const { version: _v, ...noVersion } = cap;
assert.ok(validateCapability(noVersion, 'demo').some((e) => e.includes('version')));
});
});

View File

@@ -1719,3 +1719,53 @@ describe('isCapabilityActive cross-runtime detection (GSD_RUNTIME → config.run
}
});
});
// ─── ADR-1244 D2 overlay wiring — capability-state sees installed overlays ───
describe('ADR-1244 D2: overlay-aware registry wiring in capability-state', () => {
// Verifies that resolveCapabilityRuntimeState uses loadRegistry({includeInstalled:true})
// so a valid installed overlay capability appears in the capabilities list.
// The overlay cap has no activationKey so it activates freely.
const { resolveCapabilityRuntimeState } = require('../gsd-core/bin/lib/capability-state.cjs');
test('valid overlay capability appears in runtime state capabilities list', () => {
const overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-overlay-'));
const prevGsdHome = process.env.GSD_HOME;
try {
// Write a valid overlay capability manifest
const capDir = path.join(overlayHome, '.gsd', 'capabilities', 'my-overlay-cap');
fs.mkdirSync(capDir, { recursive: true });
const capManifest = {
id: 'my-overlay-cap',
role: 'feature',
version: '1.0.0',
title: 'My Overlay Cap',
description: 'ADR-1244 D2 wiring test overlay',
tier: 'standard',
requires: [],
engines: { gsd: '>=0.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
};
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(capManifest), 'utf8');
// Point GSD_HOME to the overlay home so loadRegistry finds it
process.env.GSD_HOME = overlayHome;
// Use a non-existent cwd so no project-scope overlay is scanned — pure global
const nonExistentCwd = path.join(os.tmpdir(), 'cap-state-overlay-cwd-' + Date.now());
const result = resolveCapabilityRuntimeState(nonExistentCwd, undefined);
const overlayEntry = result.capabilities.find((c) => c.id === 'my-overlay-cap');
assert.ok(
overlayEntry !== undefined,
'overlay capability "my-overlay-cap" must appear in resolveCapabilityRuntimeState results ' +
'(ADR-1244 D2: capability-state must use overlay-aware loadRegistry)',
);
} finally {
if (prevGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = prevGsdHome;
cleanup(overlayHome);
}
});
});

View File

@@ -17,12 +17,17 @@
* (e) Arbitrary garbage strings return false (not throw) from isValidConfigKey
*/
const { describe, test } = require('node:test');
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const fc = require('./helpers/fast-check-setup.cjs');
const { cleanup } = require('./helpers.cjs');
const {
isValidConfigKey,
isCapabilityConfigKey,
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,
} = require('../gsd-core/bin/lib/config-schema.cjs');
@@ -152,3 +157,72 @@ describe('config-schema: isValidConfigKey properties', () => {
assert.equal(isValidConfigKey(NaN), false);
});
});
// ─── ADR-1244 D2: cwd-aware overlay config-key federation ─────────────────────
//
// Exercises every branch of the new _capabilityConfigSchema(cwd) path so the
// mutation suite (this is the file Stryker runs for config-schema) KILLS the
// added mutants: the `typeof cwd === 'string' && cwd` guard, the overlay
// loadRegistry({includeInstalled,cwd}) call, the `schema && typeof === 'object'`
// found-branch, the first-party fallback, and the cwd threading through
// isValidConfigKey. Uses a real overlay fixture (no test seam).
describe('config-schema: cwd-aware overlay federation (ADR-1244 D2)', () => {
const OVERLAY_KEY = 'workflow.cfgschema_overlay_gate';
// A known FIRST-PARTY capability config key (ui capability) — exercises the
// first-party fallback branch (no cwd → frozen registry configSchema).
const FIRST_PARTY_KEY = 'workflow.ui_phase';
const overlayCap = {
id: 'cfgschema-overlay', role: 'feature', version: '1.0.0', title: 'cfg overlay', description: 'x',
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: ['cfgschema-overlay-skill'], agents: [], hooks: [],
config: { [OVERLAY_KEY]: { type: 'boolean', default: true, description: 'overlay-owned key' } },
steps: [], contributions: [], gates: [],
};
let withOverlay, withoutOverlay, sandboxHome, savedHome;
before(() => {
savedHome = process.env.GSD_HOME;
sandboxHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-home-'));
process.env.GSD_HOME = sandboxHome; // empty global overlay root
withOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-proj-'));
fs.mkdirSync(path.join(withOverlay, '.planning'), { recursive: true }); // project-root marker
const capDir = path.join(withOverlay, '.gsd', 'capabilities', 'cfgschema-overlay');
fs.mkdirSync(capDir, { recursive: true });
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(overlayCap), 'utf8');
withoutOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'cfgschema-bare-'));
fs.mkdirSync(path.join(withoutOverlay, '.planning'), { recursive: true });
});
after(() => {
if (savedHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedHome;
cleanup(sandboxHome); cleanup(withOverlay); cleanup(withoutOverlay);
});
test('first-party fallback: a first-party capability config key is valid with no cwd', () => {
// Kills the fallback branch (return fp ... : {}) and the no-cwd path.
assert.equal(isCapabilityConfigKey(FIRST_PARTY_KEY), true);
assert.equal(isValidConfigKey(FIRST_PARTY_KEY), true);
});
test('overlay key is recognized only when the installing project cwd is supplied', () => {
// cwd with the overlay → true (kills cwd-guard, loadRegistry call, found-branch, hasOwnProperty)
assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withOverlay), true);
assert.equal(isValidConfigKey(OVERLAY_KEY, withOverlay), true);
// no cwd → first-party only → false (kills the cwd-true→fallback distinction)
assert.equal(isCapabilityConfigKey(OVERLAY_KEY), false);
assert.equal(isValidConfigKey(OVERLAY_KEY), false);
// cwd WITHOUT the overlay → loadRegistry returns base → false (cwd-correct)
assert.equal(isCapabilityConfigKey(OVERLAY_KEY, withoutOverlay), false);
assert.equal(isValidConfigKey(OVERLAY_KEY, withoutOverlay), false);
});
test('a genuinely unknown key is invalid regardless of cwd', () => {
assert.equal(isCapabilityConfigKey('zz.not.a.key', withOverlay), false);
assert.equal(isValidConfigKey('zz.not.a.key', withOverlay), false);
});
test('non-string keyPath returns false even with a cwd (no throw)', () => {
assert.equal(isCapabilityConfigKey(null, withOverlay), false);
assert.equal(isCapabilityConfigKey(42, withOverlay), false);
});
});

View File

@@ -423,3 +423,59 @@ describe('MALFORMED registry: loadConfig does not throw', () => {
assert.ok(Object.prototype.hasOwnProperty.call(result, 'model_profile'), 'model_profile must be present');
});
});
// ─── 5. ADR-1244 D2: overlay config-key federation is cwd-aware (REAL loader, no seam) ──
//
// Proves "toggable via config" for installed third-party capabilities AND that it
// is cwd-correct: an overlay capability's config key is valid + federates ONLY in
// the project where the overlay is installed — never globally, never for the wrong
// project, never from a bare require (no seam used here — the real loadRegistry path).
describe('ADR-1244 D2: overlay config-key federation (cwd-aware, real loader)', () => {
const configSchema = require('../gsd-core/bin/lib/config-schema.cjs');
const KEY = 'workflow.overlay_demo_gate';
const overlayCap = {
id: 'overlay-demo', role: 'feature', version: '1.0.0', title: 'Overlay demo', description: 'overlay',
tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' },
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: ['overlay-demo-skill'], agents: [], hooks: [],
config: { [KEY]: { type: 'boolean', default: true, description: 'overlay-owned federated key' } },
steps: [], contributions: [], gates: [],
};
let sandboxHome, withOverlay, withoutOverlay, savedHome;
beforeEach(() => {
_resetFederatedRegistryForTests(); // NO seam override — exercise the real cwd-aware path
savedHome = process.env.GSD_HOME;
sandboxHome = makeTempProject();
process.env.GSD_HOME = sandboxHome; // empty global overlay root
withOverlay = mkTemp();
const capDir = path.join(withOverlay, '.gsd', 'capabilities', 'overlay-demo');
fs.mkdirSync(capDir, { recursive: true });
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(overlayCap), 'utf-8');
withoutOverlay = mkTemp();
});
afterEach(() => {
if (savedHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedHome;
try { cleanup(sandboxHome); } catch { /* ignore */ }
});
test('overlay config key is valid in its own project, unknown elsewhere and with no cwd', () => {
assert.equal(configSchema.isValidConfigKey(KEY, withOverlay), true, 'valid in the project that installs the overlay');
assert.equal(configSchema.isValidConfigKey(KEY, withoutOverlay), false, 'unknown in a project without the overlay (cwd-correct)');
assert.equal(configSchema.isValidConfigKey(KEY), false, 'unknown with no cwd (first-party only)');
});
test('loadConfig federates the overlay key default only for the installing project', () => {
writeConfig(withOverlay, {});
const cfg = loadConfig(withOverlay);
assert.strictEqual(cfg.workflow && cfg.workflow.overlay_demo_gate, true, 'overlay default federates in its project');
writeConfig(withoutOverlay, {});
const other = loadConfig(withoutOverlay);
assert.strictEqual(
other.workflow ? other.workflow.overlay_demo_gate : undefined,
undefined,
'overlay key does NOT federate into an unrelated project',
);
});
});

View File

@@ -1038,3 +1038,81 @@ describe('Phase 4 regression: capabilityStatesById gates on active (not enabled)
assert.strictEqual(result.activeHooks[0].capId, 'test-cap');
});
});
// ─── ADR-1244 D2 fail-closed gate injection ────────────────────────────────────
describe('ADR-1244 D2: fail-closed gate injection for skipped overlay caps with gates', () => {
// Verifies that cmdLoopRenderHooks injects a BLOCKING synthetic gate at the
// declared point when an overlay capability that declares a gate is skipped at
// load time due to an incompatible engines.gsd version constraint.
//
// Fixture: overlay cap declares a gate at execute:wave:post with engines.gsd: ">=99.0.0"
// → loadRegistry skips it → records it in _overlay.blockedGates
// → cmdLoopRenderHooks injects a blocking=true, onError=halt gate at execute:wave:post
test('skipped gate-kind overlay cap → BLOCKING synthetic gate at its declared point', (t) => {
const overlayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-fail-closed-'));
t.after(() => cleanup(overlayHome));
// Write an overlay capability that:
// - declares a gate at execute:wave:post
// - has engines.gsd: ">=99.0.0" (incompatible → will be skipped at load)
const capId = 'fail-closed-gate-cap';
const capDir = path.join(overlayHome, '.gsd', 'capabilities', capId);
fs.mkdirSync(capDir, { recursive: true });
const capManifest = {
id: capId,
role: 'feature',
version: '1.0.0',
title: 'Fail Closed Gate Cap',
description: 'ADR-1244 D2 fail-closed wiring test',
tier: 'standard',
requires: [],
engines: { gsd: '>=99.0.0' }, // intentionally incompatible → always skipped
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [],
gates: [{ point: 'execute:wave:post', check: 'always-pass', blocking: true, onError: 'halt' }],
};
fs.writeFileSync(path.join(capDir, 'capability.json'), JSON.stringify(capManifest), 'utf8');
// Invoke gsd-tools via subprocess so stdout is the real fd-1 (io.cjs writes via writeSync).
// Set GSD_HOME to the overlay home so loadRegistry picks up the incompatible cap.
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:wave:post', '--cwd', overlayHome],
{
cwd: ROOT,
encoding: 'utf8',
env: { ...process.env, GSD_HOME: overlayHome },
},
);
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
let envelope;
try {
envelope = JSON.parse(result.stdout.trim());
} catch {
assert.fail('loop render-hooks output must be valid JSON; got: ' + result.stdout.slice(0, 300));
}
// The synthetic blocking gate must be present in activeHooks
const syntheticGate = Array.isArray(envelope.activeHooks)
? envelope.activeHooks.find((h) => h.capId === capId && h.kind === 'gate')
: undefined;
assert.ok(
syntheticGate !== undefined,
`activeHooks must contain a synthetic gate attributed to ${capId} (fail-closed injection). ` +
'Got: ' + JSON.stringify(envelope.activeHooks),
);
assert.strictEqual(syntheticGate.blocking, true, 'synthetic gate must be blocking=true');
assert.strictEqual(syntheticGate.onError, 'halt', 'synthetic gate must have onError=halt');
// The rendered markdown must also reference the gate cap
assert.ok(
typeof envelope.rendered === 'string' && envelope.rendered.includes(capId),
'rendered output must reference the fail-closed gate cap. Got: ' + envelope.rendered,
);
});
});

View File

@@ -10,6 +10,7 @@ const {
compareSemverCore,
isSemverNewer,
toNumericTuple,
semverSatisfies,
} = require('../gsd-core/bin/lib/semver-compare.cjs');
describe('isSemverNewer (shared semver comparison)', () => {
@@ -77,3 +78,102 @@ describe('isSemverNewer (shared semver comparison)', () => {
assert.strictEqual(compareSemverCore('1.2.0', '1.2.1'), -1);
});
});
describe('semverSatisfies (ADR-1244 engines.gsd range gate)', () => {
const sat = (v, r, expected) =>
assert.strictEqual(semverSatisfies(v, r), expected, `expected satisfies(${JSON.stringify(v)}, ${JSON.stringify(r)}) === ${expected}`);
test('>= comparator', () => {
sat('1.6.0', '>=1.6.0', true);
sat('1.6.1', '>=1.6.0', true);
sat('2.0.0', '>=1.6.0', true);
sat('1.5.9', '>=1.6.0', false);
});
test('> < <= = comparators', () => {
sat('1.6.1', '>1.6.0', true);
sat('1.6.0', '>1.6.0', false);
sat('1.5.0', '<1.6.0', true);
sat('1.6.0', '<1.6.0', false);
sat('1.6.0', '<=1.6.0', true);
sat('1.6.1', '<=1.6.0', false);
sat('1.6.0', '=1.6.0', true);
sat('1.6.1', '=1.6.0', false);
});
test('bare exact full version', () => {
sat('1.6.0', '1.6.0', true);
sat('1.6.1', '1.6.0', false);
});
test('AND-composed range (whitespace)', () => {
sat('1.6.0', '>=1.6.0 <3.0.0', true);
sat('2.9.9', '>=1.6.0 <3.0.0', true);
sat('3.0.0', '>=1.6.0 <3.0.0', false);
sat('1.5.0', '>=1.6.0 <3.0.0', false);
});
test('OR-composed range (||)', () => {
sat('1.6.0', '>=1.6.0 || >=2.0.0', true);
sat('2.0.0', '<1.0.0 || >=2.0.0', true);
sat('1.5.0', '<1.0.0 || >=2.0.0', false);
});
test('caret ranges', () => {
sat('1.2.3', '^1.2.3', true);
sat('1.9.0', '^1.2.3', true);
sat('2.0.0', '^1.2.3', false);
sat('1.2.2', '^1.2.3', false);
sat('0.2.3', '^0.2.3', true);
sat('0.3.0', '^0.2.3', false);
sat('0.0.3', '^0.0.3', true);
sat('0.0.4', '^0.0.3', false);
});
test('tilde ranges', () => {
sat('1.2.3', '~1.2.3', true);
sat('1.2.9', '~1.2.3', true);
sat('1.3.0', '~1.2.3', false);
sat('1.2.0', '~1.2', true);
sat('1.3.0', '~1.2', false);
sat('1.9.0', '~1', true);
sat('2.0.0', '~1', false);
});
test('wildcards and partials', () => {
sat('99.0.0', '*', true);
sat('0.0.1', '*', true);
sat('1.0.0', '1.x', true);
sat('1.9.9', '1.x', true);
sat('2.0.0', '1.x', false);
sat('0.9.9', '1.x', false);
sat('1.2.0', '1.2.x', true);
sat('1.3.0', '1.2.x', false);
sat('1.5.0', '1', true);
sat('2.0.0', '1', false);
});
test('prerelease and v-prefix normalize to numeric core', () => {
sat('1.6.0-rc.1', '>=1.6.0', true);
sat('1.5.1-dev.0', '>=1.6.0', false);
sat('v1.6.0', '>=1.6.0', true);
});
test('FAIL CLOSED on empty/unparseable ranges', () => {
for (const bad of ['', ' ', 'abc', 'not a range', '>=', '>=x', 'foo.bar.baz', '1.2.3.4', '>=1.2.3 garbage']) {
sat('1.6.0', bad, false);
}
});
test('FAIL CLOSED on malformed wildcard tokens (concrete segment after a wildcard)', () => {
for (const bad of ['1.x.2', '>=1.x.2', '1.*.2', '1.X.0']) {
sat('1.5.0', bad, false);
}
});
test('null/undefined inputs do not throw and fail closed', () => {
sat('1.6.0', null, false);
sat('1.6.0', undefined, false);
sat(null, '>=1.6.0', false); // null version -> [0,0,0] -> not >= 1.6.0
});
});