Maintainer governance decision (waiving a standalone ADR for Qoder, PR #1021): registering a runtime that reuses the existing profile-marker-only install surface + a single skills kind is an enhancement governed by ADR-3660 via addendum. Records the addendum-vs-new-ADR qualifying criteria, the normative agent-frontmatter contract (name+description only — the sibling converter shape), and Qoder as the first runtime logged under this path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
Runtime Artifact Layout Module owns per-runtime artifact placement
- Status: Accepted
- Date: 2026-05-17
- Issue: #3660
- Implementation: #3663 (Phase 1), feat/3663-runtime-artifact-layout-module-phase-1-m
The Runtime Surface Module (gsd-core/bin/lib/surface.cjs, introduced by ADR-0011 Phase 2) re-materializes a resolved Skill Surface profile to disk via applySurface. It currently hardcodes two artifact kinds (commands, agents) and re-derives their source directories via _findInstallSource / _findAgentsSource walk-up heuristics. The install and uninstall pipelines in bin/install.js each encode the same per-runtime artifact layout independently across ~14 install sites and ~6 uninstall sites. Bug #3659 surfaced the resulting drift: applySurface omits the skills kind for runtimes whose canonical layout is skills/gsd-<stem>/SKILL.md, so gsd-surface profile <name> leaves ~67 skill directories on disk under the install-time profile's footprint when the resolved profile should have pruned them — roughly 2.7k tokens per session on a measured workstation.
The root problem is the absence of a typed seam for "where does runtime R put artifact kind K." Three lifecycle sites (install, uninstall, surface) each independently encode this knowledge and drift independently.
Decision
- Add a Runtime Artifact Layout Module at
gsd-core/bin/lib/runtime-artifact-layout.cjsas the single owner of the per-runtime artifact-placement table. - The module requires
runtime-homes.cjsfor the canonical runtime enum and global config-dir resolution. It adds the artifact-kind axis on top. - Expose
resolveRuntimeArtifactLayout(runtime, configDir) → Layout. The returnedLayoutis a plain typed object —{ runtime, configDir, kinds: ArtifactKind[] }— with no I/O on resolution. - Each
ArtifactKindis{ kind: 'commands'|'agents'|'skills', destSubpath, prefix, stage }.stageis a function(resolvedProfile) → stagedDirthat closes over the per-runtime converter where one is needed (e.g.convertClaudeCommandToClaudeSkillfor theskillskind on Claude global). - The
kindsarray is empty for runtimes with no GSD surface (a hypothetical future runtime with no integration). Theskillskind is absent for runtimes that don't materialize skill directories (Cline; Gemini today). Thecommandskind is absent for runtimes that consume only the skills/agents layout (Claude global, Codex, etc.). - Per-runtime quirks live in the layout's record fields, not in caller branches:
- Hermes:
{ kind: 'skills', destSubpath: 'skills/gsd', prefix: 'gsd-' }— preserves the nested namespace from #2841. Note (#947): The original decision usedprefix: ''(bare stem) on the incorrect premise that theskills/gsd/category directory namespaced the leaf identifier in Hermes's loader. Research showed category dirs are purely organisational; dispatch is by the skillname:field. Thegsd-prefix was restored by #947 to match every other runtime. - Cline:
kinds: []— Cline resolves to zero kinds in Phase 1 (nocommandskind). - Gemini:
kinds: [ { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' } ]— no agents, no skills.
- Hermes:
applySurfacemigrates from(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap)to(runtimeConfigDir, layout, manifest, clusterMap). Body collapses tofor (const kind of layout.kinds) _syncGsdDir(kind.stage(resolved), path.join(layout.configDir, kind.destSubpath), kind.kind)._findInstallSourceand_findAgentsSourceinsurface.cjsare removed. The layout owns source resolution.- Phase 2 (separate PR): install and uninstall paths in
bin/install.jsmigrate to iteratelayout.kinds. Per-runtime if/else branches for skill-directory creation/removal collapse to one layout-driven loop per pipeline. - Legacy-layout migrations (
bin/install.js:6710/:8402for the pre-nestedskills/gsd-*/flat layout;migrateLegacyDevPreferencesToSkillfor #2973) remain inside the Installer Migration Module (ADR-0008) and run before layout-driven copy. The layout module describes only the current canonical target — no historical kinds.
Initial Scope
Phase 1 should land the module and one consumer (the bug-#3659 fix):
- New
gsd-core/bin/lib/runtime-artifact-layout.cjs—resolveRuntimeArtifactLayout, the typedLayout/ArtifactKindshapes, and the runtime table covering every runtime currently enumerated inruntime-homes.cjs. surface.cjs:applySurfacemigrates to layout-driven iteration._findInstallSourceand_findAgentsSourcedeleted. Theskillskind is now iterated alongsidecommandsandagents— bug #3659 closed.commands/gsd/surface.mdandtests/surface-apply.test.cjsupdated to construct + passLayoutvalues.- New
tests/runtime-artifact-layout-*.test.cjscovering:- Per-runtime fixture table: each runtime maps to the expected
kinds[]shape. - Hermes
skills/gsdnested case. - Cline / Gemini "kind absent" cases.
- Source-root resolution (replacing the existing
surface.cjswalk-up tests).
- Per-runtime fixture table: each runtime maps to the expected
- Address the adjacent
readSurfacepartial-field silent-null bug noted in #3659 — out of scope here; track as a separateconfirmed-bugticket.
Phase 1 should not:
- Migrate the install/uninstall pipelines in
bin/install.jsin the same PR. That's a separate enhancement issue — same seam, larger blast radius. The layout module is dual-consumable from the start; convertingbin/install.jsis a sequenced follow-up. - Move the per-runtime skill converters (
convertClaudeCommandToClaudeSkill, etc.). They survive at their current file location as the stage adapters; the layout module references them. A future ADR may consolidate them into a Skill Conversion Module if a second consumer emerges.
Migration Inventory
New file
gsd-core/bin/lib/runtime-artifact-layout.cjs— module body + runtime layout table.
Files modified (Phase 1)
gsd-core/bin/lib/surface.cjs—applySurfacesignature change;_findInstallSource+_findAgentsSourceremoval.commands/gsd/surface.md— runbook updates the 3 sites that callapplySurfaceto first callresolveRuntimeArtifactLayout.tests/surface-apply.test.cjs— 5 call sites passlayoutinstead ofcommandsDir, agentsDir.
Files modified (Phase 2 — separate PR / separate issue)
bin/install.js— install path: 4 per-runtime skill-stage blocks collapse to one layout-driven loop.bin/install.js— uninstall path: 6 per-runtime skill-removal blocks collapse to one layout-driven loop.- Estimated reduction: ~250 lines.
Files not touched
runtime-homes.cjs— its narrow contract (runtime → global config dir / skills base) stays. The layout module is its sibling.install-profiles.cjs—stageSkillsForProfile,stageAgentsForProfileremain. The layout module'skinds[i].stageclosures call them.- The four skill converters at
bin/install.js:1622/1681/1792/2534— remain in place; layout closures reference them.
Interface sketch
// runtime-artifact-layout.cjs
/**
* @typedef {Object} ArtifactKind
* @property {'commands'|'agents'|'skills'} kind
* @property {string} destSubpath joined to layout.configDir
* @property {string} prefix 'gsd-' for all runtimes (incl. Hermes after #947)
* @property {(resolved) => string} stage returns staged dir path
*/
/**
* @typedef {Object} Layout
* @property {string} runtime canonical enum from runtime-homes.cjs
* @property {string} configDir caller-supplied (local or global scope)
* @property {ArtifactKind[]} kinds empty array = runtime has no gsd-* surface
*/
function resolveRuntimeArtifactLayout(runtime, configDir) { … }
Call-site shape:
// commands/gsd/surface.md (runbook), tests/surface-apply.test.cjs, future install/uninstall
const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir);
applySurface(runtimeConfigDir, layout, manifest, CLUSTERS);
applySurface body after migration:
function applySurface(runtimeConfigDir, layout, manifest, clusterMap) {
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
for (const kind of layout.kinds) {
const staged = kind.stage(resolved);
const dest = path.join(layout.configDir, kind.destSubpath);
if (fs.existsSync(dest)) _syncGsdDir(staged, dest, kind.kind);
}
}
Consequences
- Bug #3659 becomes a fixture-table omission, not a forgotten if/else block. The layout-table test asserts every runtime's
kinds[]shape — forgetting theskillskind on Claude global would fail there. - The cross-runtime test matrix collapses from
N runtimes × 3 lifecycle verbs × 3 kinds(today: ~120 implicit assertion pairs) toN (layout table) + 3 (one per lifecycle verb iterating layout.kinds). - Adding a new runtime (recent example: Grok via commit
05316369) becomes one row in the layout table plus one fixture row in the test. The three lifecycle verbs pick it up automatically. - The four per-runtime skill converters become canonical adapters at the artifact-kind seam. Their existence is no longer accidental — they're the seam's content.
surface.cjsshrinks (_findInstallSource+_findAgentsSourceremoved). The walk-up heuristics — which were only ever-incidentally correct — are replaced by an explicit table.- Phase 2 (install/uninstall migration) shrinks
bin/install.jsby ~250 lines and removes a recurring class of bug: a new runtime added by a contributor who forgets to wire it through every install/uninstall branch. - Future architecture reviews should treat per-runtime artifact-placement knowledge added outside
runtime-artifact-layout.cjsas drift, parallel to how ADR-0011 made out-of-seam skill staging drift. - The Skill Surface Budget Module's leverage (typed sets of
{ skills, agents }) now extends all the way to disk through one seam rather than three independent re-materializations.
Open questions
- Whether the
skillskind'sstageclosure should accept the per-runtime converter as a parameter (preserving converter-as-pure-function purity) or import it directly. Implementation detail — settle in the PR. - Whether the layout module should expose a
listKinds(runtime)introspection helper for status/diagnostics surfaces, or keepLayoutas the only public type. Lean toward a single public type; add helpers only when a second consumer needs them. - Whether Phase 2 (install/uninstall migration in
bin/install.js) should land as a single follow-up PR or be split per pipeline. Lean toward single PR — the install and uninstall sides share the runtime branch structure and migrating only one introduces a temporary asymmetry inverse to today's. - The adjacent
readSurfacepartial-field silent-null fallback insurface.cjs:55-75(also flagged in #3659) is out of scope here — track separately. It's an independent shallow interface in the same module, not a runtime-layout concern.
References
- Confirmed bug:
#3659—applySurfacedoesn't prune~/.claude/skills/gsd-*/dirs - See
0011-skill-surface-budget-module.md— the Runtime Surface Module this seam serves - See
0008-installer-migration-module.md— legacy-layout migrations stay there - See
0005-sdk-architecture-seam-map.md— the seam map this module joins - Existing canonical sibling:
gsd-core/bin/lib/runtime-homes.cjs - Per-runtime skill converters this module references:
bin/install.js:1622(Copilot),:1681(Claude),:1792(Antigravity),:2534(Codex) - Hermes nested-skills layout rationale:
#2841
Implementation status
Phase 1 implementation landed on feat/3663-runtime-artifact-layout-module-phase-1-m:
gsd-core/bin/lib/runtime-artifact-layout.cjs— 15-runtime layout table (grok intentionally excluded),resolveRuntimeArtifactLayout(runtime, configDir, scope) → Layout, walk-upfindInstallSourceRoothelper.- Clarification: in this Phase 1 implementation, Cline resolves to zero kinds (
kinds: []), so it carries nocommandskind in the layout table. gsd-core/bin/lib/install-profiles.cjs— newstageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) → stagedDirhelper.gsd-core/bin/lib/surface.cjs—applySurface(runtimeConfigDir, layout, manifest, clusterMap)signature migration;_findInstallSource+_findAgentsSourcedeleted;_syncGsdDirextended to handle theskillskind via directory iteration.- Tests:
runtime-artifact-layout-resolve.test.cjs(16),runtime-artifact-layout-edge-cases.test.cjs(10),runtime-artifact-layout-stage.test.cjs(5),install-profiles-stage.test.cjs(+7 new),surface-apply.test.cjs(updated 5 call sites + new skills-kind test).
Phase 2 (separate issue #3664 — bin/install.js install/uninstall pipeline migration) is blocked on Phase 1 merge.
Amendment (2026-06-11): adding a runtime that rides the established layout is an addendum, not a new ADR
Maintainer governance decision. Registering an additional runtime that reuses the existing install machinery — the profile-marker-only install surface (ADR-58 / the config-adapter registry) plus a single skills kind in this module's layout table — is an enhancement governed by this ADR via this addendum, not a change that requires its own ADR. New design rationale (and a fuller amendment) is required only when a runtime introduces something this ADR has not already decided: a new install surface, a new ArtifactKind, or a layout quirk not expressible in the existing record fields (cf. the Hermes nested-namespace and Cline zero-kinds cases in the Decision section).
This codifies the lightweight path the project has used since Phase 1 for Codex, Copilot, Trae, Windsurf, Qwen, CodeBuddy, Cline, Kimi, et al., and makes the addendum-vs-new-ADR test explicit so contributors and reviewers stop re-litigating it per runtime.
Qualifying criteria (all three) for the addendum path
- No new install surface — the runtime maps to an existing
installSurfacevalue in the config-adapter registry (profile-marker-only, etc.);writesSharedSettings: false;finishPermissionWriter: nullor an existing writer. - No new artifact kind — the layout entry is composed only of the existing
commands/agents/skillskinds via the existingskillsKind(...)/ record-field machinery; no newArtifactKindshape. - Reuses the converter contract — per-runtime converters follow the established shape (see the agent-frontmatter contract below); only the path/name substitutions differ.
A runtime failing any of the three needs a fuller amendment here (or a new ADR) documenting the new surface/kind and its rationale.
Agent-frontmatter contract (normative)
Per-runtime agent converters emit a sanitized minimal frontmatter — name + description only — rebuilt through yamlIdentifier(name) / yamlQuote(toSingleLine(description)), matching convertClaudeAgentToTraeAgent / convertClaudeAgentToClineAgent / convertClaudeAgentToCodebuddyAgent. Claude-specific fields (tools:, color:, commented hook blocks) MUST NOT be passed through verbatim. A runtime that genuinely requires richer agent frontmatter must document the target schema in an amendment here and emit it deliberately — not arrive at pass-through by accident. (Recorded because PR #1021's first cut of convertClaudeAgentToQoderAgent fell through to full pass-through; it must be brought onto the sibling contract.)
Qoder (issue #860 / PR #1021) — first runtime recorded under this addendum
Qoder qualifies on all three criteria and is added as a layout-table entry, not a new design:
- Install surface:
profile-marker-only(config-adapter registry), identical shape to Trae/Windsurf — no statusline, nopackage.json, no sharedsettings.json/permission writes. - Layout:
case 'qoder'→[ skillsKind('skills', 'gsd-', 'convertClaudeCommandToQoderSkill', 'qoder', configDir) ];'qoder'added toALLOWED_RUNTIMES. - Home:
~/.qoder(orQODER_CONFIG_DIR), viaruntime-homes. - Converters:
convertClaudeToQoderMarkdown,convertClaudeCommandToQoderSkill,convertClaudeAgentToQoderAgent— the agent converter is subject to the contract above.
Path note: since ADR-457's .cts migration, the canonical module source is src/runtime-artifact-layout.cts (built to gsd-core/bin/lib/runtime-artifact-layout.cjs); the runtime enumeration is owned by src/runtime-homes.cts and src/runtime-config-adapter-registry.cts. The original body above predates that move; paths read accordingly.