feat(#1035): phase 5a — author 16 role:runtime capability descriptors (registry-only) (#1039)

Author capabilities/<runtime>/capability.json for all 16 runtimes (role:runtime),
populating the registry's runtimes index ({} -> 16). Each carries the 6 ADR-1016
axes (configHome structured, configFormat, artifactLayout structured, commandStyle,
hooksSurface + hookEvents, sandboxTier, supportTier), extracted from the live
modules (runtime-homes/runtime-slash/runtime-artifact-layout/runtime-config-adapter
+ CODEX_AGENT_SANDBOX). validateRuntimeBody tightened to enforce the closed
vocabularies + structured configHome/artifactLayout (rejects old string configHome;
env required; skillsHome recursively validated).

Registry-only: bin/install.js + src/*.cts untouched, zero behavior change. The 5b-5f
drive steps consume these one axis at a time (ADR-1016 §8).

Codex accuracy review caught + fixed: opencode/kilo register ZERO lifecycle hooks
(install.js skips the whole block) -> hooksSurface:none (configFormat stays
settings-json); kimi probe selects on <root>/skills existence -> probeExists:"skills".
ArtifactKind required-field strictness + ConverterName closing deferred to 5d/5e.
ADR-1016 (Proposed) amended to match (Decisions 1 + 5).

Closes #1035

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-11 09:34:07 -04:00
committed by GitHub
parent b2222fa769
commit fbd62cd84f
21 changed files with 2988 additions and 20 deletions

View File

@@ -452,8 +452,201 @@ function validateFeatureBody(cap) {
// C3: Validate role:runtime body
const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']);
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'];
/**
* Validate a runtime.configHome object per ADR-1016 Decision 1.
* Returns an array of error strings.
*
* @param {string} capId Capability id (for error messages)
* @param {*} ch The configHome value
* @returns {string[]}
*/
function validateConfigHome(capId, ch) {
const errors = [];
const ctx = 'capability "' + capId + '" runtime.configHome';
if (typeof ch !== 'object' || ch === null || Array.isArray(ch)) {
errors.push(ctx + ' must be an object (got: ' + (ch === null ? 'null' : typeof ch) + ')');
return errors;
}
// kind — must be in closed vocab; inline literal guard (CodeQL barrier)
if (ch.kind === '__proto__' || ch.kind === 'constructor' || ch.kind === 'prototype') {
errors.push(ctx + '.kind "' + ch.kind + '" is a reserved name');
} else if (!VALID_CONFIG_HOME_KINDS.has(ch.kind)) {
errors.push(
ctx + '.kind must be one of: ' + [...VALID_CONFIG_HOME_KINDS].join(', ') +
' (got: ' + JSON.stringify(ch.kind) + ')',
);
}
// name — required string
if (typeof ch.name !== 'string' || ch.name.length === 0) {
errors.push(ctx + '.name must be a non-empty string');
}
// parent — required when kind == dot-home-nested
if (ch.kind === 'dot-home-nested') {
if (typeof ch.parent !== 'string' || ch.parent.length === 0) {
errors.push(ctx + '.parent must be a non-empty string when kind is "dot-home-nested"');
}
}
// env — required; must be an array of strings (every runtime has ≥0 env overrides)
if (!Array.isArray(ch.env)) {
errors.push(ctx + '.env is required and must be an array of strings (got: ' + JSON.stringify(ch.env) + ')');
} else {
for (let i = 0; i < ch.env.length; i++) {
if (typeof ch.env[i] !== 'string') {
errors.push(ctx + '.env[' + i + '] must be a string');
}
}
}
// probe — optional; if present must be an array of strings
if (ch.probe !== undefined) {
if (!Array.isArray(ch.probe)) {
errors.push(ctx + '.probe must be an array of strings if present');
} else {
for (let i = 0; i < ch.probe.length; i++) {
if (typeof ch.probe[i] !== 'string') {
errors.push(ctx + '.probe[' + i + '] must be a string');
}
}
}
}
// probeExists — optional; if present must be a non-empty string (sub-path existence check for probe)
if (ch.probeExists !== undefined) {
if (typeof ch.probeExists !== 'string' || ch.probeExists.length === 0) {
errors.push(ctx + '.probeExists must be a non-empty string if present (got: ' + JSON.stringify(ch.probeExists) + ')');
}
}
// skillsHome — optional; if present must be a full valid configHome object (recursive validation)
if (ch.skillsHome !== undefined) {
// Recursive call: validate skillsHome as a nested configHome.
// Use a synthetic capId to surface the sub-path in error messages.
const skillsHomeErrors = validateConfigHome(capId + '.skillsHome', ch.skillsHome);
// Rewrite the inner ctx prefix so errors read as "...runtime.configHome.skillsHome..."
for (const e of skillsHomeErrors) {
errors.push(e.replace(
'capability "' + capId + '.skillsHome" runtime.configHome',
ctx + '.skillsHome',
));
}
}
return errors;
}
/**
* Validate a single ArtifactKind entry per ADR-1016 Decision 3.
* Returns an array of error strings.
*
* @param {string} capId Capability id (for error messages)
* @param {*} entry The ArtifactKind object
* @param {string} prefix Path prefix for error messages (e.g. "artifactLayout.global[0]")
* @returns {string[]}
*/
function validateArtifactKindEntry(capId, entry, prefix) {
const errors = [];
const ctx = 'capability "' + capId + '" runtime.' + prefix;
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
errors.push(ctx + ' must be an object');
return errors;
}
// kind — must be in closed vocab; inline literal guard (CodeQL barrier)
if (entry.kind === '__proto__' || entry.kind === 'constructor' || entry.kind === 'prototype') {
errors.push(ctx + '.kind "' + entry.kind + '" is a reserved name');
} else if (!VALID_ARTIFACT_KIND_NAMES.has(entry.kind)) {
errors.push(
ctx + '.kind must be one of: ' + [...VALID_ARTIFACT_KIND_NAMES].join(', ') +
' (got: ' + JSON.stringify(entry.kind) + ')',
);
}
// destSubpath — required non-empty string
if (typeof entry.destSubpath !== 'string' || entry.destSubpath.length === 0) {
errors.push(ctx + '.destSubpath must be a non-empty string');
}
// nesting — optional; if present must be in closed vocab
if (entry.nesting !== undefined) {
if (!VALID_ARTIFACT_NESTINGS.has(entry.nesting)) {
errors.push(
ctx + '.nesting must be one of: ' + [...VALID_ARTIFACT_NESTINGS].join(', ') +
' (got: ' + JSON.stringify(entry.nesting) + ')',
);
}
}
// prefix — optional; if present must be a string
if (entry.prefix !== undefined) {
if (typeof entry.prefix !== 'string') {
errors.push(ctx + '.prefix must be a string if present (got: ' + typeof entry.prefix + ')');
}
}
// recursive — optional; if present must be a boolean
if (entry.recursive !== undefined) {
if (typeof entry.recursive !== 'boolean') {
errors.push(ctx + '.recursive must be a boolean if present (got: ' + typeof entry.recursive + ')');
}
}
// converter — optional; if present must be a string or null (closed ConverterName enum in phase 5e)
if (entry.converter !== undefined) {
if (entry.converter !== null && typeof entry.converter !== 'string') {
errors.push(ctx + '.converter must be a string or null if present (got: ' + typeof entry.converter + ')');
}
}
return errors;
}
/**
* Validate runtime.artifactLayout per ADR-1016 Decision 3.
* Accepts the structured { global, local } shape.
* Returns an array of error strings.
*
* @param {string} capId Capability id (for error messages)
* @param {*} layout The artifactLayout value
* @returns {string[]}
*/
function validateArtifactLayout(capId, layout) {
const errors = [];
const ctx = 'capability "' + capId + '" runtime.artifactLayout';
if (typeof layout !== 'object' || layout === null || Array.isArray(layout)) {
errors.push(ctx + ' must be an object with "global" and "local" arrays');
return errors;
}
for (const scope of ['global', 'local']) {
const arr = layout[scope];
if (!Array.isArray(arr)) {
errors.push(ctx + '.' + scope + ' must be an array');
} else {
for (let i = 0; i < arr.length; i++) {
errors.push(...validateArtifactKindEntry(capId, arr[i], 'artifactLayout.' + scope + '[' + i + ']'));
}
}
}
return errors;
}
function validateRuntimeBody(cap) {
const errors = [];
@@ -471,24 +664,61 @@ function validateRuntimeBody(cap) {
}
const r = cap.runtime;
if (typeof r.configHome !== 'string' || r.configHome.length === 0) {
errors.push('runtime.configHome must be a non-empty string');
}
// configHome — must be a structured object (ADR-1016 Decision 1)
errors.push(...validateConfigHome(cap.id || '(unknown)', r.configHome));
// configFormat — closed 5-enum (unchanged)
if (!VALID_CONFIG_FORMATS.has(r.configFormat)) {
errors.push('runtime.configFormat must be one of: ' + [...VALID_CONFIG_FORMATS].join(', ') + ' (got: ' + r.configFormat + ')');
}
if (!Array.isArray(r.artifactLayout)) {
errors.push('runtime.artifactLayout must be an array');
// artifactLayout — structured { global, local } per ADR-1016 Decision 3
errors.push(...validateArtifactLayout(cap.id || '(unknown)', r.artifactLayout));
// commandStyle — closed 2-enum (ADR-1016 Decision 4); inline literal guard (CodeQL barrier)
if (r.commandStyle === '__proto__' || r.commandStyle === 'constructor' || r.commandStyle === 'prototype') {
errors.push('runtime.commandStyle "' + r.commandStyle + '" is a reserved name');
} else if (!VALID_COMMAND_STYLES.has(r.commandStyle)) {
errors.push(
'runtime.commandStyle must be one of: ' + [...VALID_COMMAND_STYLES].join(', ') +
' (got: ' + JSON.stringify(r.commandStyle) + ')',
);
}
if (typeof r.commandStyle !== 'string' || r.commandStyle.length === 0) {
errors.push('runtime.commandStyle must be a non-empty string');
// hooksSurface — closed 6-enum (ADR-1016 Decision 5); inline literal guard (CodeQL barrier)
if (r.hooksSurface === '__proto__' || r.hooksSurface === 'constructor' || r.hooksSurface === 'prototype') {
errors.push('runtime.hooksSurface "' + r.hooksSurface + '" is a reserved name');
} else if (!VALID_HOOKS_SURFACES.has(r.hooksSurface)) {
errors.push(
'runtime.hooksSurface must be one of: ' + [...VALID_HOOKS_SURFACES].join(', ') +
' (got: ' + JSON.stringify(r.hooksSurface) + ')',
);
}
if (typeof r.hooksSurface !== 'string' || r.hooksSurface.length === 0) {
errors.push('runtime.hooksSurface must be a non-empty string');
// hookEvents — optional; if present must be in closed 3-enum (ADR-1016 Decision 5)
if (r.hookEvents !== undefined) {
if (r.hookEvents === '__proto__' || r.hookEvents === 'constructor' || r.hookEvents === 'prototype') {
errors.push('runtime.hookEvents "' + r.hookEvents + '" is a reserved name');
} else if (!VALID_HOOK_EVENTS.has(r.hookEvents)) {
errors.push(
'runtime.hookEvents must be one of: ' + [...VALID_HOOK_EVENTS].join(', ') +
' (got: ' + JSON.stringify(r.hookEvents) + ')',
);
}
}
if (typeof r.sandboxTier !== 'string' || r.sandboxTier.length === 0) {
errors.push('runtime.sandboxTier must be a non-empty string');
// sandboxTier — closed 2-enum (ADR-1016 Decision 6); inline literal guard (CodeQL barrier)
if (r.sandboxTier === '__proto__' || r.sandboxTier === 'constructor' || r.sandboxTier === 'prototype') {
errors.push('runtime.sandboxTier "' + r.sandboxTier + '" is a reserved name');
} else if (!VALID_SANDBOX_TIERS.has(r.sandboxTier)) {
errors.push(
'runtime.sandboxTier must be one of: ' + [...VALID_SANDBOX_TIERS].join(', ') +
' (got: ' + JSON.stringify(r.sandboxTier) + ')',
);
}
// supportTier — 1 or 2 (unchanged)
if (r.supportTier !== 1 && r.supportTier !== 2) {
errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')');
}
@@ -1833,6 +2063,17 @@ module.exports = {
runConsistencyGate,
// ADR-959: command entry validation
validateCommandEntry,
// ADR-1016 phase 5a: runtime body validators + closed-vocab sets
validateConfigHome,
validateArtifactLayout,
validateArtifactKindEntry,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
// FIX 5 (lazy): PROFILE_RANK and CLUSTERS are loaded on first access via getters
// so importing the generator on a fresh/unbuilt worktree doesn't fail at module load.
get PROFILE_RANK() { return getInstallProfiles().PROFILE_RANK; },