refactor(#1763): ADR-1235 agent migration — cut over the trivial-converter group to the descriptor path (#1764)

ADR-1235 step 1: route the trivial-converter runtime group (cursor, windsurf, augment, trae, codebuddy) off the inline install() agent loop onto the descriptor-driven installRuntimeArtifacts path. Establishes the converter-context foundation (pre-converter cross-cutting + no agent-stamp). Agent install output is byte-identical for all 16 runtimes (golden-parity, global + verified local). cline deliberately excluded (local rules-only). Closes #1763.
This commit is contained in:
Tom Boucher
2026-06-26 15:38:51 -04:00
committed by GitHub
parent dcd1d7f973
commit ce62f2b68d
17 changed files with 591 additions and 64 deletions

View File

@@ -11,6 +11,19 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import conversionModule = require('./runtime-artifact-conversion.cjs');
const {
applyAgentPathRewrites: _applyAgentPathRewrites,
processAttribution: _processAttribution,
normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime,
readGsdCommandNames: _readGsdCommandNames,
} = conversionModule as {
applyAgentPathRewrites: (content: string, runtime: string, pathPrefix: string) => string;
processAttribution: (content: string, attribution: string | null | undefined) => string;
normalizeAgentBodyForRuntime: (content: string, runtime: string, cmdNames: string[]) => string;
readGsdCommandNames: () => string[];
};
// ---------------------------------------------------------------------------
// Profile definitions
@@ -530,6 +543,18 @@ function stageSkillsForRuntimeAsSkills(
return stageDir;
}
/**
* Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1).
* When present, stageAgentsForRuntimeWithConverter applies the full inline-loop
* sequence per agent: pathRewrites → attribution → converter → normalize.
* The field names mirror the inline loop's available identifiers.
*/
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
}
/**
* Stage a converted copy of the agents directory for a given runtime.
*
@@ -546,24 +571,39 @@ function stageSkillsForRuntimeAsSkills(
* For tiered profiles, only agents whose full stem is in `resolvedProfile.agents`
* are staged (mirrors `stageAgentsForProfile` behaviour).
*
* ADR-1235 §1: when `agentCtx` is provided, the per-file order matches the inline
* agent loop in bin/install.js exactly:
* 1. applyAgentPathRewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
* 2. processAttribution (Co-Authored-By policy)
* 3. converter (runtime-specific frontmatter/body transform)
* 4. normalizeAgentBodyForRuntime (colon→hyphen refs; no-op for trivial group)
* When `agentCtx` is absent, only the converter is applied (backward-compat for
* the feat-1173 synthetic-descriptor tests and the copilot/antigravity paths
* that handle cross-cutting inside their converters).
*
* @param srcAgentsDir source agents directory (e.g. agents/)
* @param resolvedProfile profile filter from resolveProfile()
* @param converter (content: string, isGlobal?: boolean) → string per-file
* converter; scope-aware converters (copilot/antigravity)
* read isGlobal, single-arg converters ignore it (#1173)
* @param isGlobal install scope passed through to the converter
* @param agentCtx optional cross-cutting context (ADR-1235 §1); when absent,
* only the converter is applied (backward compat)
*/
function stageAgentsForRuntimeWithConverter(
srcAgentsDir: string,
resolvedProfile: ResolvedProfile,
converter: (content: string, isGlobal?: boolean) => string,
isGlobal = false,
agentCtx?: AgentCtx,
): string {
if (!fs.existsSync(srcAgentsDir)) return srcAgentsDir;
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-agents-'));
try {
const entries = fs.readdirSync(srcAgentsDir, { withFileTypes: true });
// Resolve cmdNames once per staging call (not per file) for performance.
const cmdNames = agentCtx ? _readGsdCommandNames() : [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
@@ -574,9 +614,22 @@ function stageAgentsForRuntimeWithConverter(
continue;
}
}
const content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8');
const converted = converter(content, isGlobal);
fs.writeFileSync(path.join(stageDir, entry.name), converted, 'utf8');
let content = fs.readFileSync(path.join(srcAgentsDir, entry.name), 'utf8');
if (agentCtx) {
// ADR-1235 §1: pre-converter cross-cutting (matches inline loop order exactly)
// Step 1: path rewrites (4 base ~/.claude/ regexes; skipped for copilot/antigravity)
content = _applyAgentPathRewrites(content, agentCtx.runtime, agentCtx.pathPrefix);
// Step 2: attribution
content = _processAttribution(content, agentCtx.attribution);
// Step 3: converter (runtime-specific frontmatter/body transform)
content = converter(content, isGlobal);
// Step 4: normalize colon→hyphen refs (no-op for trivial group)
content = _normalizeAgentBodyForRuntime(content, agentCtx.runtime, cmdNames);
} else {
// Backward-compat: only apply the converter (no cross-cutting)
content = converter(content, isGlobal);
}
fs.writeFileSync(path.join(stageDir, entry.name), content, 'utf8');
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }

View File

@@ -2551,6 +2551,60 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
}
/**
* Runtimes that use the hyphen-namespace form `/gsd-<cmd>` in agent bodies.
* claude/qwen/hermes use hyphen-name:`...` frontmatter; cursor/windsurf/etc
* self-convert. Mirrors the `HYPHEN_NAME_AGENT_RUNTIMES` set in bin/install.js.
*
* @private — export normalizeAgentBodyForRuntime for callers.
*/
const HYPHEN_NAME_AGENT_RUNTIMES: ReadonlySet<string> = new Set(['claude', 'qwen', 'hermes']);
/**
* Normalize `/gsd:<cmd>` colon refs in the agent body to `/gsd-<cmd>` for
* hyphen-`name:` runtimes (claude / qwen / hermes). No-op for all other
* runtimes. Mirrors the per-file call in bin/install.js line 9400.
*
* @param content raw agent file content (post-converter)
* @param runtime canonical runtime ID
* @param cmdNames gsd command names from readGsdCommandNames()
*/
function normalizeAgentBodyForRuntime(content: string, runtime: string, cmdNames: string[]): string {
if (!HYPHEN_NAME_AGENT_RUNTIMES.has(runtime)) return content;
return transformContentToHyphen(content, cmdNames);
}
/**
* Apply the 4 base `~/.claude/` path-prefix rewrites to a single agent content
* string. Mirrors the inline agent loop in bin/install.js lines 9330-9340:
* ~/\.claude/ → pathPrefix
* $HOME/\.claude/ → pathPrefix
* ~/\.claude\b → normalizedPathPrefix
* $HOME/\.claude\b → normalizedPathPrefix
*
* Skipped for copilot and antigravity (which do NOT do path rewrites in the
* inline loop). NO stamp (_stampNonClaudeRuntimeDefaults) — agents are NOT
* stamped in the inline loop.
*
* ADR-1235 §1: pre-converter cross-cutting for descriptor-driven agent pipeline.
* Exported as `applyAgentPathRewrites` for testing and for injection into
* stageAgentsForRuntimeWithConverter via agentCtx.
*
* @param content raw agent file content
* @param runtime canonical runtime ID
* @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/')
* @returns content with path-prefix rewrites applied (or unchanged for copilot/antigravity)
*/
function applyAgentPathRewrites(content: string, runtime: string, pathPrefix: string): string {
if (runtime === 'copilot' || runtime === 'antigravity') return content;
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
content = content.replace(/~\/\.claude\//g, pathPrefix);
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
return content;
}
// ── End rewrite engine ────────────────────────────────────────────────────────
/**
@@ -2644,6 +2698,9 @@ export = {
// High-level wrappers (derive pathPrefix + attribution from opts):
rewriteStagedSkillBodies,
rewriteStagedCommandBodies,
// ADR-1235 §1: descriptor-driven agent cross-cutting
applyAgentPathRewrites,
normalizeAgentBodyForRuntime,
_computePathPrefix: computePathPrefix,
_applyRuntimeRewrites,
_stampNonClaudeRuntimeDefaults,

View File

@@ -21,11 +21,17 @@ interface ResolvedProfile {
agents?: Set<string>;
}
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
}
interface ArtifactKind {
kind: ArtifactKindName;
destSubpath: string;
prefix?: string;
stage: (resolvedProfile: ResolvedProfile) => string;
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
}
interface Layout {
@@ -49,9 +55,18 @@ interface Dependencies {
rewriteStagedCommandBodies?: (stagedDir: string, opts: RewriteOpts) => string | void;
}
interface ComputePathPrefixOpts {
isGlobal: boolean;
isOpencode: boolean;
isWindowsHost: boolean;
resolvedTarget: string;
homeDir: string;
}
interface RuntimeArtifactConversionExports {
rewriteStagedSkillBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
rewriteStagedCommandBodies: (stagedDir: string, opts: RewriteOpts) => string | void;
_computePathPrefix: (opts: ComputePathPrefixOpts) => string;
}
interface PlanItem {
@@ -151,10 +166,34 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
resolveAttribution,
};
// ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply
// the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter
// → normalize). This mirrors the exact per-file order in the inline agent loop
// in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg
// to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
const os = _require('node:os') as typeof import('node:os');
const homedirFn: () => string = homedir ?? (() => os.homedir());
const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
const homeDir = homedirFn().replace(/\\/g, '/');
const isGlobal = scope === 'global';
const isOpencode = layout.runtime === 'opencode';
const isWindowsHost = (platform ?? process.platform) === 'win32';
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
const agentCtx: AgentCtx = { runtime: layout.runtime, pathPrefix, attribution };
for (const kind of layout.kinds) {
let stagedDir: string;
try {
stagedDir = kind.stage(resolvedProfile);
if (kind.kind === 'agents') {
// ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies
// the full inline-loop order: pathRewrites → attribution → converter → normalize.
// The cross-cutting is now PRE-converter (inside staging), not POST.
stagedDir = kind.stage(resolvedProfile, agentCtx);
} else {
stagedDir = kind.stage(resolvedProfile);
}
} catch (err) {
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
@@ -168,6 +207,8 @@ function createRuntimeArtifactInstallPlan(args: CreateRuntimeArtifactInstallPlan
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
// agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx.
// No POST-step needed. sourceDir stays as stagedDir.
} catch (err) {
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}

View File

@@ -54,11 +54,25 @@ interface ResolvedProfile {
agents: Set<string>;
}
/**
* Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1).
* Passed as the optional second arg to ArtifactKind.stage() for agents kind
* entries so that stageAgentsForRuntimeWithConverter can apply the exact
* inline-loop transform order: pathRewrites → attribution → converter → normalize.
*/
interface AgentCtx {
runtime: string;
pathPrefix: string;
attribution: string | null | undefined;
}
interface ArtifactKind {
kind: KimiArtifactKindName;
destSubpath: string;
prefix: string;
stage: (resolvedProfile: ResolvedProfile) => string;
/** For agents kind with a converter, accepts an optional AgentCtx as the second
* arg so cross-cutting can be applied pre-converter (ADR-1235 §1). */
stage: (resolvedProfile: ResolvedProfile, agentCtx?: AgentCtx) => string;
}
interface Layout {
@@ -207,17 +221,21 @@ function convertedAgentsKind(
kind: 'agents',
destSubpath,
prefix,
stage: (resolved) => {
stage: (resolved, agentCtx) => {
// isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
// choose global-home vs workspace-relative paths; converters that only take
// (content) ignore the extra positional arg. Mirrors skillsKind's scope
// threading (#1173).
const converter = conversionExports[converterName] as (content: string, isGlobal?: boolean) => string;
// ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan
// for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter
// can apply the full pre-converter + post-converter sequence in the correct order.
return stageAgentsForRuntimeWithConverter(
findAgentsSourceRoot(configDir),
resolved,
converter,
scope === 'global',
agentCtx,
);
},
};