Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
13 KiB
ADR-1235: Migrate agent conversion to the descriptor-driven install path
- Status: Accepted
- Date: 2026-06-14
- Issue: #1235
- Builds on: ADR-3660 (runtime artifact layout), ADR-457 (the
src/*.ctsbuild-at-publish tree the converters live in), ADR-1016 (runtime capability descriptor) - Relates to: #1173 (implementation), #1175 (dead-converter cleanup, done), #1227 (partial
convertedAgentsKindplumbing groundwork)
Context
Agent installation is the last major artifact class in install() that does not flow through the descriptor-driven path established by ADR-3660 (installRuntimeArtifacts → _copyStaged, with each runtime's artifacts declared in the capability registry's artifactLayout). Skills and kimi-agents already flow through it. The claude-local agents kind is declared in the descriptor (raw-copy, converter: null), but production install() still produces claude-local agent output via the inline loop — the skills-runtime gate routes Claude through installRuntimeArtifacts only when isGlobal (bin/install.js ~9822/9829), and claude-local commands/agents go through the back-compat copy path. So agents for every runtime (claude included) are, in practice, converted and written by a separate inline loop in bin/install.js (currently lines ~10102–10249).
This was surfaced while attempting #1173: #1227 added the convertedAgentsKind plumbing to the layout module, but it is inert in production — no runtime descriptor declares an agents kind with a non-null converter, so dispatchKindEntry always routes to the raw-copy agentsKind. The plumbing is exercised only by tests/runtime-converters.test.cjs (folds former feat-1173-agent-converters-descriptor, consolidation epic #1969) against a synthetic registry.
A naive cutover (point descriptors at the extracted converters, delete the inline loop) regresses installs for every runtime, because the inline loop performs cross-cutting and runtime-specific pipeline steps that the descriptor agents path does not replicate, and because the extracted converters' signature cannot receive the context those steps require. Two of those behaviors have no converter function at all today. Because the migration touches install correctness across ~15 runtimes in the most-edited install module, the cutover strategy and the parity-test contract are architectural decisions worth recording before code.
The two (really three) mechanisms today
-
Descriptor path —
installRuntimeArtifacts(bin/install.js) →resolveRuntimeArtifactLayout(src/runtime-artifact-layout.cts) → per-kindstage()→applyRuntimeContentRewritesInPlace→_copyStaged. Live in production for skills and kimi-agents. A claude-localagentskind (converter: null, raw copy) is declared in the claude descriptor, but production does not route claude-local agents throughinstallRuntimeArtifacts(only claude-global skills hit the layout path) — so the declared entry does not govern claude-local agent output today. Also noteinstallRuntimeArtifactsappliesapplyRuntimeContentRewritesInPlaceto skills/kimi-agents but not to theagentskind.convertedAgentsKind(src/runtime-artifact-layout.cts:214–229) exists and is dispatched (:439–443) but is unused by any real descriptor. -
Inline agent loop —
bin/install.js~10102–10249. Runs for all runtimes except kimi (short-circuited; handled by thekimi-agentsdescriptor kind) and minimal mode. For skills-based runtimes it runs afterinstallRuntimeArtifactsand is the only agent-install path for them. This is where the real per-runtime conversion happens. -
Codex TOML sidecar — a third, separate path (
bin/install.js~5730–5750,generateCodexAgentToml) writes Codex agents'.tomlsidecar (reading model overrides + effort), independent of both mechanisms above. Any "agents through the descriptor" story must account for it.
Decision
Migrate agent conversion onto the descriptor path incrementally, one runtime at a time, each cutover gated on byte-for-byte golden-output parity — never as a single big-bang replacement (Gall's Law: evolve the working dual-path system, don't replace it wholesale). Concretely:
-
Split the agent pipeline into cross-cutting steps and a runtime-specific converter. The descriptor agents path applies the cross-cutting steps uniformly to every runtime (mirroring how skills already get
applyRuntimeContentRewritesInPlace— which theagentskind does not get today), and delegates only the runtime-specific transform to the converter. Cross-cutting = path-prefix rewrite,processAttribution,normalizeAgentBodyForRuntime. Runtime-specific = the per-runtime frontmatter/format converter plus its scope/model/effort needs. Preserve the inline loop's transform order, because byte-parity depends on it: stale-cleanup → path-prefix rewrite →processAttribution→ runtime converter/branding → claudeeffort/disallowedToolsinjection → body normalization → filename rename. Implementers must not silently inherit the skills ordering or the current single-argconvertedAgentsKindshape. -
Introduce a converter-context contract so converters can replicate the runtime-specific behaviors the single-arg
(content) => stringsignature cannot express (this signature is a leaky abstraction — it omits the context conversion actually needs). -
Build a golden-parity harness first, capture the inline loop's current output per runtime as golden fixtures, then cut over each runtime only when the descriptor path reproduces its golden output byte-for-byte (Hyrum's Law: ~15 runtimes' installs depend on the exact current output, documented or not — preserve it).
-
Delete each runtime's inline branch only after its parity gate is green, and delete the inline loop entirely only when the last runtime (and the Codex TOML sidecar) has cut over.
The parity behaviors the descriptor agents path must gain
The inline loop performs ten behaviors beyond raw copy (the issue listed seven; verification against bin/install.js found three more — Qwen/Hermes branding swaps, the Codex TOML sidecar, and stale-agent cleanup). The descriptor path must reproduce each, for the runtimes shown:
| # | Behavior | bin/install.js (approx.) |
Applies to | Kind |
|---|---|---|---|---|
| a | Path-prefix rewrite (~/.claude/ → pathPrefix) |
10154–10165 | all except copilot, antigravity (own rewrite) | cross-cutting |
| b | processAttribution(content, getCommitAttribution(runtime)) |
10166 | all | cross-cutting |
| c | normalizeAgentBodyForRuntime (colon→hyphen /msd: refs) |
10240 | claude, qwen, hermes | cross-cutting (gated set) |
| d | Claude effort: + disallowedTools: frontmatter injection |
10225–10232 | claude only | runtime-specific (needs agentName + targetDir) |
| e | Copilot .agent.md filename rename |
10241 | copilot only | runtime-specific (file-level, not content) |
| f | isGlobal scope arg to the converter |
10194–10196 | copilot, antigravity | runtime-specific (needs scope) |
| g | OpenCode per-agent model-override read + inject | 10168–10186 | opencode only | runtime-specific (needs agentName + targetDir) |
| h | Qwen/Hermes branding swaps (Claude Code/.claude//CLAUDE.md) |
10210–10217 | qwen, hermes | runtime-specific — no converter function exists today |
| i | Codex .toml sidecar (reads model override + effort; emits model_reasoning_effort only when a model is pinned) |
~5730–5750, ~3167 | codex only | separate third path |
| j | Stale-agent cleanup: remove pre-existing agents/msd-* before (re)install; minimal-mode Codex strips stale [agents.msd-*] TOML (the full→minimal shrink) |
~10111, ~10126 | all (file cleanup); codex (toml) | cross-cutting pre-step |
Converter-context contract
Today the extracted converters (src/runtime-artifact-conversion.cts) are (content: string) => string (a few accept an optional isGlobal), and convertedAgentsKind wraps them as (content) => string, discarding all other context. The contract enrichment passes a context object alongside content:
AgentConverterContext = {
agentName: string; // 'msd-planner' — for effort, disallowedTools, model overrides
runtime: string; // for attribution + normalization predicate
isGlobal: boolean; // copilot/antigravity scope selection
targetDir: string; // to read effort/model-override/attribution user config
cmdNames: string[]; // for body hyphen-normalization
modelOverride?: string; // resolved opencode/codex per-agent override
effort?: string; // resolved claude per-agent effort
disallowedTools?: string; // resolved claude read-only-agent tools
}
Cross-cutting steps (a, b, c) are applied by the descriptor pipeline for the appropriate runtime sets, so converters need context only for the runtime-specific steps (d–i). Resolving modelOverride/effort/disallowedTools at the descriptor boundary (where targetDir is known) keeps converters pure on (content, ctx).
Incremental cutover plan (each step gated on golden parity)
- Parity harness — capture, per runtime, the inline loop's installed agent files as golden fixtures for both full and minimal mode (so the full→minimal shrink and stale-agent cleanup, behavior j, are covered), and add a test asserting the descriptor path's staged+converted output is byte-identical — including that re-installing over a stale
agents/msd-*set converges to the same result. This is the gate every subsequent step must pass. - Trivial converters (cursor, windsurf, augment, trae, codebuddy, cline) — single-arg converters; need only cross-cutting steps + the existing converter. Lowest risk; proves the split.
- Scope-aware (copilot, antigravity) — thread
isGlobal; copilot also needs the.agent.mdrename (a descriptor file-name transform). - Config-reading (opencode model override; claude effort/disallowedTools) — resolve via context at the descriptor boundary.
- No-converter runtimes (qwen, hermes) — add real
convertClaudeAgentTo{Qwen,Hermes}Agentconverters capturing today's branding swaps, then cut over. - Codex — fold the
.tomlsidecar into the descriptor (or declare it an explicit companion artifact); the.md+.tomlmust both reach parity. - Delete the inline loop once every runtime is green; remove the now-dead
isKimi/minimal special-casing that referenced it.
Cutover progress (#1575)
- Step 0 (parity harness): shipped in
tests/golden-parity-single-source.test.cjs(folded asfolded:issue-1575-agent-descriptor-parityin the #3336 test-hygiene sweep). AssertsapplySurfaceoutput is byte-identical toinstallRuntimeArtifactsfor all descriptor-driven runtimes. Covers stale-cleanup convergence (pre-existing legacy.agent.mdpruned correctly). - Step 1 (trivial converters): cursor, windsurf, augment, trae, codebuddy — install-path cutover complete (PR #1438); surface-path parity shipped (#1575:
applySurfacenow buildsagentCtxand passes it tokind.stage()for agents, applying path-rewrite + attribution + converter + normalize). - Step 2 (scope-aware): copilot and antigravity — cutover complete (#1575: declared
agentskind incapability.json, added to_DESCRIPTOR_AGENTS_RUNTIMES, copilot.agent.mdrename handled in both_copyStagedand_syncMsdDir). - Cline: deferred — rules-only local branch + local/global complication not handled by the descriptor-driven path.
- Remaining: steps 3–6 (config-reading, no-converter, codex, inline-loop deletion).
Risks / trade-offs
- Silent install regression across ~15 runtimes is the dominant risk; the byte-for-byte golden gate is the mitigation, and per-runtime sequencing bounds the blast radius of any single step.
- Context contract scope creep — passing
targetDirinto staging couples conversion to user config reads. Mitigated by resolving the config-derived values (effort/model/attribution) before the converter and passing only resolved scalars. - Golden fixtures drift — fixtures must be regenerated deliberately when agent source legitimately changes; treat fixture updates as reviewed changes, not auto-accepted.
- Codex's third path may not fit the single-artifact descriptor model cleanly; it may warrant a companion-artifact concept rather than being forced into the agents kind.
Out of scope
- Implementing the migration (that is #1173 and its follow-ups; this ADR is the design gate).
- Changing agent authoring (
agents/*.mdsource) or thesrc/*.ctsbuild model (ADR-457). - Kimi agents (already descriptor-driven via
kimi-agents).
Success criteria
- A documented contract (this ADR) approved before cutover code lands.
- A golden-parity harness exists and each runtime's cutover PR is gated on it.
- After the final step,
bin/install.jshas a single agent-install path (descriptor), the inline loop is deleted, andconvertedAgentsKindis live for every converting runtime.
Dependencies
- ADR-3660 (the descriptor/layout module being extended), ADR-457 (the
src/*.ctstree the converters live in), ADR-1016 (capability descriptor that carriesartifactLayout). - #1173 (implementation tracker), #1227 (groundwork:
convertedAgentsKind), #1175 (dead-converter cleanup, completed separately).