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

@@ -0,0 +1,7 @@
---
type: Changed
pr: 1764
---
**Internal: agent install for cursor/windsurf/augment/trae/codebuddy now flows through the descriptor path** — ADR-1235 step 1 routes the trivial-converter runtime group's agents off the inline install() loop onto the descriptor-driven `installRuntimeArtifacts` path, applying the cross-cutting steps uniformly (pre-converter, no workflow-stamp). Agent output is byte-identical for all 16 runtimes (golden-parity asserted, global + local verified); no user-facing change.
<!-- docs-exempt: internal refactor, no user-facing surface -->

View File

@@ -9268,11 +9268,24 @@ function install(isGlobal, runtime = 'claude', options = {}) {
agentsSrc = _stageAgents(path.join(src, 'agents'));
const agentsDest = path.join(targetDir, 'agents');
// ADR-1235 §1: runtimes that have been migrated to the descriptor-driven agent
// path (installRuntimeArtifacts → convertedAgentsKind). The descriptor path
// applies path-rewrite + attribution + converter + normalize via
// stageAgentsForRuntimeWithConverter (with agentCtx pre-converter threading) in
// createRuntimeArtifactInstallPlan. Their agents are already written ABOVE
// (by installRuntimeArtifacts at line 8912), which also performs its own
// stale-file prune pass. The inline stale-removal + inline loop both skip them.
// Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
// cline is excluded: it takes a rules-only local branch and has a local/global
// complication that the descriptor-driven path does not handle correctly.
const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy']);
// Always remove stale gsd-* agents first so re-installing with
// `--minimal` actually shrinks a previously-full install.
// For Codex this also covers per-agent `.toml` files alongside the `.md`
// sources so a full → minimal switch doesn't leave stale registrations.
if (fs.existsSync(agentsDest)) {
// Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes).
if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && fs.existsSync(agentsDest)) {
for (const file of fs.readdirSync(agentsDest)) {
if (
file.startsWith('gsd-') &&
@@ -9285,6 +9298,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
if (isKimi) {
console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
} else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
// installRuntimeArtifacts already wrote agents + handles stale-file cleanup
// via its own prune pass. No further action needed.
console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
} else if (isMinimalMode(_effectiveInstallMode)) {
// Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
// Without stripping them here, a full → minimal reinstall would leave the

View File

@@ -36,6 +36,14 @@
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAugmentAgent"
}
],
"local": [
@@ -54,6 +62,14 @@
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAugmentAgent"
}
]
},

View File

@@ -36,6 +36,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCodebuddyAgent"
}
],
"local": [
@@ -54,6 +62,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCodebuddyAgent"
}
]
},

View File

@@ -36,6 +36,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCursorAgent"
}
],
"local": [
@@ -54,6 +62,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCursorAgent"
}
]
},

View File

@@ -28,6 +28,14 @@
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToTraeAgent"
}
],
"local": [
@@ -38,6 +46,14 @@
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToTraeAgent"
}
]
},

View File

@@ -21,7 +21,16 @@
"localConfigDir": ".windsurf",
"configFormat": "none",
"artifactLayout": {
"global": [],
"global": [
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToWindsurfAgent"
}
],
"local": [
{
"kind": "commands",
@@ -30,6 +39,14 @@
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToWindsurfWorkflow"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToWindsurfAgent"
}
]
},

View File

