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

@@ -0,0 +1,36 @@
{
"id": "antigravity",
"role": "runtime",
"title": "Antigravity",
"description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; nested skill layout; tier-1 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home-nested",
"name": "antigravity",
"parent": ".gemini",
"env": ["ANTIGRAVITY_CONFIG_DIR"],
"probe": ["antigravity", "antigravity-ide", "antigravity-cli"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAntigravitySkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "gemini",
"sandboxTier": "none",
"supportTier": 1
}
}

View File

@@ -0,0 +1,42 @@
{
"id": "augment",
"role": "runtime",
"title": "Augment Code",
"description": "Augment Code CLI — commands + nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".augment",
"env": ["AUGMENT_CONFIG_DIR"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,51 @@
{
"id": "claude",
"role": "runtime",
"title": "Claude Code",
"description": "Anthropic Claude Code — primary development runtime; tier-1 support with full hook surface and skills-based global install.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".claude",
"env": ["CLAUDE_CONFIG_DIR"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": [
{
"kind": "commands",
"destSubpath": "commands/gsd",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
}
]
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 1
}
}

View File

@@ -0,0 +1,33 @@
{
"id": "cline",
"role": "runtime",
"title": "Cline",
"description": "Cline (VS Code extension) — global-only nested-skill layout; cline-rules hook surface (.clinerules); no hook events emitted; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".cline",
"env": ["CLINE_CONFIG_DIR"]
},
"configFormat": "markdown-dir",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClineSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "cline-rules",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,42 @@
{
"id": "codebuddy",
"role": "runtime",
"title": "CodeBuddy",
"description": "CodeBuddy (Tencent) — converted commands + skills artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".codebuddy",
"env": ["CODEBUDDY_CONFIG_DIR"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddyCommand"
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,34 @@
{
"id": "codex",
"role": "runtime",
"title": "OpenAI Codex CLI",
"description": "OpenAI Codex CLI — shell-var command style; per-agent sandbox tiers; config.toml + hooks.json hook surface; tier-1 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".codex",
"env": ["CODEX_HOME"]
},
"configFormat": "toml",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodexSkill"
}
],
"local": []
},
"commandStyle": "shell-var",
"hooksSurface": "codex-hooks-json",
"hookEvents": "claude",
"sandboxTier": "codex-agent-sandbox",
"supportTier": 1
}
}

View File

