refactor(#1756): derive getDirName from a documented runtime.localConfigDir descriptor axis (#1757)

ADR-1239 Phase B (parent #1679). getDirName was a hand-maintained 15-branch
if-chain mapping each runtime to its local content-rewrite dot-dir. Relocate
those values into a documented runtime.localConfigDir descriptor field; derive
getDirName from registry.runtimes[id].runtime.localConfigDir (fallback .claude).

- 16 capability.json gain runtime.localConfigDir (byte-identical values)
- capability-validator.cjs requires it (non-empty dot-dir); registry regenerated
- docs/reference/capability-manifest.md documents the field + the three
  divergent values (copilot=.github, antigravity=.agents, kimi=.kimi-code)
- drift-guard test: golden value map + key-set equality both ways

Byte-identical install output for all 16 runtimes (golden-parity harness #1730).

Closes #1756

Co-authored-by: review-bot <review-bot@gsd>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-26 12:44:09 -04:00
committed by GitHub
parent e075a41c86
commit a3d3c2a445
25 changed files with 174 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 1757
---
**Internal: getDirName is now derived from a documented `runtime.localConfigDir` descriptor field** — each runtime's local content-rewrite directory (e.g. `cursor`→`.cursor`, `copilot`→`.github`) moved from a hand-maintained if-chain into its capability descriptor (ADR-1239 Phase B), so it can no longer drift from the registry. Install output is byte-identical for all 16 runtimes (golden-parity asserted); no user-facing change.

View File

@@ -24,6 +24,7 @@
],
"probeExists": "gsd-core/VERSION"
},
"localConfigDir": ".agents",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"AUGMENT_CONFIG_DIR"
]
},
"localConfigDir": ".augment",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"CLAUDE_CONFIG_DIR"
]
},
"localConfigDir": ".claude",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"CLINE_CONFIG_DIR"
]
},
"localConfigDir": ".cline",
"configFormat": "markdown-dir",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"CODEBUDDY_CONFIG_DIR"
]
},
"localConfigDir": ".codebuddy",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"CODEX_HOME"
]
},
"localConfigDir": ".codex",
"configFormat": "toml",
"artifactLayout": {
"global": [

View File

@@ -18,6 +18,7 @@
"COPILOT_HOME"
]
},
"localConfigDir": ".github",
"configFormat": "markdown",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"CURSOR_CONFIG_DIR"
]
},
"localConfigDir": ".cursor",
"configFormat": "none",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"GEMINI_CONFIG_DIR"
]
},
"localConfigDir": ".gemini",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"HERMES_HOME"
]
},
"localConfigDir": ".hermes",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -24,6 +24,7 @@
"env": []
}
},
"localConfigDir": ".kilo",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -22,6 +22,7 @@
],
"probeExists": "skills"
},
"localConfigDir": ".kimi-code",
"configFormat": "none",
"artifactLayout": {
"global": [

View File

@@ -19,6 +19,7 @@
"XDG_CONFIG_HOME"
]
},
"localConfigDir": ".opencode",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"QWEN_CONFIG_DIR"
]
},
"localConfigDir": ".qwen",
"configFormat": "settings-json",
"artifactLayout": {
"global": [

View File

@@ -17,6 +17,7 @@
"TRAE_CONFIG_DIR"
]
},
"localConfigDir": ".trae",
"configFormat": "none",
"artifactLayout": {
"global": [

View File

@@ -18,6 +18,7 @@
"WINDSURF_CONFIG_DIR"
]
},
"localConfigDir": ".windsurf",
"configFormat": "none",
"artifactLayout": {
"global": [],

View File

@@ -143,6 +143,7 @@ Runtime capabilities describe how GSD projects its artefacts onto one host CLI.
| Axis | Field | Type summary |
|---|---|---|
| Config home | `runtime.configHome` | Structured object with `kind` (`dot-home` \| `dot-home-nested` \| `xdg` \| `generic-agents-root`), `name`, optional `parent`, `env[]`, `probe[]`, `probeExists`, `skillsHome`. `probeExists` is an optional sub-path applied to probe candidates: for `generic-agents-root` it is a hard filter (a candidate qualifies only if `<candidate>/<probeExists>` exists); for `dot-home-nested` it is a preference that makes probing pick the candidate GSD owns (e.g. `gsd-core/VERSION`) over a bare-existing sibling before falling back — see ADR-1016 and #213/#217. |
| Local config dir | `runtime.localConfigDir` | Required dot-prefixed string. The runtime's **local** content-rewrite directory — the `./` target GSD stamps into rewritten artefact bodies (e.g. `./.claude/` → `./<localConfigDir>/`) and the local install dir basename. Backs `getDirName()` (registry-derived, #1679). Usually `.<runtime>` (the runtime's home dot-dir), but **three runtimes diverge** because they read GSD's content from a non-home directory: `copilot` → `.github` (GitHub Copilot reads custom instructions from `.github/copilot-instructions.md` / `.github/instructions/`; see `convertClaudeToCopilotContent` rewrites in `src/runtime-artifact-conversion.cts`), `antigravity` → `.agents` (local agent/workflow dir; see the antigravity rewrites in `src/runtime-artifact-conversion.cts`), `kimi` → `.kimi-code`. Distinct from `configHome.name` (the **global** install home, which for these three is `.copilot` / `antigravity` / `agents`). Byte-parity-proven against the prior hand-maintained mapping by the golden-install-parity harness. |
| Config format | `runtime.configFormat` | Closed enum: `settings-json` \| `toml` \| `markdown` \| `markdown-dir` \| `none`. |
| Artefact layout | `runtime.artifactLayout` | Object with `global` and `local` arrays of `ArtifactKind` (`kind`, `destSubpath`, `prefix`, `nesting`, `recursive`, `stage`). |
| Command style | `runtime.commandStyle` | Closed enum: `slash-hyphen` \| `shell-var`. |

