feat(#1304): add optional activationKey capability manifest field (#1309)

Add an optional activationKey to the feature role of capability.json — the
dotted config key that gates the whole capability (e.g. graphify.enabled).
gen-capability-registry validates it (non-empty string, reserved-name guard,
must be declared in the capability's own config slice, feature-only) and emits
it per-capability in the generated registry. Declared on graphify + intel.
No runtime consumption yet (resolver wiring lands in #1305). Part of #1302.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-15 21:33:06 -04:00
committed by GitHub
parent 91bd82f9a6
commit 1f41a0ce9a
6 changed files with 292 additions and 3 deletions

View File

@@ -155,13 +155,13 @@ Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` c
Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58.
### Capability [Planned]
A bundle delivering one optional GSD feature, toggled as a unit at install or after install. Owns its skills, agents, hooks, federated config-key schema (keys + defaults + validation), and loop extension-point registrations, plus a `requires` list of other Capabilities. Declared co-located in the Capability's own folder and compiled into a generated central Capability Registry at build time. The five-step loop (Discuss → Plan → Execute → Verify → Ship) and shared-infrastructure skills (phase, config, help, update, surface, progress) are the privileged host, not Capabilities, in v1 — but host extension points are data so a loop step can become a Capability under a future uniform kernel. Supersedes the implicit feature-scattering across clusters, install-profiles, and config-schema. Generalizes the Skill Surface Budget Module and Runtime Install Policy Module.
A bundle delivering one optional GSD feature, toggled as a unit at install or after install. Owns its skills, agents, hooks, federated config-key schema (keys + defaults + validation), and loop extension-point registrations, plus a `requires` list of other Capabilities, plus an optional `activationKey` (a dotted config key, e.g. `graphify.enabled`) naming the config toggle that gates the whole capability — consumed by the Capability State Resolver's per-capability `active` (absent → no config gate; see Capability State Resolver tri-state deepening). Declared co-located in the Capability's own folder and compiled into a generated central Capability Registry at build time. The five-step loop (Discuss → Plan → Execute → Verify → Ship) and shared-infrastructure skills (phase, config, help, update, surface, progress) are the privileged host, not Capabilities, in v1 — but host extension points are data so a loop step can become a Capability under a future uniform kernel. Supersedes the implicit feature-scattering across clusters, install-profiles, and config-schema. Generalizes the Skill Surface Budget Module and Runtime Install Policy Module.
### Loop Host Contract
Generated description of what the five-step loop (Discuss → Plan → Execute → Verify → Ship) exposes as extension points: per-step loop points, agent roles, and core artifacts. Sourced from structured `<!-- gsd:loop-host ... -->` HTML-comment markers embedded near the top of each of the five step workflow files (`discuss-phase.md`, `plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`). Generated by `scripts/gen-loop-host-contract.cjs` → `gsd-core/bin/lib/loop-host-contract.cjs` (ADR-894 §3 phase 3a-impl-2). Covers exactly the 12 canonical points (discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post). The generator enforces a drift guard: every declared non-orchestrator agent role must correspond to an actual agent reference in the workflow file. Consumed by `gen-capability-registry.cjs` (replaces the former inline `LOOP_HOST_CONTRACT` constant). Run `node scripts/gen-loop-host-contract.cjs --write` after editing a workflow step marker.
### Capability Registry
Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. ADR-857 phase 4a adds two derived views: `capabilityClusters` (`{ <capId>: [<skill stems>] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ <capId>: { tier, profiles: [...] } }` — the tier-derived index: suffix of `PROFILE_RANK` starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty `skills` array). The generator enforces a HARD gate (throws) if a capId matching a `CLUSTERS` key has a mismatched skill set, and emits SOFT `⚠ pending-reconciliation` warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. `install` and `surface` are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities/<id>/capability.json`.
Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. Each feature capability's entry in `capabilities` now includes the optional `activationKey` field (the dotted config key that gates the whole capability, e.g. `"graphify.enabled"`; absent means no config gate). ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. ADR-857 phase 4a adds two derived views: `capabilityClusters` (`{ <capId>: [<skill stems>] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ <capId>: { tier, profiles: [...] } }` — the tier-derived index: suffix of `PROFILE_RANK` starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty `skills` array). The generator enforces a HARD gate (throws) if a capId matching a `CLUSTERS` key has a mismatched skill set, and emits SOFT `⚠ pending-reconciliation` warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. `install` and `surface` are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities/<id>/capability.json`.
### 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.

View File

@@ -8,6 +8,7 @@
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
"skills": ["graphify"],
"agents": [],
"activationKey": "graphify.enabled",
"config": {
"graphify.enabled": {
"type": "boolean",

View File

@@ -8,6 +8,7 @@
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
"skills": [],
"agents": [],
"activationKey": "intel.enabled",
"config": {
"intel.enabled": {
"type": "boolean",

View File

@@ -762,6 +762,7 @@ const capabilities = {
"graphify"
],
"agents": [],
"activationKey": "graphify.enabled",
"config": {
"graphify.enabled": {
"type": "boolean",
@@ -845,6 +846,7 @@ const capabilities = {
},
"skills": [],
"agents": [],
"activationKey": "intel.enabled",
"config": {
"intel.enabled": {
"type": "boolean",

View File

@@ -542,6 +542,32 @@ function validateFeatureBody(cap) {
}
}
// activationKey: optional string naming the dotted config key that gates this capability.
// If present: must be a non-empty string that is declared in this capability's own config slice.
if (cap.activationKey !== undefined) {
if (typeof cap.activationKey !== 'string' || cap.activationKey.length === 0) {
errors.push(
'capability "' + (cap.id || '(unknown)') + '" activationKey must be a non-empty string (got: ' +
JSON.stringify(cap.activationKey) + ')',
);
} else if (cap.activationKey === '__proto__' || cap.activationKey === 'constructor' || cap.activationKey === 'prototype') {
// Prototype-pollution guard (inline literal, CodeQL barrier)
errors.push(
'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey +
'" is a reserved JavaScript property name and cannot be used as an activationKey',
);
} else if (
typeof cap.config !== 'object' ||
cap.config === null ||
!Object.prototype.hasOwnProperty.call(cap.config, cap.activationKey)
) {
errors.push(
'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey +
'" is not declared in this capability\'s config slice — add it to the "config" object or use a key that is declared there',
);
}
}
return errors;
}
@@ -586,7 +612,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']);
const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks'];
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']);
const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);

View File

@@ -4772,3 +4772,262 @@ describe('#1196 — discuss loop wiring + wired-point guard', () => {
});
});
});
// ─── activationKey validation (issue #1304 Phase 1) ─────────────────────────
describe('activationKey validation', () => {
// Minimal valid feature capability fixture for activationKey tests.
// Uses UI_CAP as a base so all required fields are satisfied.
function makeCapWithActivationKey(activationKey) {
const cap = { ...UI_CAP, activationKey };
if (activationKey === undefined) delete cap.activationKey;
return cap;
}
// (a) valid activationKey referencing a key declared in the cap's own config slice
test('(a) valid activationKey referencing own config key: no errors, emitted in registry', () => {
// UI_CAP declares 'workflow.ui_phase' (boolean) in its config — use that as activationKey
const cap = makeCapWithActivationKey('workflow.ui_phase');
const errors = validateCapability(cap, 'ui');
assert.deepEqual(
errors,
[],
'Expected no validation errors for activationKey that matches own config key, got: ' +
JSON.stringify(errors),
);
// Confirm activationKey is emitted in the built registry
const capDir = makeTempCapDir({ ui: cap });
const { capMap, errors: loadErrors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(loadErrors, [], 'Expected no load errors: ' + JSON.stringify(loadErrors));
const registry = buildRegistry(capMap);
assert.strictEqual(
registry.capabilities.ui.activationKey,
'workflow.ui_phase',
'registry.capabilities.ui.activationKey must equal the declared activationKey',
);
});
// (b) activationKey referencing an UNKNOWN config key → a specific error naming the cap + key
test('(b) activationKey referencing unknown config key: specific error emitted', () => {
const cap = makeCapWithActivationKey('no-such-key.enabled');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey references an unknown config key',
);
const joined = errors.join('\n');
assert.ok(
joined.includes('no-such-key.enabled'),
'Error must name the bad activationKey, got: ' + JSON.stringify(errors),
);
assert.ok(
joined.includes('ui') || joined.includes('(unknown)'),
'Error must name the capability id, got: ' + JSON.stringify(errors),
);
assert.ok(
joined.includes('config'),
'Error must reference the config slice, got: ' + JSON.stringify(errors),
);
});
// (c) activationKey absent → valid (back-compat)
test('(c) activationKey absent: valid (back-compat — no errors)', () => {
const cap = makeCapWithActivationKey(undefined);
assert.ok(
!Object.prototype.hasOwnProperty.call(cap, 'activationKey'),
'Fixture must not have activationKey when undefined is passed',
);
const errors = validateCapability(cap, 'ui');
assert.deepEqual(
errors,
[],
'Expected no validation errors when activationKey is absent, got: ' + JSON.stringify(errors),
);
});
// (d) activationKey present but empty string → error
test('(d) activationKey empty string: error', () => {
const cap = makeCapWithActivationKey('');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is an empty string',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('non-empty')),
'Error must mention activationKey and non-empty, got: ' + JSON.stringify(errors),
);
});
// (d-extra) activationKey non-string (number) → error
test('(d-extra) activationKey non-string (number): error', () => {
const cap = { ...UI_CAP, activationKey: 42 };
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is a number',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('non-empty')),
'Error must mention activationKey and non-empty string requirement, got: ' + JSON.stringify(errors),
);
});
// (e) reserved-name guard: activationKey === '__proto__' → reserved-name error, not not-declared error
test('(e) activationKey "__proto__": reserved-name error (not not-declared error)', () => {
const cap = makeCapWithActivationKey('__proto__');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is "__proto__"',
);
assert.ok(
errors.some((e) => e.includes('__proto__') && e.includes('reserved')),
'Error must mention "__proto__" and "reserved", got: ' + JSON.stringify(errors),
);
// Must NOT emit the not-declared error (the guard runs before hasOwnProperty.call)
assert.ok(
!errors.some((e) => e.includes('is not declared in this capability\'s config slice')),
'Reserved-name guard must fire before the not-declared check; got: ' + JSON.stringify(errors),
);
});
// (f) reserved-name guard: activationKey === 'constructor' → reserved-name error
test('(f) activationKey "constructor": reserved-name error', () => {
const cap = makeCapWithActivationKey('constructor');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is "constructor"',
);
assert.ok(
errors.some((e) => e.includes('constructor') && e.includes('reserved')),
'Error must mention "constructor" and "reserved", got: ' + JSON.stringify(errors),
);
assert.ok(
!errors.some((e) => e.includes('is not declared in this capability\'s config slice')),
'Reserved-name guard must fire before the not-declared check; got: ' + JSON.stringify(errors),
);
});
// (g) reserved-name guard: activationKey === 'prototype' → reserved-name error
test('(g) activationKey "prototype": reserved-name error', () => {
const cap = makeCapWithActivationKey('prototype');
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is "prototype"',
);
assert.ok(
errors.some((e) => e.includes('prototype') && e.includes('reserved')),
'Error must mention "prototype" and "reserved", got: ' + JSON.stringify(errors),
);
assert.ok(
!errors.some((e) => e.includes('is not declared in this capability\'s config slice')),
'Reserved-name guard must fire before the not-declared check; got: ' + JSON.stringify(errors),
);
});
// (h) regression guard: activationKey === null → non-empty-string error (typeof null === 'object' footgun)
test('(h) activationKey null: non-empty-string error (typeof null footgun regression guard)', () => {
const cap = { ...UI_CAP, activationKey: null };
const errors = validateCapability(cap, 'ui');
assert.ok(
errors.length > 0,
'Expected at least one error when activationKey is null',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('non-empty')),
'Error must mention activationKey and non-empty string requirement (typeof null === "object" must not bypass the check), got: ' + JSON.stringify(errors),
);
// Must NOT emit the reserved-name error
assert.ok(
!errors.some((e) => e.includes('reserved')),
'null must not trigger the reserved-name guard, got: ' + JSON.stringify(errors),
);
});
// Registry integration: activationKey absent → field absent in registry entry (omit semantics)
test('activationKey absent: field omitted from registry capabilities entry', () => {
const cap = makeCapWithActivationKey(undefined);
const capDir = makeTempCapDir({ ui: cap });
const { capMap, errors } = loadAndValidate(new Set(), capDir);
assert.deepEqual(errors, [], 'Expected no load errors: ' + JSON.stringify(errors));
const registry = buildRegistry(capMap);
assert.ok(
!Object.prototype.hasOwnProperty.call(registry.capabilities.ui, 'activationKey'),
'activationKey must be absent from registry.capabilities.ui when not declared',
);
});
// Verify graphify capability.json declares correct activationKey
test('graphify capability.json declares activationKey matching its own config key', () => {
const graphifyCap = JSON.parse(
require('node:fs').readFileSync(
require('node:path').join(ROOT, 'capabilities', 'graphify', 'capability.json'),
'utf8',
),
);
assert.strictEqual(
graphifyCap.activationKey,
'graphify.enabled',
'graphify capability.json must declare activationKey: "graphify.enabled"',
);
assert.ok(
Object.prototype.hasOwnProperty.call(graphifyCap.config, 'graphify.enabled'),
'graphify capability.json config must contain key "graphify.enabled"',
);
});
// Verify intel capability.json declares correct activationKey
test('intel capability.json declares activationKey matching its own config key', () => {
const intelCap = JSON.parse(
require('node:fs').readFileSync(
require('node:path').join(ROOT, 'capabilities', 'intel', 'capability.json'),
'utf8',
),
);
assert.strictEqual(
intelCap.activationKey,
'intel.enabled',
'intel capability.json must declare activationKey: "intel.enabled"',
);
assert.ok(
Object.prototype.hasOwnProperty.call(intelCap.config, 'intel.enabled'),
'intel capability.json config must contain key "intel.enabled"',
);
});
// (i) role:runtime capability with activationKey → feature-only field error
test('(i) role:runtime with activationKey: feature-only field error', () => {
const cap = {
id: 'cursor', role: 'runtime', title: 'Cursor', description: 'Cursor IDE runtime',
tier: 'standard', requires: [],
activationKey: 'some.key',
runtime: {
configHome: { kind: 'dot-home', name: '.cursor', env: ['CURSOR_CONFIG_DIR'] },
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'cursor-hooks-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 2,
installSurface: 'cursor-hooks-json',
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
},
};
const errors = validateCapability(cap, 'cursor');
assert.ok(
errors.length > 0,
'Expected at least one error when role:runtime declares activationKey',
);
assert.ok(
errors.some((e) => e.includes('activationKey') && e.includes('feature-only')),
'Error must mention activationKey and feature-only, got: ' + JSON.stringify(errors),
);
});
});