@@ -213,6 +213,14 @@ const capabilities = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAugmentAgent"
}
],
"local": [
@@ -231,6 +239,14 @@ const capabilities = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAugmentAgent"
}
]
},
@@ -506,6 +522,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCodebuddyAgent"
}
],
"local": [
@@ -524,6 +548,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCodebuddyAgent"
}
]
},
@@ -735,6 +767,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCursorAgent"
}
],
"local": [
@@ -753,6 +793,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCursorAgent"
}
]
},
@@ -2107,6 +2155,14 @@ const capabilities = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToTraeAgent"
}
],
"local": [
@@ -2117,6 +2173,14 @@ const capabilities = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToTraeAgent"
}
]
},
@@ -2265,7 +2329,16 @@ const capabilities = {
"localConfigDir": ".windsurf",
"configFormat": "none",
"artifactLayout": {
"global": [],
"global": [
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToWindsurfAgent"
}
],
"local": [
{
"kind": "commands",
@@ -2274,6 +2347,14 @@ const capabilities = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToWindsurfWorkflow"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToWindsurfAgent"
}
]
},
@@ -3144,6 +3225,14 @@ const runtimes = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAugmentAgent"
}
],
"local": [
@@ -3162,6 +3251,14 @@ const runtimes = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToAugmentSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToAugmentAgent"
}
]
},
@@ -3376,6 +3473,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCodebuddyAgent"
}
],
"local": [
@@ -3394,6 +3499,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCodebuddySkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCodebuddyAgent"
}
]
},
@@ -3605,6 +3718,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCursorAgent"
}
],
"local": [
@@ -3623,6 +3744,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToCursorCommand"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToCursorAgent"
}
]
},
@@ -4160,6 +4289,14 @@ const runtimes = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToTraeAgent"
}
],
"local": [
@@ -4170,6 +4307,14 @@ const runtimes = {
"nesting": "nested",
"recursive": false,
"converter": "convertClaudeCommandToTraeSkill"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToTraeAgent"
}
]
},
@@ -4223,7 +4368,16 @@ const runtimes = {
"localConfigDir": ".windsurf",
"configFormat": "none",
"artifactLayout": {
"global": [],
"global": [
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToWindsurfAgent"
}
],
"local": [
{
"kind": "commands",
@@ -4232,6 +4386,14 @@ const runtimes = {
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeCommandToWindsurfWorkflow"
},
{
"kind": "agents",
"destSubpath": "agents",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,
"converter": "convertClaudeAgentToWindsurfAgent"
}
]
},

View File

@@ -60,10 +60,34 @@ function createRuntimeArtifactInstallPlan(args) {
platform,
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');
const homedirFn = 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 = { runtime: layout.runtime, pathPrefix, attribution };
for (const kind of layout.kinds) {
let stagedDir;
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 };
@@ -78,6 +102,8 @@ function createRuntimeArtifactInstallPlan(args) {
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

@@ -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,
);
},
};

View File