View File

@@ -86,6 +86,7 @@ const capabilities = {
],
"probeExists": "gsd-core/VERSION"
},
"localConfigDir": ".agents",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -193,6 +194,7 @@ const capabilities = {
"AUGMENT_CONFIG_DIR"
]
},
"localConfigDir": ".augment",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -279,6 +281,7 @@ const capabilities = {
"CLAUDE_CONFIG_DIR"
]
},
"localConfigDir": ".claude",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -362,6 +365,7 @@ const capabilities = {
"CLINE_CONFIG_DIR"
]
},
"localConfigDir": ".cline",
"configFormat": "markdown-dir",
"artifactLayout": {
"global": [
@@ -483,6 +487,7 @@ const capabilities = {
"CODEBUDDY_CONFIG_DIR"
]
},
"localConfigDir": ".codebuddy",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -569,6 +574,7 @@ const capabilities = {
"CODEX_HOME"
]
},
"localConfigDir": ".codex",
"configFormat": "toml",
"artifactLayout": {
"global": [
@@ -640,6 +646,7 @@ const capabilities = {
"COPILOT_HOME"
]
},
"localConfigDir": ".github",
"configFormat": "markdown",
"artifactLayout": {
"global": [
@@ -709,6 +716,7 @@ const capabilities = {
"CURSOR_CONFIG_DIR"
]
},
"localConfigDir": ".cursor",
"configFormat": "none",
"artifactLayout": {
"global": [
@@ -914,6 +922,7 @@ const capabilities = {
"GEMINI_CONFIG_DIR"
]
},
"localConfigDir": ".gemini",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -1029,6 +1038,7 @@ const capabilities = {
"HERMES_HOME"
]
},
"localConfigDir": ".hermes",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -1158,6 +1168,7 @@ const capabilities = {
"env": []
}
},
"localConfigDir": ".kilo",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -1248,6 +1259,7 @@ const capabilities = {
],
"probeExists": "skills"
},
"localConfigDir": ".kimi-code",
"configFormat": "none",
"artifactLayout": {
"global": [
@@ -1542,6 +1554,7 @@ const capabilities = {
"XDG_CONFIG_HOME"
]
},
"localConfigDir": ".opencode",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -1758,6 +1771,7 @@ const capabilities = {
"QWEN_CONFIG_DIR"
]
},
"localConfigDir": ".qwen",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -2082,6 +2096,7 @@ const capabilities = {
"TRAE_CONFIG_DIR"
]
},
"localConfigDir": ".trae",
"configFormat": "none",
"artifactLayout": {
"global": [
@@ -2247,6 +2262,7 @@ const capabilities = {
"WINDSURF_CONFIG_DIR"
]
},
"localConfigDir": ".windsurf",
"configFormat": "none",
"artifactLayout": {
"global": [],
@@ -3038,6 +3054,7 @@ const runtimes = {
],
"probeExists": "gsd-core/VERSION"
},
"localConfigDir": ".agents",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -3108,6 +3125,7 @@ const runtimes = {
"AUGMENT_CONFIG_DIR"
]
},
"localConfigDir": ".augment",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -3194,6 +3212,7 @@ const runtimes = {
"CLAUDE_CONFIG_DIR"
]
},
"localConfigDir": ".claude",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -3277,6 +3296,7 @@ const runtimes = {
"CLINE_CONFIG_DIR"
]
},
"localConfigDir": ".cline",
"configFormat": "markdown-dir",
"artifactLayout": {
"global": [
@@ -3337,6 +3357,7 @@ const runtimes = {
"CODEBUDDY_CONFIG_DIR"
]
},
"localConfigDir": ".codebuddy",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -3423,6 +3444,7 @@ const runtimes = {
"CODEX_HOME"
]
},
"localConfigDir": ".codex",
"configFormat": "toml",
"artifactLayout": {
"global": [
@@ -3494,6 +3516,7 @@ const runtimes = {
"COPILOT_HOME"
]
},
"localConfigDir": ".github",
"configFormat": "markdown",
"artifactLayout": {
"global": [
@@ -3563,6 +3586,7 @@ const runtimes = {
"CURSOR_CONFIG_DIR"
]
},
"localConfigDir": ".cursor",
"configFormat": "none",
"artifactLayout": {
"global": [
@@ -3649,6 +3673,7 @@ const runtimes = {
"GEMINI_CONFIG_DIR"
]
},
"localConfigDir": ".gemini",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -3723,6 +3748,7 @@ const runtimes = {
"HERMES_HOME"
]
},
"localConfigDir": ".hermes",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -3800,6 +3826,7 @@ const runtimes = {
"env": []
}
},
"localConfigDir": ".kilo",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -3890,6 +3917,7 @@ const runtimes = {
],
"probeExists": "skills"
},
"localConfigDir": ".kimi-code",
"configFormat": "none",
"artifactLayout": {
"global": [
@@ -3960,6 +3988,7 @@ const runtimes = {
"XDG_CONFIG_HOME"
]
},
"localConfigDir": ".opencode",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -4045,6 +4074,7 @@ const runtimes = {
"QWEN_CONFIG_DIR"
]
},
"localConfigDir": ".qwen",
"configFormat": "settings-json",
"artifactLayout": {
"global": [
@@ -4119,6 +4149,7 @@ const runtimes = {
"TRAE_CONFIG_DIR"
]
},
"localConfigDir": ".trae",
"configFormat": "none",
"artifactLayout": {
"global": [
@@ -4189,6 +4220,7 @@ const runtimes = {
"WINDSURF_CONFIG_DIR"
]
},
"localConfigDir": ".windsurf",
"configFormat": "none",
"artifactLayout": {
"global": [],

View File

@@ -1030,6 +1030,20 @@ function validateRuntimeBody(cap) {
);
}
// localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679)
// Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry
// generator catches any descriptor missing the field before regenerating.
if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
errors.push(
'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
'got: ' + JSON.stringify(r.localConfigDir),
);
} else if (!r.localConfigDir.startsWith('.')) {
errors.push(
'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
);
}
// extendedHookEvents — required array; every element must be in closed enum
if (!Array.isArray(r.extendedHookEvents)) {
errors.push(

View File

@@ -142,20 +142,12 @@ export function getProjectInstructionFile(runtime: unknown): string {
* `bin/install.js` re-exports this same function for back-compat.
*/
export function getDirName(runtime: string): string {
if (runtime === 'copilot') return '.github';
if (runtime === 'opencode') return '.opencode';
if (runtime === 'gemini') return '.gemini';
if (runtime === 'kilo') return '.kilo';
if (runtime === 'codex') return '.codex';
if (runtime === 'antigravity') return '.agents';
if (runtime === 'cursor') return '.cursor';
if (runtime === 'windsurf') return '.windsurf';
if (runtime === 'augment') return '.augment';
if (runtime === 'trae') return '.trae';
if (runtime === 'qwen') return '.qwen';
if (runtime === 'hermes') return '.hermes';
if (runtime === 'kimi') return '.kimi-code';
if (runtime === 'codebuddy') return '.codebuddy';
if (runtime === 'cline') return '.cline';
if (!runtime) return '.claude';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runtimes } = require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: { localConfigDir?: string } } | undefined>;
};
const dir = runtimes[runtime]?.runtime?.localConfigDir;
if (typeof dir === 'string' && dir.length > 0) return dir;
return '.claude';
}

View File

@@ -76,6 +76,7 @@ function runtimeCap(overrides) {
engines: { gsd: '>=1.6.0' },
runtime: {
configHome: { kind: 'dot-home', name: '.demo', env: [] },
localConfigDir: '.demo',
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',

View File

@@ -1764,6 +1764,7 @@ describe('C3: role:runtime body validation', () => {
tier: 'standard', requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.cursor', env: ['CURSOR_CONFIG_DIR'] },
localConfigDir: '.cursor',
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
@@ -3217,6 +3218,7 @@ function makeRuntimeCap(overrides) {
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-rt', env: ['TEST_RT_DIR'] },
localConfigDir: '.test-rt',
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',
@@ -4292,6 +4294,7 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT
requires: [],
runtime: {
configHome: { kind: 'dot-home', name: '.test-runtime', env: [] },
localConfigDir: '.test-runtime',
configFormat: 'settings-json',
artifactLayout: { global: [], local: [] },
commandStyle: 'slash-hyphen',

View File

@@ -0,0 +1,93 @@
'use strict';
/**
* Drift-guard: getDirName must be derived from the capability registry.
* Verifies:
* 1. For every known runtime id, getDirName(id) equals the hardcoded golden
* expected map — a pinned oracle that catches BOTH formula bugs AND
* unintended registry drift (adding/removing a runtime or changing its
* localConfigDir forces a deliberate golden-map update here).
* 2. getDirName('unknown') and getDirName('') fall back to '.claude'.
* 3. Every registry runtime entry has a non-empty dot-dir localConfigDir string —
* cross-check from a different angle than the production derivation formula.
*
* ADR-1239 Phase B (#1679).
* Behavioral tests only: assert on returned values, no source-grep.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { getDirName } = runtimeNamePolicy;
// Golden oracle: hardcoded expected map of all 16 runtime ids to their local config dir.
// A pinned expected value in a TEST is correct — the test IS the oracle (non-circular).
// Only PRODUCTION code should derive dynamically from the registry.
// If this map diverges from getDirName output, either the formula is wrong
// OR the registry changed — both require a deliberate golden-map update here.
const GOLDEN_DIR_MAP = {
claude: '.claude',
copilot: '.github',
opencode: '.opencode',
gemini: '.gemini',
kilo: '.kilo',
codex: '.codex',
antigravity: '.agents',
cursor: '.cursor',
windsurf: '.windsurf',
augment: '.augment',
trae: '.trae',
qwen: '.qwen',
hermes: '.hermes',
kimi: '.kimi-code',
codebuddy: '.codebuddy',
cline: '.cline',
};
test('getDirName: golden map matches for all 16 known runtime ids', () => {
for (const [id, expected] of Object.entries(GOLDEN_DIR_MAP)) {
const actual = getDirName(id);
assert.strictEqual(
actual,
expected,
`getDirName('${id}') diverged from golden.\n` +
` actual: ${JSON.stringify(actual)}\n` +
` expected: ${JSON.stringify(expected)}`,
);
}
});
test('drift guard: registry runtime id set EXACTLY equals the golden map (adding/removing a runtime forces a golden update)', () => {
// Without this, a newly-added runtime would pass (its value never checked) and
// removing `claude` could pass via the .claude fallback. Pin the set both ways.
const registryIds = Object.keys(registry.runtimes).sort();
const goldenIds = Object.keys(GOLDEN_DIR_MAP).sort();
assert.deepEqual(registryIds, goldenIds,
'registry.runtimes id set must exactly match GOLDEN_DIR_MAP — update the golden map when adding/removing a runtime');
});
test('getDirName fallback: unknown runtime returns ".claude"', () => {
assert.strictEqual(getDirName('unknown'), '.claude',
'getDirName("unknown") must return ".claude" (default fallback)');
});
test('getDirName fallback: empty string returns ".claude"', () => {
assert.strictEqual(getDirName(''), '.claude',
'getDirName("") must return ".claude" (empty-input fallback)');
});
test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string', () => {
for (const [id, entry] of Object.entries(registry.runtimes)) {
if (!entry || typeof entry !== 'object') continue;
const runtimeBlock = entry.runtime;
if (!runtimeBlock || typeof runtimeBlock !== 'object') continue;
const dir = runtimeBlock.localConfigDir;
assert.strictEqual(typeof dir, 'string',
`registry.runtimes['${id}'].runtime.localConfigDir must be a string (got: ${typeof dir})`);
assert.ok(dir.length > 0,
`registry.runtimes['${id}'].runtime.localConfigDir must be non-empty`);
assert.ok(dir.startsWith('.'),
`registry.runtimes['${id}'].runtime.localConfigDir must start with '.' (got: ${JSON.stringify(dir)})`);
}
});

View File

@@ -51,6 +51,7 @@ function makeMinimalRuntimeCap(overrides = {}) {
sandboxTier: 'none',
supportTier: 1,
installSurface: 'profile-marker-only',
localConfigDir: '.test-runtime',
writesSharedSettings: false,
permissionWriter: null,
extendedHookEvents: [],
@@ -331,6 +332,7 @@ describe('Fix 3: reserved-key guard on hostIntegration and hostIntegration.dispa
sandboxTier: 'none',
supportTier: 1,
installSurface: 'settings-json',
localConfigDir: '.test-runtime',
writesSharedSettings: true,
permissionWriter: null,
extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'],