@@ -0,0 +1,33 @@
{
"id": "copilot",
"role": "runtime",
"title": "GitHub Copilot",
"description": "GitHub Copilot (VS Code) — markdown config format; copilot-inline hook surface; no hook events emitted; flat skill nesting (unconfirmed recursive loader); tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".copilot",
"env": ["COPILOT_CONFIG_DIR", "COPILOT_HOME"]
},
"configFormat": "markdown",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCopilotSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "copilot-inline",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,42 @@
{
"id": "cursor",
"role": "runtime",
"title": "Cursor",
"description": "Cursor IDE — skills + converted commands artifact layout; hooks.json surface; Claude hook event dialect; recursive skill loader (flat nesting); tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".cursor",
"env": ["CURSOR_CONFIG_DIR"]
},
"configFormat": "none",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToCursorSkill"
},
{
"kind": "commands",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "cursor-hooks-json",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,34 @@
{
"id": "gemini",
"role": "runtime",
"title": "Gemini CLI",
"description": "Google Gemini CLI — commands-only artifact layout (TOML); Gemini hook event dialect; settings-json hook surface; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".gemini",
"env": ["GEMINI_CONFIG_DIR"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "commands",
"destSubpath": "commands/gsd",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "gemini",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,34 @@
{
"id": "hermes",
"role": "runtime",
"title": "Hermes Agent",
"description": "Hermes Agent (NousResearch) — skills nest under skills/gsd/ category bucket; nested skill layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".hermes",
"env": ["HERMES_HOME"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills/gsd",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,46 @@
{
"id": "kilo",
"role": "runtime",
"title": "Kilo Code",
"description": "Kilo Code — XDG-based config dir; global skills at ~/.kilo/skills (separate from XDG config); flat command/ + skills artifact layout; no lifecycle hook registration; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "xdg",
"name": "kilo",
"env": ["KILO_CONFIG_DIR", "KILO_CONFIG", "XDG_CONFIG_HOME"],
"skillsHome": {
"kind": "dot-home",
"name": ".kilo",
"env": []
}
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToKiloSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,43 @@
{
"id": "kimi",
"role": "runtime",
"title": "Kimi CLI",
"description": "Kimi CLI (Moonshot AI) — generic agents root at ~/.config/agents; skills + kimi-agents artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "generic-agents-root",
"name": "agents",
"env": ["KIMI_CONFIG_DIR"],
"probe": ["~/.config/agents", "~/.agents"],
"probeExists": "skills"
},
"configFormat": "none",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToKimiSkill"
},
{
"kind": "kimi-agents",
"destSubpath": "agents",
"prefix": "gsd",
"nesting": "flat",
"recursive": false,
"converter": null
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,41 @@
{
"id": "opencode",
"role": "runtime",
"title": "OpenCode",
"description": "OpenCode — XDG-based config dir; flat command/ + skills artifact layout; settings-json config format; no lifecycle hook registration; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "xdg",
"name": "opencode",
"env": ["OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG", "XDG_CONFIG_HOME"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "commands",
"destSubpath": "command",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": null
},
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": true,
"converter": "convertClaudeCommandToOpencodeSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,34 @@
{
"id": "qwen",
"role": "runtime",
"title": "Qwen Code",
"description": "Qwen Code (Alibaba) — nested-skill artifact layout; settings-json hook surface; Claude hook event dialect; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".qwen",
"env": ["QWEN_CONFIG_DIR"]
},
"configFormat": "settings-json",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToClaudeSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "settings-json",
"hookEvents": "claude",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,33 @@
{
"id": "trae",
"role": "runtime",
"title": "Trae IDE",
"description": "Trae IDE — nested-skill artifact layout; no hook surface (profile-marker-only config); tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home",
"name": ".trae",
"env": ["TRAE_CONFIG_DIR"]
},
"configFormat": "none",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -0,0 +1,34 @@
{
"id": "windsurf",
"role": "runtime",
"title": "Windsurf",
"description": "Windsurf (Codeium) — nested under ~/.codeium/windsurf; skills-only artifact layout; no hook surface; no hook events; tier-2 support.",
"tier": "core",
"requires": [],
"runtime": {
"configHome": {
"kind": "dot-home-nested",
"name": "windsurf",
"parent": ".codeium",
"env": ["WINDSURF_CONFIG_DIR"]
},
"configFormat": "none",
"artifactLayout": {
"global": [
{
"kind": "skills",
"destSubpath": "skills",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToWindsurfSkill"
}
],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"sandboxTier": "none",
"supportTier": 2
}
}

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-06-10",
"generated": "2026-06-11",
"families": {
"agents": [
"gsd-advisor-researcher",

View File

@@ -32,8 +32,9 @@ configHome: {
kind: 'dot-home' | 'dot-home-nested' | 'xdg' | 'generic-agents-root',
name: string, // e.g. 'claude', 'opencode'
parent?: string, // for dot-home-nested: e.g. '.gemini' (antigravity), '.codeium' (windsurf)
env: string[], // ordered override env vars, e.g. ['CLAUDE_CONFIG_DIR']
env: string[], // ordered override env vars, e.g. ['CLAUDE_CONFIG_DIR'] (required; may be empty)
probe?: string[], // ordered candidate subpaths; first existing wins (antigravity, kimi)
probeExists?: string, // if set, select the first probe candidate where <candidate>/<probeExists> exists (kimi: 'skills')
skillsHome?: { kind, name, ... } // override when the skills dir ≠ config dir (kilo only)
}
```
@@ -82,7 +83,7 @@ hookEvents?: 'claude' // SessionStart/PreToolUse/PostToolUse
| 'opencode-subset' // settings-json surface but SessionStart/PostToolUse skipped
```
`settings-json` covers claude, gemini, antigravity, augment, qwen, hermes, codebuddy, opencode; the event-name and registration-subset differences ride on `hookEvents` rather than splitting the surface enum. `none` = windsurf, trae, kimi, kilo.
`settings-json` covers claude, gemini, antigravity, augment, qwen, hermes, codebuddy; the event-name and registration-subset differences ride on `hookEvents` rather than splitting the surface enum. `none` = windsurf, trae, kimi, kilo, opencode — these five runtimes register **zero** managed lifecycle hooks today; `opencode-subset` is reserved ADR vocabulary with no current consumer (opencode and kilo write a `settings.json` for config/permissions, which is the `configFormat` axis, not the hook-registration axis).
### 6. `sandboxTier` — the agent-sandbox primitive (closed enum)

File diff suppressed because it is too large Load Diff

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

View File

@@ -1150,15 +1150,19 @@ describe('C2: cross-capability consumes satisfiability (global pass)', () => {
// ─── 10. C3: role:runtime validation ─────────────────────────────────────────
describe('C3: role:runtime body validation', () => {
// ADR-1016 phase 5a: fixture updated to match tightened structured-value shapes.
// configHome is now an object (Decision 1), artifactLayout is { global, local } (Decision 3),
// commandStyle is closed enum (Decision 4), hooksSurface is closed enum (Decision 5).
const VALID_RUNTIME_CAP = {
id: 'cursor', role: 'runtime', title: 'Cursor', description: 'Cursor IDE runtime',
tier: 'standard', requires: [],
runtime: {
configHome: '~/.cursor',
configHome: { kind: 'dot-home', name: '.cursor', env: ['CURSOR_CONFIG_DIR'] },
configFormat: 'settings-json',
artifactLayout: [],
commandStyle: 'slash',
hooksSurface: 'rules',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'cursor-hooks-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 2,
},
@@ -2496,3 +2500,784 @@ describe('ADR-959: validateCapability — commands field on feature role', () =>
assert.ok(errors.some((e) => e.includes('commands')), 'Expected commands error, got: ' + JSON.stringify(errors));
});
});
// ─── 24. ADR-1016 phase 5a: runtime capability descriptors ───────────────────
const {
validateConfigHome,
validateArtifactLayout,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
} = require('../scripts/gen-capability-registry.cjs');
const RUNTIME_IDS = [
'claude', 'codex', 'antigravity', 'gemini', 'cursor', 'opencode',
'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes',
'codebuddy', 'cline', 'kimi', 'windsurf',
];
// Helper: build a minimal valid runtime capability object for fixture-based tests
function makeRuntimeCap(overrides) {
return {
id: 'test-rt',
role: 'runtime',
title: 'Test Runtime',
description: 'A synthetic runtime capability for testing.',
tier: 'core',
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-rt', env: ['TEST_RT_DIR'] },
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
hooksSurface: 'settings-json',
hookEvents: 'claude',
sandboxTier: 'none',
supportTier: 1,
...((overrides && overrides.runtime) ? overrides.runtime : {}),
},
...overrides,
};
}
// ── 24a. All 16 runtime ids appear in the runtimes index ─────────────────────
describe('ADR-1016 phase 5a: all 16 runtimes in registry index', () => {
let registry;
test('loadAndValidate + buildRegistry produces runtimes index with 16 entries', () => {
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'Expected no hard errors: ' + JSON.stringify(hardErrors));
registry = buildRegistry(capMap);
const runtimeKeys = Object.keys(registry.runtimes).sort();
assert.strictEqual(runtimeKeys.length, 16, 'Expected 16 runtime entries, got: ' + runtimeKeys.join(', '));
for (const id of RUNTIME_IDS) {
assert.ok(
Object.prototype.hasOwnProperty.call(registry.runtimes, id),
'runtimes index must contain "' + id + '"',
);
}
});
test('every runtimes entry has role: "runtime"', () => {
const { capMap } = loadAndValidate(new Set());
registry = buildRegistry(capMap);
for (const id of RUNTIME_IDS) {
assert.strictEqual(
registry.runtimes[id] && registry.runtimes[id].role, 'runtime',
'runtimes["' + id + '"].role must be "runtime"',
);
}
});
test('every runtimes entry has all 6 axes present in runtime object', () => {
const { capMap } = loadAndValidate(new Set());
registry = buildRegistry(capMap);
const requiredAxes = ['configHome', 'configFormat', 'artifactLayout', 'commandStyle', 'hooksSurface', 'sandboxTier'];
for (const id of RUNTIME_IDS) {
const rt = registry.runtimes[id] && registry.runtimes[id].runtime;
assert.ok(rt, 'runtimes["' + id + '"].runtime must exist');
for (const axis of requiredAxes) {
assert.ok(
Object.prototype.hasOwnProperty.call(rt, axis),
'runtimes["' + id + '"].runtime.' + axis + ' must be present',
);
}
// supportTier also required
assert.ok(
rt.supportTier === 1 || rt.supportTier === 2,
'runtimes["' + id + '"].runtime.supportTier must be 1 or 2 (got: ' + rt.supportTier + ')',
);
}
});
});
// ── 24b. Sample axis value assertions ────────────────────────────────────────
describe('ADR-1016 phase 5a: sample axis value assertions', () => {
test('codex: commandStyle === shell-var and sandboxTier === codex-agent-sandbox', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['codex'].runtime;
assert.strictEqual(rt.commandStyle, 'shell-var', 'codex.commandStyle must be "shell-var"');
assert.strictEqual(rt.sandboxTier, 'codex-agent-sandbox', 'codex.sandboxTier must be "codex-agent-sandbox"');
});
test('codex: configHome.kind === dot-home, name === .codex', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['codex'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home', 'codex.configHome.kind must be "dot-home"');
assert.strictEqual(rt.configHome.name, '.codex', 'codex.configHome.name must be ".codex"');
assert.ok(Array.isArray(rt.configHome.env) && rt.configHome.env.includes('CODEX_HOME'),
'codex.configHome.env must include "CODEX_HOME"');
});
test('claude: commandStyle === slash-hyphen, sandboxTier === none', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['claude'].runtime;
assert.strictEqual(rt.commandStyle, 'slash-hyphen', 'claude.commandStyle must be "slash-hyphen"');
assert.strictEqual(rt.sandboxTier, 'none', 'claude.sandboxTier must be "none"');
});
test('claude: configHome.kind === dot-home', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['claude'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home', 'claude.configHome.kind must be "dot-home"');
});
test('antigravity: configHome.kind === dot-home-nested with parent .gemini', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['antigravity'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home-nested', 'antigravity.configHome.kind must be "dot-home-nested"');
assert.strictEqual(rt.configHome.parent, '.gemini', 'antigravity.configHome.parent must be ".gemini"');
assert.ok(Array.isArray(rt.configHome.probe), 'antigravity.configHome.probe must be an array');
assert.ok(rt.configHome.probe.length > 0, 'antigravity.configHome.probe must be non-empty');
});
test('kilo: configHome.skillsHome is present', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kilo'].runtime;
assert.ok(rt.configHome.skillsHome, 'kilo.configHome.skillsHome must be present');
assert.ok(typeof rt.configHome.skillsHome.kind === 'string', 'kilo.configHome.skillsHome.kind must be a string');
});
test('windsurf: configHome.kind === dot-home-nested with parent .codeium', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['windsurf'].runtime;
assert.strictEqual(rt.configHome.kind, 'dot-home-nested', 'windsurf.configHome.kind must be "dot-home-nested"');
assert.strictEqual(rt.configHome.parent, '.codeium', 'windsurf.configHome.parent must be ".codeium"');
});
test('kimi: configHome.kind === generic-agents-root', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kimi'].runtime;
assert.strictEqual(rt.configHome.kind, 'generic-agents-root', 'kimi.configHome.kind must be "generic-agents-root"');
});
test('antigravity: hookEvents === gemini', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['antigravity'].runtime;
assert.strictEqual(rt.hookEvents, 'gemini', 'antigravity.hookEvents must be "gemini"');
});
test('opencode: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['opencode'].runtime;
assert.strictEqual(rt.hooksSurface, 'none', 'opencode.hooksSurface must be "none" (no managed lifecycle hooks)');
assert.ok(
!Object.prototype.hasOwnProperty.call(rt, 'hookEvents'),
'opencode.runtime must NOT have hookEvents (no lifecycle hook registration)',
);
});
test('kilo: hooksSurface === none, no hookEvents (registers zero lifecycle hooks)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kilo'].runtime;
assert.strictEqual(rt.hooksSurface, 'none', 'kilo.hooksSurface must be "none" (no managed lifecycle hooks)');
assert.ok(
!Object.prototype.hasOwnProperty.call(rt, 'hookEvents'),
'kilo.runtime must NOT have hookEvents (no lifecycle hook registration)',
);
});
test('kimi: configHome.probeExists === "skills" (probe selects first candidate with skills/ dir)', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const rt = registry.runtimes['kimi'].runtime;
assert.strictEqual(
rt.configHome.probeExists, 'skills',
'kimi.configHome.probeExists must be "skills" (selects first probe candidate where <candidate>/skills exists)',
);
});
test('copilot/trae/kimi/windsurf/cline/opencode/kilo: hooksSurface none or copilot-inline/cline-rules, no hookEvents', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
// opencode and kilo register ZERO lifecycle hooks → hooksSurface none, no hookEvents
const noEventsRuntimes = ['trae', 'kimi', 'windsurf', 'opencode', 'kilo'];
for (const id of noEventsRuntimes) {
const rt = registry.runtimes[id].runtime;
assert.ok(
!Object.prototype.hasOwnProperty.call(rt, 'hookEvents'),
id + '.runtime must NOT have hookEvents (no hook events for this runtime)',
);
}
// cline has cline-rules surface but no hookEvents
const clineRt = registry.runtimes['cline'].runtime;
assert.strictEqual(clineRt.hooksSurface, 'cline-rules', 'cline.hooksSurface must be "cline-rules"');
assert.ok(
!Object.prototype.hasOwnProperty.call(clineRt, 'hookEvents'),
'cline.runtime must NOT have hookEvents',
);
// copilot has copilot-inline surface but no hookEvents
const copilotRt = registry.runtimes['copilot'].runtime;
assert.strictEqual(copilotRt.hooksSurface, 'copilot-inline', 'copilot.hooksSurface must be "copilot-inline"');
assert.ok(
!Object.prototype.hasOwnProperty.call(copilotRt, 'hookEvents'),
'copilot.runtime must NOT have hookEvents',
);
});
test('codex: hooksSurface === codex-hooks-json, cursor: hooksSurface === cursor-hooks-json', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
assert.strictEqual(registry.runtimes['codex'].runtime.hooksSurface, 'codex-hooks-json',
'codex.hooksSurface must be "codex-hooks-json"');
assert.strictEqual(registry.runtimes['cursor'].runtime.hooksSurface, 'cursor-hooks-json',
'cursor.hooksSurface must be "cursor-hooks-json"');
});
test('tier-1 runtimes: claude, codex, antigravity have supportTier === 1', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
for (const id of ['claude', 'codex', 'antigravity']) {
assert.strictEqual(
registry.runtimes[id].runtime.supportTier, 1,
id + '.runtime.supportTier must be 1 (tier-1 support)',
);
}
});
test('tier-2 runtimes: gemini through windsurf have supportTier === 2', () => {
const { capMap } = loadAndValidate(new Set());
const registry = buildRegistry(capMap);
const tier2 = ['gemini', 'cursor', 'opencode', 'kilo', 'copilot', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi', 'windsurf'];
for (const id of tier2) {
assert.strictEqual(
registry.runtimes[id].runtime.supportTier, 2,
id + '.runtime.supportTier must be 2 (tier-2 support)',
);
}
});
});
// ── 24c. Closed-vocab REJECTION tests ────────────────────────────────────────
describe('ADR-1016 phase 5a: closed-vocab rejection — tightened validateRuntimeBody', () => {
test('bad configHome.kind → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'custom-home', name: '.test', env: [] } } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('configHome') && e.includes('kind')),
'Expected configHome.kind error, got: ' + JSON.stringify(errors),
);
});
test('configHome is a string (old shape) → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: '~/.test-rt' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('configHome')),
'Expected configHome object error (string not accepted), got: ' + JSON.stringify(errors),
);
});
test('dot-home-nested without parent → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { configHome: { kind: 'dot-home-nested', name: 'foo', env: [] } } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('parent') && e.includes('dot-home-nested')),
'Expected parent required for dot-home-nested, got: ' + JSON.stringify(errors),
);
});
test('bad commandStyle → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { commandStyle: 'slash-colon' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('commandStyle')),
'Expected commandStyle error, got: ' + JSON.stringify(errors),
);
});
test('bad hooksSurface → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { hooksSurface: 'custom-hooks' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('hooksSurface')),
'Expected hooksSurface error, got: ' + JSON.stringify(errors),
);
});
test('bad hookEvents → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { hookEvents: 'my-dialect' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('hookEvents')),
'Expected hookEvents error, got: ' + JSON.stringify(errors),
);
});
test('bad sandboxTier → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { sandboxTier: 'workspace-sandbox' } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('sandboxTier')),
'Expected sandboxTier error, got: ' + JSON.stringify(errors),
);
});
test('artifactLayout is an array (old shape) → validation error', () => {
const cap = makeRuntimeCap({ id: 'test-rt', runtime: { artifactLayout: [] } });
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('artifactLayout')),
'Expected artifactLayout shape error (old array not accepted), got: ' + JSON.stringify(errors),
);
});
test('bad ArtifactKind.kind in artifactLayout → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'magic-kind', destSubpath: 'x', prefix: 'g-', nesting: 'flat', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('kind')),
'Expected ArtifactKind.kind error, got: ' + JSON.stringify(errors),
);
});
test('bad ArtifactKind.nesting → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'deep', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('nesting')),
'Expected nesting error, got: ' + JSON.stringify(errors),
);
});
test('valid runtime descriptor passes validateCapability with no errors', () => {
const cap = makeRuntimeCap({ id: 'test-rt' });
const errors = validateCapability(cap, 'test-rt');
assert.deepEqual(errors, [], 'Expected no errors for valid runtime cap: ' + JSON.stringify(errors));
});
});
// ── 24d. validateConfigHome + validateArtifactLayout unit tests ───────────────
describe('ADR-1016 phase 5a: validateConfigHome unit tests', () => {
test('valid dot-home with env → no errors', () => {
const errors = validateConfigHome('test', { kind: 'dot-home', name: '.test', env: ['TEST_DIR'] });
assert.deepEqual(errors, []);
});
test('valid dot-home-nested with parent → no errors', () => {
const errors = validateConfigHome('test', { kind: 'dot-home-nested', name: 'foo', parent: '.bar', env: [] });
assert.deepEqual(errors, []);
});
test('valid xdg → no errors', () => {
const errors = validateConfigHome('test', { kind: 'xdg', name: 'opencode', env: ['OPENCODE_CONFIG_DIR', 'XDG_CONFIG_HOME'] });
assert.deepEqual(errors, []);
});
test('valid generic-agents-root with probe → no errors', () => {
const errors = validateConfigHome('test', { kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'], probe: ['~/.config/agents'] });
assert.deepEqual(errors, []);
});
test('null configHome → error', () => {
const errors = validateConfigHome('test', null);
assert.ok(errors.length > 0 && errors.some((e) => e.includes('configHome')));
});
test('string configHome → error', () => {
const errors = validateConfigHome('test', '~/.test');
assert.ok(errors.length > 0 && errors.some((e) => e.includes('configHome')));
});
test('unknown kind → error mentioning the valid set', () => {
const errors = validateConfigHome('test', { kind: 'unknown', name: '.x', env: [] });
assert.ok(errors.some((e) => e.includes('kind') && e.includes('dot-home')),
'Error should list valid kinds, got: ' + JSON.stringify(errors));
});
test('dot-home-nested missing parent → error', () => {
const errors = validateConfigHome('test', { kind: 'dot-home-nested', name: 'x', env: [] });
assert.ok(errors.some((e) => e.includes('parent')));
});
test('skillsHome with valid kind → no errors', () => {
const errors = validateConfigHome('test', {
kind: 'xdg', name: 'kilo', env: [],
skillsHome: { kind: 'dot-home', name: '.kilo', env: [] },
});
assert.deepEqual(errors, []);
});
test('skillsHome with bad kind → error', () => {
const errors = validateConfigHome('test', {
kind: 'xdg', name: 'kilo', env: [],
skillsHome: { kind: 'exotic', name: '.kilo', env: [] },
});
assert.ok(errors.some((e) => e.includes('skillsHome') && e.includes('kind')));
});
});
describe('ADR-1016 phase 5a: validateArtifactLayout unit tests', () => {
test('valid empty global/local → no errors', () => {
const errors = validateArtifactLayout('test', { global: [], local: [] });
assert.deepEqual(errors, []);
});
test('valid skills entry in global → no errors', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null }],
local: [],
});
assert.deepEqual(errors, []);
});
test('array (old shape) → error', () => {
const errors = validateArtifactLayout('test', []);
assert.ok(errors.some((e) => e.includes('artifactLayout')));
});
test('missing global → error', () => {
const errors = validateArtifactLayout('test', { local: [] });
assert.ok(errors.some((e) => e.includes('global')));
});
test('missing local → error', () => {
const errors = validateArtifactLayout('test', { global: [] });
assert.ok(errors.some((e) => e.includes('local')));
});
test('bad kind in global[0] → error', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'bad-kind', destSubpath: 'x', prefix: '', nesting: 'flat', recursive: false, converter: null }],
local: [],
});
assert.ok(errors.some((e) => e.includes('kind')));
});
test('bad nesting in global[0] → error', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'skills', destSubpath: 'x', prefix: '', nesting: 'trilateral', recursive: false, converter: null }],
local: [],
});
assert.ok(errors.some((e) => e.includes('nesting')));
});
test('kimi-agents kind → no errors', () => {
const errors = validateArtifactLayout('test', {
global: [{ kind: 'kimi-agents', destSubpath: 'agents', prefix: 'gsd', nesting: 'flat', recursive: false, converter: null }],
local: [],
});
assert.deepEqual(errors, []);
});
});
// ── 24d-extra. FIX 3: tightened validateRuntimeBody / validateConfigHome ──────
describe('FIX 3: tightened runtime validator — configHome.env required', () => {
test('configHome missing env → validation error', () => {
// env is now required; omitting it must produce an error
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: { configHome: { kind: 'dot-home', name: '.test-rt' } }, // no env
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('env') && (e.includes('required') || e.includes('array'))),
'Expected configHome.env required error, got: ' + JSON.stringify(errors),
);
});
test('configHome with env: [] (empty array) is accepted', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: [] } },
});
const errors = validateCapability(cap, 'test-rt');
const envErrors = errors.filter((e) => e.includes('env'));
assert.deepEqual(envErrors, [], 'Empty env array should be accepted, got: ' + JSON.stringify(envErrors));
});
test('configHome with env: null → validation error (not an array)', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: { configHome: { kind: 'dot-home', name: '.test-rt', env: null } },
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('env')),
'Expected env error for null, got: ' + JSON.stringify(errors),
);
});
});
describe('FIX 3: tightened runtime validator — skillsHome recursive validation', () => {
test('skillsHome with bad kind → validation error via full recursive check', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
configHome: {
kind: 'xdg', name: 'test', env: [],
skillsHome: { kind: 'exotic-kind', name: '.test', env: [] },
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('skillsHome') && e.includes('kind')),
'Expected skillsHome.kind error for exotic-kind, got: ' + JSON.stringify(errors),
);
});
test('skillsHome missing env → validation error (env required in recursive check)', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
configHome: {
kind: 'xdg', name: 'test', env: [],
skillsHome: { kind: 'dot-home', name: '.test' }, // no env
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('skillsHome') && e.includes('env')),
'Expected skillsHome.env required error, got: ' + JSON.stringify(errors),
);
});
test('kilo descriptor: skillsHome passes full validation', () => {
// kilo has skillsHome: { kind: "dot-home", name: ".kilo", env: [] } — must pass
const { capMap, errors } = loadAndValidate(new Set());
const hardErrors = errors.filter((e) => !e.includes('pending-migration'));
assert.deepEqual(hardErrors, [], 'No hard errors expected for kilo, got: ' + JSON.stringify(hardErrors));
const kiloRt = capMap.get('kilo');
assert.ok(kiloRt, 'kilo must be in capMap');
assert.ok(kiloRt.runtime.configHome.skillsHome, 'kilo.configHome.skillsHome must be present');
assert.strictEqual(kiloRt.runtime.configHome.skillsHome.kind, 'dot-home');
assert.strictEqual(kiloRt.runtime.configHome.skillsHome.name, '.kilo');
});
});
describe('FIX 3: tightened runtime validator — ArtifactKind field type checks', () => {
test('ArtifactKind with recursive: "yes" (non-boolean) → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: 'yes', converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('recursive') && e.includes('boolean')),
'Expected recursive non-boolean error, got: ' + JSON.stringify(errors),
);
});
test('ArtifactKind with recursive: true (boolean) → no error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: true, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
const recursiveErrors = errors.filter((e) => e.includes('recursive'));
assert.deepEqual(recursiveErrors, [], 'recursive: true must be accepted, got: ' + JSON.stringify(recursiveErrors));
});
test('ArtifactKind with prefix: 42 (non-string) → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 42, nesting: 'flat', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('prefix') && e.includes('string')),
'Expected prefix non-string error, got: ' + JSON.stringify(errors),
);
});
test('ArtifactKind with converter: 123 (non-string, non-null) → validation error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: 123 }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
assert.ok(
errors.some((e) => e.includes('converter') && (e.includes('string') || e.includes('null'))),
'Expected converter type error, got: ' + JSON.stringify(errors),
);
});
test('ArtifactKind with converter: null → no error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'converter: null must be accepted, got: ' + JSON.stringify(converterErrors));
});
test('ArtifactKind with converter: "convertClaudeCommandToKiloSkill" (string) → no error', () => {
const cap = makeRuntimeCap({
id: 'test-rt',
runtime: {
artifactLayout: {
global: [{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', nesting: 'flat', recursive: true, converter: 'convertClaudeCommandToKiloSkill' }],
local: [],
},
},
});
const errors = validateCapability(cap, 'test-rt');
const converterErrors = errors.filter((e) => e.includes('converter'));
assert.deepEqual(converterErrors, [], 'converter: string must be accepted, got: ' + JSON.stringify(converterErrors));
});
});
describe('FIX 3: tightened runtime validator — probeExists optional string', () => {
test('probeExists: "skills" is accepted', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'],
probe: ['~/.config/agents', '~/.agents'],
probeExists: 'skills',
});
assert.deepEqual(errors, [], 'probeExists: "skills" must be accepted, got: ' + JSON.stringify(errors));
});
test('probeExists: "" (empty string) → error', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: [],
probeExists: '',
});
assert.ok(
errors.some((e) => e.includes('probeExists')),
'Expected probeExists empty string error, got: ' + JSON.stringify(errors),
);
});
test('probeExists: 42 (non-string) → error', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: [],
probeExists: 42,
});
assert.ok(
errors.some((e) => e.includes('probeExists')),
'Expected probeExists non-string error, got: ' + JSON.stringify(errors),
);
});
test('probeExists absent → no error (optional)', () => {
const errors = validateConfigHome('test', {
kind: 'generic-agents-root', name: 'agents', env: ['KIMI_CONFIG_DIR'],
probe: ['~/.config/agents'],
});
const probeExistsErrors = errors.filter((e) => e.includes('probeExists'));
assert.deepEqual(probeExistsErrors, [], 'probeExists is optional — no error when absent, got: ' + JSON.stringify(probeExistsErrors));
});
});
// ── 24e. Closed-vocab set exports are correct ─────────────────────────────────
describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
test('VALID_CONFIG_HOME_KINDS has exactly the 4 expected values', () => {
assert.ok(VALID_CONFIG_HOME_KINDS instanceof Set);
for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']) {
assert.ok(VALID_CONFIG_HOME_KINDS.has(v), 'VALID_CONFIG_HOME_KINDS must contain "' + v + '"');
}
assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 4, 'VALID_CONFIG_HOME_KINDS must have exactly 4 members');
});
test('VALID_COMMAND_STYLES has exactly 2 values', () => {
assert.ok(VALID_COMMAND_STYLES instanceof Set);
assert.ok(VALID_COMMAND_STYLES.has('slash-hyphen'));
assert.ok(VALID_COMMAND_STYLES.has('shell-var'));
assert.strictEqual(VALID_COMMAND_STYLES.size, 2);
});
test('VALID_HOOKS_SURFACES has exactly 6 values', () => {
assert.ok(VALID_HOOKS_SURFACES instanceof Set);
for (const v of ['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'none']) {
assert.ok(VALID_HOOKS_SURFACES.has(v), 'VALID_HOOKS_SURFACES must contain "' + v + '"');
}
assert.strictEqual(VALID_HOOKS_SURFACES.size, 6);
});
test('VALID_HOOK_EVENTS has exactly 3 values', () => {
assert.ok(VALID_HOOK_EVENTS instanceof Set);
for (const v of ['claude', 'gemini', 'opencode-subset']) {
assert.ok(VALID_HOOK_EVENTS.has(v), 'VALID_HOOK_EVENTS must contain "' + v + '"');
}
assert.strictEqual(VALID_HOOK_EVENTS.size, 3);
});
test('VALID_SANDBOX_TIERS has exactly 2 values', () => {
assert.ok(VALID_SANDBOX_TIERS instanceof Set);
assert.ok(VALID_SANDBOX_TIERS.has('none'));
assert.ok(VALID_SANDBOX_TIERS.has('codex-agent-sandbox'));
assert.strictEqual(VALID_SANDBOX_TIERS.size, 2);
});
test('VALID_ARTIFACT_KIND_NAMES has exactly 4 values', () => {
assert.ok(VALID_ARTIFACT_KIND_NAMES instanceof Set);
for (const v of ['commands', 'agents', 'skills', 'kimi-agents']) {
assert.ok(VALID_ARTIFACT_KIND_NAMES.has(v), 'VALID_ARTIFACT_KIND_NAMES must contain "' + v + '"');
}
assert.strictEqual(VALID_ARTIFACT_KIND_NAMES.size, 4);
});
test('VALID_ARTIFACT_NESTINGS has exactly 2 values', () => {
assert.ok(VALID_ARTIFACT_NESTINGS instanceof Set);
assert.ok(VALID_ARTIFACT_NESTINGS.has('flat'));
assert.ok(VALID_ARTIFACT_NESTINGS.has('nested'));
assert.strictEqual(VALID_ARTIFACT_NESTINGS.size, 2);
});
});