From ce62f2b68df7564967b1f9443de31a4cba890f48 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 26 Jun 2026 15:38:51 -0400 Subject: [PATCH] =?UTF-8?q?refactor(#1763):=20ADR-1235=20agent=20migration?= =?UTF-8?q?=20=E2=80=94=20cut=20over=20the=20trivial-converter=20group=20t?= =?UTF-8?q?o=20the=20descriptor=20path=20(#1764)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/clever-cats-howl.md | 7 + bin/install.js | 19 +- capabilities/augment/capability.json | 16 ++ capabilities/codebuddy/capability.json | 16 ++ capabilities/cursor/capability.json | 16 ++ capabilities/trae/capability.json | 16 ++ capabilities/windsurf/capability.json | 19 +- gsd-core/bin/lib/capability-registry.cjs | 166 +++++++++++++++++- .../bin/lib/runtime-artifact-install-plan.cjs | 28 ++- src/install-profiles.cts | 59 ++++++- src/runtime-artifact-conversion.cts | 57 ++++++ src/runtime-artifact-install-plan.cts | 45 ++++- src/runtime-artifact-layout.cts | 22 ++- tests/enh-789-codebuddy-commands.test.cjs | 6 +- tests/enh-790-augment-commands.test.cjs | 8 +- ...-artifact-layout-descriptor-drive.test.cjs | 31 +++- tests/runtime-artifact-layout.test.cjs | 124 ++++++++----- 17 files changed, 591 insertions(+), 64 deletions(-) create mode 100644 .changeset/clever-cats-howl.md diff --git a/.changeset/clever-cats-howl.md b/.changeset/clever-cats-howl.md new file mode 100644 index 000000000..af82bb000 --- /dev/null +++ b/.changeset/clever-cats-howl.md @@ -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. + + diff --git a/bin/install.js b/bin/install.js index 1f11be324..272439d24 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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 diff --git a/capabilities/augment/capability.json b/capabilities/augment/capability.json index 7cd74f23a..27f211790 100644 --- a/capabilities/augment/capability.json +++ b/capabilities/augment/capability.json @@ -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" } ] }, diff --git a/capabilities/codebuddy/capability.json b/capabilities/codebuddy/capability.json index 500565c51..f2de29607 100644 --- a/capabilities/codebuddy/capability.json +++ b/capabilities/codebuddy/capability.json @@ -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" } ] }, diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index 17d94b257..247674009 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -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" } ] }, diff --git a/capabilities/trae/capability.json b/capabilities/trae/capability.json index c900a5660..87dc2f5cd 100644 --- a/capabilities/trae/capability.json +++ b/capabilities/trae/capability.json @@ -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" } ] }, diff --git a/capabilities/windsurf/capability.json b/capabilities/windsurf/capability.json index 274b5e421..98933d374 100644 --- a/capabilities/windsurf/capability.json +++ b/capabilities/windsurf/capability.json @@ -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" } ] }, diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index c2fd94c50..4d1b0c64f 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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" } ] }, diff --git a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs index bf8e9d6a9..e05aad2ec 100644 --- a/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +++ b/gsd-core/bin/lib/runtime-artifact-install-plan.cjs @@ -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 }; diff --git a/src/install-profiles.cts b/src/install-profiles.cts index 4d1a929ce..90674c367 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -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 */ } diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 0ebfe5d13..56bf688c6 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -2551,6 +2551,60 @@ function rewriteStagedCommandBodies(stagedDir, opts) { return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution); } +/** + * Runtimes that use the hyphen-namespace form `/gsd-` 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 = new Set(['claude', 'qwen', 'hermes']); + +/** + * Normalize `/gsd:` colon refs in the agent body to `/gsd-` 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, diff --git a/src/runtime-artifact-install-plan.cts b/src/runtime-artifact-install-plan.cts index 1adfb5874..aea0a0218 100644 --- a/src/runtime-artifact-install-plan.cts +++ b/src/runtime-artifact-install-plan.cts @@ -21,11 +21,17 @@ interface ResolvedProfile { agents?: Set; } +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 }; } diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index aceb479c9..073d670b8 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -54,11 +54,25 @@ interface ResolvedProfile { agents: Set; } +/** + * 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, ); }, }; diff --git a/tests/enh-789-codebuddy-commands.test.cjs b/tests/enh-789-codebuddy-commands.test.cjs index 8c7741db1..dd07ed44c 100644 --- a/tests/enh-789-codebuddy-commands.test.cjs +++ b/tests/enh-789-codebuddy-commands.test.cjs @@ -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', () => { diff --git a/tests/enh-790-augment-commands.test.cjs b/tests/enh-790-augment-commands.test.cjs index eeb6b359d..8ab26b342 100644 --- a/tests/enh-790-augment-commands.test.cjs +++ b/tests/enh-790-augment-commands.test.cjs @@ -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', () => { diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index a5f086dd3..14bf94450 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -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'); }); }); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index ca6580aa6..17ce90abe 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -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'); }); });