@@ -56,11 +56,11 @@ const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
// ─── Layout contract ─────────────────────────────────────────────────────────
describe('enh-789 — codebuddy layout has commands + skills kinds', () => {
test('resolveRuntimeArtifactLayout codebuddy returns 2 kinds', () => {
test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => {
const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir');
assert.strictEqual(layout.kinds.length, 2, 'codebuddy must have exactly 2 artifact kinds');
assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)');
const kindNames = layout.kinds.map(k => k.kind).sort();
assert.deepStrictEqual(kindNames, ['commands', 'skills']);
assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']);
});
test('codebuddy commands kind targets commands/ with gsd- prefix', () => {

View File

@@ -31,12 +31,12 @@ const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
// ─── Layout contract ─────────────────────────────────────────────────────────
describe('enh-790 — augment layout has commands + skills kinds', () => {
test('resolveRuntimeArtifactLayout augment returns 2 kinds', () => {
describe('enh-790 — augment layout has commands + skills + agents kinds', () => {
test('resolveRuntimeArtifactLayout augment returns 3 kinds', () => {
const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir');
assert.strictEqual(layout.kinds.length, 2, 'augment must have exactly 2 artifact kinds');
assert.strictEqual(layout.kinds.length, 3, 'augment must have exactly 3 artifact kinds');
const kindNames = layout.kinds.map(k => k.kind).sort();
assert.deepStrictEqual(kindNames, ['commands', 'skills']);
assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']);
});
test('augment commands kind targets commands/ with gsd- prefix', () => {

View File

@@ -44,6 +44,8 @@ const FAKE_DIR = '/tmp/fake-config-dir-dd';
// ── STEP-0 golden (captured from switch BEFORE edits) ────────────────────────
// Format: { kind, destSubpath, prefix } for each entry in kinds[].
// 'function' means we assert typeof kind.stage === 'function'.
// ADR-1235 step 1 (#1763): cursor, windsurf, augment, trae, codebuddy each gained
// an `agents` kind (appended last). Goldens consciously updated post-cutover.
const GOLDEN = {
// ── claude ──────────────────────────────────────────────────────────────────
@@ -58,13 +60,16 @@ const GOLDEN = {
// ── cursor ───────────────────────────────────────────────────────────────────
// Old switch: BOTH scopes returned [skills, commands] (no scope branch).
// 5b backfill: local == global.
// ADR-1235 step 1 (#1763): agents kind added.
'cursor/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
'cursor/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── gemini ───────────────────────────────────────────────────────────────────
@@ -104,29 +109,39 @@ const GOLDEN = {
],
// ── windsurf ─────────────────────────────────────────────────────────────────
'windsurf/global': [],
// ADR-1235 step 1 (#1763): agents kind added to both scopes.
'windsurf/global': [
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
'windsurf/local': [
{ kind: 'commands', destSubpath: 'workflows', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── augment ──────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
// ADR-1235 step 1 (#1763): agents kind added.
'augment/global': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
'augment/local': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── trae ─────────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
// ADR-1235 step 1 (#1763): agents kind added.
'trae/global': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
'trae/local': [
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── qwen ─────────────────────────────────────────────────────────────────────
@@ -149,13 +164,16 @@ const GOLDEN = {
// ── codebuddy ────────────────────────────────────────────────────────────────
// Old switch: no scope branch → local == global. 5b backfill restores this.
// ADR-1235 step 1 (#1763): agents kind added.
'codebuddy/global': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
'codebuddy/local': [
{ kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' },
{ kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' },
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── cline ────────────────────────────────────────────────────────────────────
@@ -360,13 +378,15 @@ describe('resolveRuntimeArtifactLayout — scope defaults to global (descriptor-
// ── Non-vacuous check: verify at least one multi-kind runtime ─────────────────
describe('resolveRuntimeArtifactLayout — multi-kind runtimes non-vacuous (descriptor-driven)', () => {
test('augment global returns 2 kinds (commands + skills)', () => {
test('augment global returns 3 kinds (commands + skills + agents)', () => {
const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR, 'global');
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds.length, 3);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[1].kind, 'skills');
assert.strictEqual(layout.kinds[2].kind, 'agents');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
assert.strictEqual(typeof layout.kinds[2].stage, 'function');
});
test('kimi global returns skills then kimi-agents', () => {
@@ -378,10 +398,11 @@ describe('resolveRuntimeArtifactLayout — multi-kind runtimes non-vacuous (desc
assert.strictEqual(layout.kinds[1].prefix, 'gsd');
});
test('codebuddy global returns commands then skills', () => {
test('codebuddy global returns commands then skills then agents', () => {
const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR, 'global');
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds.length, 3);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[1].kind, 'skills');
assert.strictEqual(layout.kinds[2].kind, 'agents');
});
});

View File

@@ -61,11 +61,11 @@ describe('resolveRuntimeArtifactLayout — claude global', () => {
});
describe('resolveRuntimeArtifactLayout — cursor', () => {
test('returns correct layout for cursor — skills + commands kinds (#785)', () => {
test('returns correct layout for cursor — skills + commands + agents kinds (#785, ADR-1235)', () => {
const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR);
assert.strictEqual(layout.runtime, 'cursor');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 2);
assert.strictEqual(layout.kinds.length, 3);
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
assert.ok(skillsKind, 'must have a skills kind');
@@ -78,6 +78,12 @@ describe('resolveRuntimeArtifactLayout — cursor', () => {
assert.strictEqual(commandsKind.destSubpath, 'commands');
assert.strictEqual(commandsKind.prefix, 'gsd-');
assert.strictEqual(typeof commandsKind.stage, 'function');
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
assert.strictEqual(agentsKind.destSubpath, 'agents');
assert.strictEqual(agentsKind.prefix, 'gsd-');
assert.strictEqual(typeof agentsKind.stage, 'function');
});
});
@@ -134,54 +140,84 @@ describe('resolveRuntimeArtifactLayout — antigravity', () => {
});
describe('resolveRuntimeArtifactLayout — windsurf', () => {
test('returns local workflow layout for windsurf', () => {
test('returns local workflow + agents layout for windsurf (ADR-1235)', () => {
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'local');
assert.strictEqual(layout.runtime, 'windsurf');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'workflows');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(layout.kinds.length, 2);
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
assert.ok(commandsKind, 'must have a commands/workflows kind');
assert.strictEqual(commandsKind.destSubpath, 'workflows');
assert.strictEqual(commandsKind.prefix, 'gsd-');
assert.strictEqual(typeof commandsKind.stage, 'function');
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
assert.strictEqual(agentsKind.destSubpath, 'agents');
assert.strictEqual(agentsKind.prefix, 'gsd-');
assert.strictEqual(typeof agentsKind.stage, 'function');
});
test('returns empty global layout for windsurf', () => {
test('returns agents-only global layout for windsurf (ADR-1235)', () => {
const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR, 'global');
assert.strictEqual(layout.runtime, 'windsurf');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 0);
assert.strictEqual(layout.kinds.length, 1);
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
assert.ok(agentsKind, 'global windsurf must have agents kind (ADR-1235 §1)');
assert.strictEqual(agentsKind.destSubpath, 'agents');
assert.strictEqual(agentsKind.prefix, 'gsd-');
assert.strictEqual(typeof agentsKind.stage, 'function');
});
});
describe('resolveRuntimeArtifactLayout — augment', () => {
test('returns correct layout for augment (commands + skills)', () => {
test('returns correct layout for augment (commands + skills + agents — ADR-1235)', () => {
const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR);
assert.strictEqual(layout.runtime, 'augment');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 2);
// commands kind first
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'commands');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
// skills kind second
assert.strictEqual(layout.kinds[1].kind, 'skills');
assert.strictEqual(layout.kinds[1].destSubpath, 'skills');
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
assert.strictEqual(layout.kinds.length, 3);
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
assert.ok(commandsKind, 'must have a commands kind');
assert.strictEqual(commandsKind.destSubpath, 'commands');
assert.strictEqual(commandsKind.prefix, 'gsd-');
assert.strictEqual(typeof commandsKind.stage, 'function');
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
assert.ok(skillsKind, 'must have a skills kind');
assert.strictEqual(skillsKind.destSubpath, 'skills');
assert.strictEqual(skillsKind.prefix, 'gsd-');
assert.strictEqual(typeof skillsKind.stage, 'function');
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
assert.strictEqual(agentsKind.destSubpath, 'agents');
assert.strictEqual(agentsKind.prefix, 'gsd-');
assert.strictEqual(typeof agentsKind.stage, 'function');
});
});
describe('resolveRuntimeArtifactLayout — trae', () => {
test('returns correct layout for trae', () => {
test('returns correct layout for trae (skills + agents — ADR-1235)', () => {
const layout = resolveRuntimeArtifactLayout('trae', FAKE_DIR);
assert.strictEqual(layout.runtime, 'trae');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 1);
assert.strictEqual(layout.kinds[0].kind, 'skills');
assert.strictEqual(layout.kinds[0].destSubpath, 'skills');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(layout.kinds.length, 2);
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
assert.ok(skillsKind, 'must have a skills kind');
assert.strictEqual(skillsKind.destSubpath, 'skills');
assert.strictEqual(skillsKind.prefix, 'gsd-');
assert.strictEqual(typeof skillsKind.stage, 'function');
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
assert.strictEqual(agentsKind.destSubpath, 'agents');
assert.strictEqual(agentsKind.prefix, 'gsd-');
assert.strictEqual(typeof agentsKind.stage, 'function');
});
});
@@ -234,21 +270,29 @@ describe('resolveRuntimeArtifactLayout — hermes', () => {
});
describe('resolveRuntimeArtifactLayout — codebuddy', () => {
test('returns correct layout for codebuddy (commands + skills — #789)', () => {
test('returns correct layout for codebuddy (commands + skills + agents — #789, ADR-1235)', () => {
const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR);
assert.strictEqual(layout.runtime, 'codebuddy');
assert.strictEqual(layout.configDir, FAKE_DIR);
assert.strictEqual(layout.kinds.length, 2);
// commands kind first
assert.strictEqual(layout.kinds[0].kind, 'commands');
assert.strictEqual(layout.kinds[0].destSubpath, 'commands');
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
// skills kind second
assert.strictEqual(layout.kinds[1].kind, 'skills');
assert.strictEqual(layout.kinds[1].destSubpath, 'skills');
assert.strictEqual(layout.kinds[1].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[1].stage, 'function');
assert.strictEqual(layout.kinds.length, 3);
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
assert.ok(commandsKind, 'must have a commands kind');
assert.strictEqual(commandsKind.destSubpath, 'commands');
assert.strictEqual(commandsKind.prefix, 'gsd-');
assert.strictEqual(typeof commandsKind.stage, 'function');
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
assert.ok(skillsKind, 'must have a skills kind');
assert.strictEqual(skillsKind.destSubpath, 'skills');
assert.strictEqual(skillsKind.prefix, 'gsd-');
assert.strictEqual(typeof skillsKind.stage, 'function');
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
assert.ok(agentsKind, 'must have an agents kind (ADR-1235 §1 descriptor cutover)');
assert.strictEqual(agentsKind.destSubpath, 'agents');
assert.strictEqual(agentsKind.prefix, 'gsd-');
assert.strictEqual(typeof agentsKind.stage, 'function');
});
});