diff --git a/CONTEXT.md b/CONTEXT.md index 881952bf9..de55b1fab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 `` 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` (`{ : [] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ : { 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//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` (`{ : [] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ : { 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//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. diff --git a/capabilities/graphify/capability.json b/capabilities/graphify/capability.json index 4c7e0bfc4..187fc53cb 100644 --- a/capabilities/graphify/capability.json +++ b/capabilities/graphify/capability.json @@ -8,6 +8,7 @@ "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": ["graphify"], "agents": [], + "activationKey": "graphify.enabled", "config": { "graphify.enabled": { "type": "boolean", diff --git a/capabilities/intel/capability.json b/capabilities/intel/capability.json index 74149e3d7..ffd2b2ba6 100644 --- a/capabilities/intel/capability.json +++ b/capabilities/intel/capability.json @@ -8,6 +8,7 @@ "runtimeCompat": { "supported": ["*"], "unsupported": [] }, "skills": [], "agents": [], + "activationKey": "intel.enabled", "config": { "intel.enabled": { "type": "boolean", diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 6ec540646..0efba5681 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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", diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index d13395d5e..83135fdf5 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -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']); diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index dd46c34bf..15610aeb4 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -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), + ); + }); +});