Files
msd-core/docs/adr/1235-descriptor-driven-agent-conversion-migration.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
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.
2026-10-06 01:47:40 +02:00

13 KiB
Raw Permalink Blame History

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/*.cts build-at-publish tree the converters live in), ADR-1016 (runtime capability descriptor)
  • Relates to: #1173 (implementation), #1175 (dead-converter cleanup, done), #1227 (partial convertedAgentsKind plumbing 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

  1. Descriptor path — installRuntimeArtifacts (bin/install.js) → resolveRuntimeArtifactLayout (src/runtime-artifact-layout.cts) → per-kind stage() → applyRuntimeContentRewritesInPlace → _copyStaged. Live in production for skills and kimi-agents. A claude-local agents kind (converter: null, raw copy) is declared in the claude descriptor, but production does not route claude-local agents through installRuntimeArtifacts (only claude-global skills hit the layout path) — so the declared entry does not govern claude-local agent output today. Also note installRuntimeArtifacts applies applyRuntimeContentRewritesInPlace to skills/kimi-agents but not to the agents kind. convertedAgentsKind (src/runtime-artifact-layout.cts:214–229) exists and is dispatched (:439–443) but is unused by any real descriptor.

  2. Inline agent loop — bin/install.js ~10102–10249. Runs for all runtimes except kimi (short-circuited; handled by the kimi-agents descriptor kind) and minimal mode. For skills-based runtimes it runs after installRuntimeArtifacts and is the only agent-install path for them. This is where the real per-runtime conversion happens.

  3. Codex TOML sidecar — a third, separate path (bin/install.js ~5730–5750, generateCodexAgentToml) writes Codex agents' .toml sidecar (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:

  1. 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 the agents kind 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 → claude effort/disallowedTools injection → body normalization → filename rename. Implementers must not silently inherit the skills ordering or the current single-arg convertedAgentsKind shape.

  2. Introduce a converter-context contract so converters can replicate the runtime-specific behaviors the single-arg (content) => string signature cannot express (this signature is a leaky abstraction — it omits the context conversion actually needs).

  3. 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).

  4. 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)

  1. 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.
  2. Trivial converters (cursor, windsurf, augment, trae, codebuddy, cline) — single-arg converters; need only cross-cutting steps + the existing converter. Lowest risk; proves the split.
  3. Scope-aware (copilot, antigravity) — thread isGlobal; copilot also needs the .agent.md rename (a descriptor file-name transform).
  4. Config-reading (opencode model override; claude effort/disallowedTools) — resolve via context at the descriptor boundary.
  5. No-converter runtimes (qwen, hermes) — add real convertClaudeAgentTo{Qwen,Hermes}Agent converters capturing today's branding swaps, then cut over.
  6. Codex — fold the .toml sidecar into the descriptor (or declare it an explicit companion artifact); the .md + .toml must both reach parity.
  7. 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 as folded:issue-1575-agent-descriptor-parity in the #3336 test-hygiene sweep). Asserts applySurface output is byte-identical to installRuntimeArtifacts for all descriptor-driven runtimes. Covers stale-cleanup convergence (pre-existing legacy .agent.md pruned correctly).
  • Step 1 (trivial converters): cursor, windsurf, augment, trae, codebuddy — install-path cutover complete (PR #1438); surface-path parity shipped (#1575: applySurface now builds agentCtx and passes it to kind.stage() for agents, applying path-rewrite + attribution + converter + normalize).
  • Step 2 (scope-aware): copilot and antigravity — cutover complete (#1575: declared agents kind in capability.json, added to _DESCRIPTOR_AGENTS_RUNTIMES, copilot .agent.md rename handled in both _copyStaged and _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 targetDir into 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/*.md source) or the src/*.cts build 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.js has a single agent-install path (descriptor), the inline loop is deleted, and convertedAgentsKind is live for every converting runtime.

Dependencies

  • ADR-3660 (the descriptor/layout module being extended), ADR-457 (the src/*.cts tree the converters live in), ADR-1016 (capability descriptor that carries artifactLayout).
  • #1173 (implementation tracker), #1227 (groundwork: convertedAgentsKind), #1175 (dead-converter cleanup, completed separately).