@opengsd/gsd-core 1.6.0 → 1.7.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-verifier.md +1 -0
  3. package/bin/gsd-mcp-server.js +31 -0
  4. package/bin/install.js +293 -1145
  5. package/commands/gsd/review.md +6 -0
  6. package/gemini-extension.json +1 -1
  7. package/gsd-core/bin/gsd-tools.cjs +116 -1
  8. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  9. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  10. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  12. package/gsd-core/bin/lib/capability-loader.cjs +18 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  14. package/gsd-core/bin/lib/capability-registry.cjs +889 -82
  15. package/gsd-core/bin/lib/capability-source.cjs +4 -4
  16. package/gsd-core/bin/lib/capability-validator.cjs +198 -0
  17. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  18. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  19. package/gsd-core/bin/lib/config.cjs +27 -0
  20. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  21. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  22. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +408 -0
  24. package/gsd-core/bin/lib/init.cjs +1 -1
  25. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  26. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  27. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  28. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  29. package/gsd-core/bin/lib/milestone.cjs +27 -30
  30. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  31. package/gsd-core/bin/lib/phase.cjs +41 -72
  32. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  33. package/gsd-core/bin/lib/probe-core.cjs +91 -1
  34. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  35. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  36. package/gsd-core/bin/lib/roadmap.cjs +17 -3
  37. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  38. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  39. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  40. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  41. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  42. package/gsd-core/bin/lib/shell-command-projection.cjs +37 -1
  43. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  44. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  45. package/gsd-core/bin/lib/state-io.cjs +55 -0
  46. package/gsd-core/bin/lib/state-transition.cjs +1588 -0
  47. package/gsd-core/bin/lib/state.cjs +306 -681
  48. package/gsd-core/bin/lib/surface.cjs +4 -1
  49. package/gsd-core/bin/lib/workstream.cjs +4 -4
  50. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  51. package/gsd-core/references/honest-verifier.md +105 -0
  52. package/gsd-core/references/reviewer-instances.md +99 -0
  53. package/gsd-core/workflows/autonomous.md +9 -9
  54. package/gsd-core/workflows/manager.md +15 -15
  55. package/gsd-core/workflows/plan-phase.md +1 -1
  56. package/gsd-core/workflows/review.md +26 -0
  57. package/gsd-core/workflows/thread.md +4 -4
  58. package/gsd-core/workflows/verify-phase.md +11 -4
  59. package/hooks/dist/gsd-graphify-update.sh +7 -1
  60. package/hooks/gsd-graphify-update.sh +7 -1
  61. package/package.json +4 -4
  62. package/scripts/ci-test-scope.cjs +38 -9
  63. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  64. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  65. package/scripts/lint-test-file-count.allowlist.json +19 -5
  66. package/scripts/mutation-matrix.cjs +45 -3
  67. package/scripts/prompt-injection-scan.sh +8 -0
  68. package/scripts/lint-windows-test-portability.cjs +0 -178
package/bin/install.js CHANGED
@@ -37,7 +37,7 @@ const {
37
37
  // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
38
38
  // conversion module's rewrite engine can consume it without importing
39
39
  // bin/install.js. Re-exported below for back-compat consumers/tests.
40
- const { getDirName } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
40
+ const { getDirName, getRuntimeLabel, getGlobalConfigHomeFragment, runtimeFlags, getRuntimeNewProjectCommand } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
41
41
  const {
42
42
  applyWorktreeBaseRef,
43
43
  readBaseRefFromSettings,
@@ -364,6 +364,7 @@ const {
364
364
  resolveRuntimeArtifactLayout,
365
365
  } = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs'));
366
366
  const {
367
+ assertDestWithinConfigHome,
367
368
  createRuntimeArtifactInstallPlan,
368
369
  createRuntimeArtifactUninstallPlan,
369
370
  } = require(path.join(_gsdLibDir, 'runtime-artifact-install-plan.cjs'));
@@ -375,6 +376,31 @@ const {
375
376
  updateCacheFileName,
376
377
  } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
377
378
 
379
+ // ADR-1239 Phase B: runtime-artifact install cluster extracted to install-engine.cjs.
380
+ // getCommitAttribution STAYS here (impure install-time config I/O); it is injected
381
+ // into the engine functions via the resolveAttribution parameter at each call site.
382
+ const installEngine = require(path.join(_gsdLibDir, 'install-engine.cjs'));
383
+ const {
384
+ installRuntimeArtifacts,
385
+ uninstallRuntimeArtifacts,
386
+ installOpencodeFamilySkills,
387
+ _copyStaged,
388
+ hasExistingSymlinkBetween,
389
+ preserveUserArtifacts,
390
+ restoreUserArtifacts,
391
+ migrateLegacyDevPreferencesToSkill,
392
+ applyOpencodeFamilyPathPrefix,
393
+ convertClaudeCommandToOpencodeSkill,
394
+ convertClaudeCommandToKiloSkill,
395
+ USER_OWNED_ARTIFACTS,
396
+ _runLegacyInstallMigrations,
397
+ _runLegacyUninstallCleanup,
398
+ _removeGsdEntries,
399
+ _snapshotDir,
400
+ _restoreDir,
401
+ _removeHermesBareStemDirs,
402
+ } = installEngine;
403
+
378
404
  // Parse args
379
405
  const args = process.argv.slice(2);
380
406
  const hasGlobal = args.includes('--global') || args.includes('-g');
@@ -484,18 +510,11 @@ function getConfigDirFromHome(runtime, isGlobal) {
484
510
  // Local installs use the same dir name pattern
485
511
  return `'${getDirName(runtime)}'`;
486
512
  }
487
- // Global installs - OpenCode uses XDG path structure
488
- if (runtime === 'copilot') return "'.copilot'";
489
- if (runtime === 'opencode') {
490
- // OpenCode: ~/.config/opencode -> '.config', 'opencode'
491
- // Return as comma-separated for path.join() replacement
492
- return "'.config', 'opencode'";
493
- }
494
- if (runtime === 'gemini') return "'.gemini'";
495
- if (runtime === 'kilo') return "'.config', 'kilo'";
496
- if (runtime === 'codex') return "'.codex'";
513
+ // Global installs. antigravity's home is resolved dynamically (env-overridable,
514
+ // multi-segment via resolveAntigravityGlobalDir + path.relative) — not a table
515
+ // entry. (The prior inner `if (!isGlobal) return "'.agents'"` was unreachable:
516
+ // !isGlobal returns at the top of this function.)
497
517
  if (runtime === 'antigravity') {
498
- if (!isGlobal) return "'.agents'";
499
518
  const antigravityDir = resolveAntigravityGlobalDir();
500
519
  const rel = path.relative(os.homedir(), antigravityDir);
501
520
  const segments = rel.split(path.sep).filter(Boolean);
@@ -506,16 +525,9 @@ function getConfigDirFromHome(runtime, isGlobal) {
506
525
  // stable legacy template so generated path.join() calls remain valid.
507
526
  return "'.gemini', 'antigravity'";
508
527
  }
509
- if (runtime === 'cursor') return "'.cursor'";
510
- if (runtime === 'windsurf') return "'.windsurf'";
511
- if (runtime === 'augment') return "'.augment'";
512
- if (runtime === 'trae') return "'.trae'";
513
- if (runtime === 'qwen') return "'.qwen'";
514
- if (runtime === 'hermes') return "'.hermes'";
515
- if (runtime === 'codebuddy') return "'.codebuddy'";
516
- if (runtime === 'cline') return "'.cline'";
517
- if (runtime === 'kimi') return "'.config', 'agents'";
518
- return "'.claude'";
528
+ // All other runtimes: single source-of-truth fragment table (ADR-1239 Phase B,
529
+ // #1679). claude/unknown fall through to the table's default '.claude'.
530
+ return getGlobalConfigHomeFragment(runtime);
519
531
  }
520
532
 
521
533
  /**
@@ -630,6 +642,15 @@ const processAttribution = runtimeArtifactConversion.processAttribution;
630
642
  const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
631
643
  const applyRuntimeContentRewritesInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesInPlace;
632
644
  const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace;
645
+ // #1675 (ADR-1508): the augment converter family is single-sourced in the
646
+ // conversion module. install.js re-binds (does not re-define) these so there
647
+ // is exactly one body — the generative-drift hazard the dedup removes. The two
648
+ // private helpers (getAugmentSkillAdapterHeader, convertSlashCommandsToAugmentSkillMentions)
649
+ // live only in the conversion module now; they are no longer duplicated here.
650
+ // (All call sites are below this line → no TDZ hazard.)
651
+ const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
652
+ const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
653
+ const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
633
654
 
634
655
  function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
635
656
  return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
@@ -2580,105 +2601,14 @@ const claudeToAugmentTools = {
2580
2601
  TodoWrite: 'add_tasks',
2581
2602
  };
2582
2603
 
2583
- function convertSlashCommandsToAugmentSkillMentions(content) {
2584
- return content.replace(/gsd:/gi, 'gsd-');
2585
- }
2586
-
2587
- function convertClaudeToAugmentMarkdown(content) {
2588
- let converted = convertSlashCommandsToAugmentSkillMentions(content);
2589
- converted = converted.replace(/\bBash\(/g, 'launch-process(');
2590
- converted = converted.replace(/\bEdit\(/g, 'str-replace-editor(');
2591
- converted = converted.replace(/\bRead\(/g, 'view(');
2592
- converted = converted.replace(/\bWrite\(/g, 'save-file(');
2593
- converted = converted.replace(/\bTodoWrite\(/g, 'add_tasks(');
2594
- converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
2595
- // Replace subagent_type from Claude to Augment format
2596
- converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
2597
- converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
2598
- // Replace project-level Claude conventions with Augment equivalents
2599
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.augment/rules/`');
2600
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.augment/rules/');
2601
- converted = converted.replace(/`CLAUDE\.md`/g, '`.augment/rules/`');
2602
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.augment/rules/');
2603
- converted = converted.replace(/\.claude\/skills\//g, '.augment/skills/');
2604
- // Remove Claude Code-specific bug workarounds before brand replacement
2605
- converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2606
- converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2607
- // Replace "Claude Code" brand references with "Augment"
2608
- converted = converted.replace(/\bClaude Code\b/g, 'Augment');
2609
- return converted;
2610
- }
2611
-
2612
- function getAugmentSkillAdapterHeader(skillName) {
2613
- return `<augment_skill_adapter>
2614
- ## A. Skill Invocation
2615
- - This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
2616
- - Treat all user text after the skill mention as \`{{GSD_ARGS}}\`.
2617
- - If no arguments are present, treat \`{{GSD_ARGS}}\` as empty.
2618
-
2619
- ## B. User Prompting
2620
- When the workflow needs user input, prompt the user conversationally:
2621
- - Present options as a numbered list in your response text
2622
- - Ask the user to reply with their choice
2623
- - For multi-select, ask for comma-separated numbers
2624
-
2625
- ## C. Tool Usage
2626
- Use these Augment tools when executing GSD workflows:
2627
- - \`launch-process\` for running commands (terminal operations)
2628
- - \`str-replace-editor\` for editing existing files
2629
- - \`view\` for reading files and listing directories
2630
- - \`save-file\` for creating new files
2631
- - \`grep\` for searching code (or use MCP servers for advanced search)
2632
- - \`web-search\`, \`web-fetch\` for web queries
2633
- - \`add_tasks\`, \`view_tasklist\`, \`update_tasks\` for task management
2634
-
2635
- ## D. Subagent Spawning
2636
- When the workflow needs to spawn a subagent:
2637
- - Use the built-in subagent spawning capability
2638
- - Define agent prompts in \`.augment/agents/\` directory
2639
- </augment_skill_adapter>`;
2640
- }
2641
-
2642
- function convertClaudeCommandToAugmentSkill(content, skillName) {
2643
- const converted = convertClaudeToAugmentMarkdown(content);
2644
- const { frontmatter, body } = extractFrontmatterAndBody(converted);
2645
- let description = `Run GSD workflow ${skillName}.`;
2646
- if (frontmatter) {
2647
- const maybeDescription = extractFrontmatterField(frontmatter, 'description');
2648
- if (maybeDescription) {
2649
- description = maybeDescription;
2650
- }
2651
- }
2652
- description = toSingleLine(description);
2653
- const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2654
- const adapter = getAugmentSkillAdapterHeader(skillName);
2655
-
2656
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2657
- }
2658
-
2659
- /**
2660
- * Convert Claude Code agent markdown to Augment agent format.
2661
- * Strips frontmatter fields Augment doesn't support (color, skills),
2662
- * converts tool references, and cleans up for Augment agents.
2663
- */
2664
- function convertClaudeAgentToAugmentAgent(content) {
2665
- let converted = convertClaudeToAugmentMarkdown(content);
2666
-
2667
- const { frontmatter, body } = extractFrontmatterAndBody(converted);
2668
- if (!frontmatter) return converted;
2669
-
2670
- const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
2671
- const description = extractFrontmatterField(frontmatter, 'description') || '';
2672
-
2673
- const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
2674
-
2675
- return `${cleanFrontmatter}\n${body}`;
2676
- }
2677
-
2678
- /**
2679
- * Copy Claude commands as Augment skills — one folder per skill with SKILL.md.
2680
- * Mirrors copyCommandsAsCursorSkills but uses Augment converters.
2681
- */
2604
+ // #1675 (ADR-1508): the augment converter family below was a byte-identical
2605
+ // duplicate of runtime-artifact-conversion.cjs:
2606
+ // convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown,
2607
+ // getAugmentSkillAdapterHeader, convertClaudeCommandToAugmentSkill,
2608
+ // convertClaudeAgentToAugmentAgent
2609
+ // Deleted here and bound from runtimeArtifactConversion above (single source).
2610
+ // The DEFECT.GENERATIVE-FIX parity guard in
2611
+ // tests/enh-1511-rewrite-engine-relocation.test.cjs asserts reference identity.
2682
2612
 
2683
2613
  function convertSlashCommandsToTraeSkillMentions(content) {
2684
2614
  return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
@@ -5710,8 +5640,21 @@ function writeCopilotHookConfig(targetDir) {
5710
5640
  * Reads agent .md files from source, extracts metadata, writes .toml configs.
5711
5641
  */
5712
5642
  function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') {
5713
- const configPath = path.join(targetDir, 'config.toml');
5714
- const agentsTomlDir = path.join(targetDir, 'agents');
5643
+ // ADR-1239 Phase B write-confinement: every Codex config write stays under targetDir.
5644
+ const configPath = assertDestWithinConfigHome(targetDir, 'config.toml');
5645
+ const agentsTomlDir = assertDestWithinConfigHome(targetDir, 'agents');
5646
+ const resolvedTargetRoot = path.resolve(targetDir);
5647
+ // Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the
5648
+ // lexical gate above does not resolve symlinks, so a pre-existing config.toml or
5649
+ // agents/ symlink could redirect writes outside targetDir. Reject those.
5650
+ if (
5651
+ hasExistingSymlinkBetween(resolvedTargetRoot, configPath) ||
5652
+ hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir))
5653
+ ) {
5654
+ throw new Error(
5655
+ `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink escaping the install root — refusing to write`,
5656
+ );
5657
+ }
5715
5658
  fs.mkdirSync(agentsTomlDir, { recursive: true });
5716
5659
 
5717
5660
  const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
@@ -5756,7 +5699,16 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
5756
5699
  // follows the same config-driven precedence as the Claude .md effort key.
5757
5700
  const effortCfg = readGsdEffectiveEffortConfig(targetDir);
5758
5701
  const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
5759
- fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent);
5702
+ // Confine the per-agent write to the agents/ dir itself: a crafted agent
5703
+ // `name` containing path separators must not escape agents/ (which would let
5704
+ // it clobber config.toml or write elsewhere under the configHome).
5705
+ const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`);
5706
+ if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath)) {
5707
+ throw new Error(
5708
+ `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink escaping the install root — refusing to write`,
5709
+ );
5710
+ }
5711
+ fs.writeFileSync(agentTomlPath, tomlContent);
5760
5712
  }
5761
5713
 
5762
5714
  const gsdBlock = generateCodexConfigBlock(agents, targetDir);
@@ -6326,69 +6278,9 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6326
6278
  return `---\n${newFrontmatter}\n---${body}`;
6327
6279
  }
6328
6280
 
6329
- /**
6330
- * Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo),
6331
- * which share a config schema (Kilo derives from OpenCode). OpenCode discovers
6332
- * skills as `skills/<name>/SKILL.md` and Kilo follows the same layout
6333
- * (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills).
6334
- *
6335
- * The skill body reuses the runtime's command-frontmatter converter for tool,
6336
- * path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill
6337
- * frontmatter: only `name` (lowercase-hyphen, must match the containing
6338
- * directory) and `description` (1–1024 chars) are emitted, per the OpenCode
6339
- * skill spec. The command's `tools:`/`permission:` block is intentionally
6340
- * dropped — OpenCode skills are loaded on-demand via the native skill tool and
6341
- * inherit the calling agent's permissions.
6342
- *
6343
- * @param {string} content - Claude command markdown (with YAML frontmatter)
6344
- * @param {string} skillName - Skill directory name (e.g. gsd-help)
6345
- * @param {(content: string) => string} frontmatterConverter - runtime command converter
6346
- * @returns {string} SKILL.md content
6347
- */
6348
- function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) {
6349
- const converted = frontmatterConverter(content);
6350
- const { frontmatter, body } = extractFrontmatterAndBody(converted);
6351
- let description = `Run GSD workflow ${skillName}.`;
6352
- if (frontmatter) {
6353
- const maybeDescription = extractFrontmatterField(frontmatter, 'description');
6354
- if (maybeDescription) {
6355
- description = maybeDescription;
6356
- }
6357
- }
6358
- description = toSingleLine(description);
6359
- // OpenCode skill descriptions must be 1–1024 characters.
6360
- if (description.length > 1024) {
6361
- description = `${description.slice(0, 1021)}...`;
6362
- }
6363
- // `name` must be lowercase alphanumeric with single-hyphen separators and
6364
- // match the containing directory name (the staged dir is `${skillName}/`).
6365
- const name = yamlIdentifier(skillName);
6366
- return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`;
6367
- }
6368
-
6369
- /**
6370
- * Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
6371
- * Thin wrapper over the shared OpenCode-family writer.
6372
- */
6373
- function convertClaudeCommandToOpencodeSkill(content, skillName) {
6374
- return convertClaudeCommandToOpencodeFamilySkill(
6375
- content,
6376
- skillName,
6377
- (c) => convertClaudeToOpencodeFrontmatter(c),
6378
- );
6379
- }
6380
-
6381
- /**
6382
- * Convert a Claude command (.md) to a Kilo skill (SKILL.md).
6383
- * Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
6384
- */
6385
- function convertClaudeCommandToKiloSkill(content, skillName) {
6386
- return convertClaudeCommandToOpencodeFamilySkill(
6387
- content,
6388
- skillName,
6389
- (c) => convertClaudeToKiloFrontmatter(c),
6390
- );
6391
- }
6281
+ // convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill,
6282
+ // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
6283
+ // Imported from installEngine above.
6392
6284
 
6393
6285
  /**
6394
6286
  * Convert Claude Code markdown command to Gemini TOML format
@@ -6471,30 +6363,8 @@ function convertClaudeToGeminiToml(content, { commandName = null } = {}) {
6471
6363
  * @param {string} pathPrefix - Path prefix for file references
6472
6364
  * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
6473
6365
  */
6474
- /**
6475
- * Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a
6476
- * RAW Claude command/skill body, BEFORE the frontmatter converter runs.
6477
- *
6478
- * This is the single source of truth shared by copyFlattenedCommands (commands)
6479
- * and installOpencodeFamilySkills (skills) so the two surfaces produce identical
6480
- * path references. Applying pathPrefix pre-conversion (rather than rewriting an
6481
- * already-converted body) is what avoids the converter's hardcoded default
6482
- * config dir leaking into --local / --config-dir installs, and the
6483
- * prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784)
6484
- *
6485
- * @param {string} content - raw Claude command markdown
6486
- * @param {string} runtime - 'opencode' or 'kilo'
6487
- * @param {string} pathPrefix - trailing-slash install-target prefix
6488
- * @returns {string}
6489
- */
6490
- function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
6491
- content = content.replace(/~\/\.claude\//g, pathPrefix);
6492
- content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
6493
- content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
6494
- content = content.replace(/~\/\.opencode\//g, pathPrefix);
6495
- content = content.replace(/~\/\.kilo\//g, pathPrefix);
6496
- return content;
6497
- }
6366
+ // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
6367
+ // Imported from installEngine above.
6498
6368
 
6499
6369
  function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
6500
6370
  if (!fs.existsSync(srcDir)) {
@@ -6633,688 +6503,140 @@ function writeHermesCategoryDescription(categoryDir) {
6633
6503
  * @param {boolean} isGlobal - Whether this is a global install
6634
6504
  */
6635
6505
 
6636
- /**
6637
- * Single source of truth for user-owned artifacts inside gsd-core/.
6638
- *
6639
- * These files are created/refreshed by user-facing workflows (e.g.
6640
- * /gsd-profile-user) and must be preserved across reinstalls. Critically, they
6641
- * MUST be excluded from gsd-file-manifest.json — otherwise saveLocalPatches()
6642
- * will compare a refreshed file against a stale manifest hash and emit a
6643
- * spurious "locally modified GSD file" warning (bug #2771).
6644
- *
6645
- * Invariant: a file is either distribution (manifest-tracked, diff'd against
6646
- * manifest) or user artifact (preserved across installs, never diff'd). Never
6647
- * both. Both preserveUserArtifacts call sites and writeManifest must agree on
6648
- * this list, which is why it lives here as a single constant.
6649
- *
6650
- * Paths are relative to the gsd-core/ directory.
6651
- */
6652
- const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
6653
-
6654
- /**
6655
- * Save user-generated files from destDir to an in-memory map before a wipe.
6656
- *
6657
- * @param {string} destDir - Directory that is about to be wiped
6658
- * @param {string[]} fileNames - Relative file names (e.g. ['USER-PROFILE.md']) to preserve
6659
- * @returns {Map<string, string>} Map of fileName → file content (only entries that existed)
6660
- */
6661
- function preserveUserArtifacts(destDir, fileNames) {
6662
- const saved = new Map();
6663
- for (const name of fileNames) {
6664
- const fullPath = path.join(destDir, name);
6665
- if (fs.existsSync(fullPath)) {
6666
- try {
6667
- saved.set(name, fs.readFileSync(fullPath, 'utf8'));
6668
- } catch { /* skip unreadable files */ }
6669
- }
6670
- }
6671
- return saved;
6672
- }
6673
-
6674
- /**
6675
- * Restore user-generated files saved by preserveUserArtifacts after a wipe.
6676
- *
6677
- * @param {string} destDir - Directory that was wiped and recreated
6678
- * @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
6679
- */
6680
- function restoreUserArtifacts(destDir, saved) {
6681
- for (const [name, content] of saved) {
6682
- const fullPath = path.join(destDir, name);
6683
- try {
6684
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
6685
- fs.writeFileSync(fullPath, content, 'utf8');
6686
- } catch { /* skip unwritable paths */ }
6687
- }
6688
- }
6689
-
6690
- /**
6691
- * Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the
6692
- * runtime-aware SKILL.md location used by the writer after #2973.
6693
- *
6694
- * For runtimes with a nested skills layout (e.g. Hermes: skills/gsd/<stem>/),
6695
- * the target is <configDir>/skills/gsd/dev-preferences/SKILL.md.
6696
- * For runtimes with a flat skills layout (prefix='gsd-'), the target is
6697
- * <configDir>/skills/gsd-dev-preferences/SKILL.md.
6698
- *
6699
- * Skips silently if no legacy file was preserved, or if a SKILL.md already
6700
- * exists at the new location (don't clobber user-customized skill content
6701
- * — they may have edited the new file directly). Returns true on actual
6702
- * migration so callers can log a one-line confirmation.
6703
- *
6704
- * @param {string} targetDir - Resolved runtime config directory (e.g. ~/.claude)
6705
- * @param {Map<string, string>} saved - Map returned by preserveUserArtifacts
6706
- * @param {string} [runtime] - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude')
6707
- * @param {'global'|'local'} [scope] - install scope
6708
- * @returns {boolean} - true if a file was migrated, false otherwise
6709
- */
6710
- function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') {
6711
- if (!saved || !saved.has('dev-preferences.md')) return false;
6712
- let skillDir;
6713
- if (runtime) {
6714
- const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
6715
- const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
6716
- if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local)
6717
- const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
6718
- skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName);
6719
- } else {
6720
- // Legacy fallback for callers that have not yet been updated to pass runtime
6721
- skillDir = path.join(targetDir, 'skills', 'gsd-dev-preferences');
6722
- }
6723
- const skillFile = path.join(skillDir, 'SKILL.md');
6724
- if (fs.existsSync(skillFile)) return false;
6725
- try {
6726
- fs.mkdirSync(skillDir, { recursive: true });
6727
- fs.writeFileSync(skillFile, saved.get('dev-preferences.md'), 'utf8');
6728
- return true;
6729
- } catch {
6730
- return false;
6731
- }
6732
- }
6506
+ // USER_OWNED_ARTIFACTS, preserveUserArtifacts, restoreUserArtifacts,
6507
+ // migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries,
6508
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _snapshotDir,
6509
+ // _restoreDir, _removeHermesBareStemDirs, installRuntimeArtifacts,
6510
+ // installOpencodeFamilySkills, uninstallRuntimeArtifacts:
6511
+ // ALL moved to src/install-engine.cts (ADR-1239 Phase B).
6512
+ // Imported from installEngine above.
6733
6513
 
6734
6514
  // ---------------------------------------------------------------------------
6735
- // Phase 2 — Layout-driven install/uninstall orchestrators
6515
+ // Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
6516
+ // _applyRuntimeRewrites / _stampNonClaudeRuntimeDefaults remain here for
6517
+ // call sites in copyWithPathReplacement (not moved).
6736
6518
  // ---------------------------------------------------------------------------
6737
-
6738
- /**
6739
- * Apply per-runtime content rewrites in place across every SKILL.md inside a
6740
- * staged directory. Reproduces the rewrite scaffolding that the old
6741
- * copyCommandsAs<Runtime>Skills functions applied between read-content and
6742
- * converter-call. Applied AFTER stage (which already called the converter);
6743
- * rewrites target stable path patterns the converter doesn't touch.
6744
- *
6745
- * For Qwen/Hermes, branding rewrites (.claude/ → .qwen/ / .hermes/) run
6746
- * AFTER the slash-form path replacements but they only catch bare `.claude/`
6747
- * patterns (skill-body relative refs) that the slash forms didn't consume.
6748
- * This mirrors the exact ordering in the legacy copyCommandsAsClaudeSkills body.
6749
- *
6750
- * @param {string} stagedDir
6751
- * @param {string} runtime
6752
- * @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
6753
- * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
6754
- */
6755
- // applyRuntimeContentRewritesInPlace: walk loop is now owned by
6756
- // runtimeArtifactConversion.applyRuntimeContentRewritesInPlace (ADR-1508 / #1511 Phase 2).
6757
- // The const binding above (~line 629) delegates here. Call sites in installRuntimeArtifacts
6758
- // pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract.
6759
-
6760
- /**
6761
- * Apply per-runtime content rewrites to flat .md files in a staged commands dir.
6762
- * Used for runtimes that have a commandsKind in their layout and need content rewrites
6763
- * (e.g. augment — replaces ~/.claude/ paths and applies branding conversions).
6764
- *
6765
- * IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged
6766
- * on a full/default profile (skills === '*'). This function MUST NOT mutate that source
6767
- * directory. It always copies to a temp dir first, rewrites there, and returns the new
6768
- * path so the caller installs from the temp copy, not the source.
6769
- *
6770
- * @param {string} stagedDir directory of staged flat .md command files (may be source dir)
6771
- * @param {string} runtime
6772
- * @param {string} pathPrefix
6773
- * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
6774
- * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
6775
- */
6776
- // applyRuntimeContentRewritesForCommandsInPlace: copy+rewrite loop is now owned by
6777
- // runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace (ADR-1508 / #1511 Phase 2).
6778
- // The const binding above (~line 630) delegates here. Call sites in installRuntimeArtifacts
6779
- // pass attribution as the 5th arg (getCommitAttribution(runtime)) per the new contract.
6780
-
6781
- /**
6782
- * Apply the per-runtime rewrite table to a single content string.
6783
- * Extracted so it can be unit-tested independently of the filesystem walk.
6784
- *
6785
- * @param {string} content
6786
- * @param {string} runtime
6787
- * @param {string} pathPrefix trailing-slash string
6788
- * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
6789
- * @returns {string}
6790
- */
6791
- // _applyRuntimeRewrites: single implementation lives in runtimeArtifactConversion
6792
- // (ADR-1508 / #1511 Phase 2). Bound here so install.js call sites and exports are
6793
- // reference-identical to the conversion module (consistent with the walkers above).
6794
- // All call sites are below this line → no TDZ hazard.
6795
6519
  const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites;
6796
6520
  const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults;
6797
6521
 
6798
6522
  /**
6799
- * Copy a staged directory's contents into destDir.
6800
- * Additive — does not prune (surface.cjs handles pruning).
6801
- *
6802
- * For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy
6803
- * the whole dir into destDir.
6804
- * For commands/agents kind: iterate .md files and write them into destDir.
6805
- * - commands: write as `${prefix}${stem}.md` unless destSubpath already
6806
- * encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
6807
- * which case write as `${stem}.md` (directory IS the namespace).
6808
- * - agents: write as-is (files already carry their own `gsd-` prefix).
6809
- * For kimi-agents kind: recursively copy generated YAML/prompt files.
6810
- */
6811
- function _copyStaged(stagedDir, destDir, kind) {
6812
- if (!fs.existsSync(stagedDir)) return;
6813
- fs.mkdirSync(destDir, { recursive: true });
6814
-
6815
- if (kind.kind === 'skills') {
6816
- // Each child of stagedDir is a prefixed skill directory: gsd-help/, etc.
6817
- for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
6818
- if (!entry.isDirectory()) continue;
6819
- const src = path.join(stagedDir, entry.name);
6820
- const dest = path.join(destDir, entry.name);
6821
- fs.cpSync(src, dest, { recursive: true });
6822
- }
6823
- return;
6824
- }
6825
-
6826
- if (kind.kind === 'kimi-agents') {
6827
- fs.cpSync(stagedDir, destDir, { recursive: true });
6828
- return;
6829
- }
6830
-
6831
- // commands or agents
6832
- const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
6833
- // For commands: apply prefix unless the destSubpath's last segment already
6834
- // represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd').
6835
- const destLast = path.basename(kind.destSubpath);
6836
- const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : '';
6837
- const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem;
6838
-
6839
- for (const entry of entries) {
6840
- if (!entry.isFile()) continue;
6841
- if (!entry.name.endsWith('.md')) continue;
6842
- const stem = entry.name.slice(0, -3); // strip .md
6843
-
6844
- let destName;
6845
- if (kind.kind === 'agents') {
6846
- // Agent files already carry the gsd- prefix in the source dir
6847
- destName = entry.name;
6848
- } else if (namespacedByDir) {
6849
- // Directory is the namespace; don't double-prefix the filename
6850
- destName = entry.name;
6851
- } else {
6852
- // Flat commands directory (e.g. command/ for opencode/kilo)
6853
- destName = `${kind.prefix}${stem}.md`;
6854
- }
6855
-
6856
- fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName));
6857
- }
6858
- }
6859
-
6860
- /**
6861
- * Remove GSD-prefixed entries from destDir matching kind.prefix.
6862
- * For the prefix='' case: the destSubpath IS the namespace — remove the entire
6863
- * destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
6864
- * as a defensive guard for future runtimes.)
6865
- */
6866
- function _removeGsdEntries(destDir, kind) {
6867
- if (!fs.existsSync(destDir)) return;
6868
- if (kind.kind === 'kimi-agents') {
6869
- for (const fileName of ['gsd.yaml', 'gsd.md']) {
6870
- fs.rmSync(path.join(destDir, fileName), { force: true });
6871
- }
6872
- const subagentsDir = path.join(destDir, 'subagents');
6873
- if (fs.existsSync(subagentsDir)) {
6874
- for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) {
6875
- if (!entry.isFile()) continue;
6876
- if (!entry.name.startsWith('gsd-')) continue;
6877
- if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
6878
- fs.rmSync(path.join(subagentsDir, entry.name), { force: true });
6879
- }
6880
- }
6881
- return;
6882
- }
6883
- if (kind.prefix === '') {
6884
- // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
6885
- // The directory itself is the GSD namespace, so remove it entirely.
6886
- fs.rmSync(destDir, { recursive: true, force: true });
6887
- return;
6888
- }
6889
- for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) {
6890
- if (!entry.name.startsWith(kind.prefix)) continue;
6891
- fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true });
6892
- }
6893
- }
6894
-
6895
- /**
6896
- * Run legacy install migrations that must execute BEFORE the layout-driven
6897
- * copy so stale artifacts are cleaned up before new ones are written.
6898
- *
6899
- * - Claude/Qwen/Hermes: migrate legacy commands/gsd/dev-preferences.md →
6900
- * skills/gsd-dev-preferences/SKILL.md if the old file is present.
6901
- * Also removes the legacy commands/gsd/ directory.
6902
- * - Hermes: remove flat skills/gsd-STAR directories (pre-2841 layout) before
6903
- * writing the new nested skills/gsd/ layout.
6904
- *
6905
- * @param {string} runtime
6906
- * @param {string} configDir resolved runtime config directory
6907
- * @param {'global'|'local'} [scope]
6908
- */
6909
- function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
6910
- const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
6911
-
6912
- // Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences
6913
- // for migration. The actual migration call is deferred to after all layout cleanup so
6914
- // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly
6915
- // created skills/gsd-dev-preferences/ skill dir.
6916
- let savedLegacyArtifacts = null;
6917
- if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') {
6918
- if (fs.existsSync(legacyCommandsGsd)) {
6919
- savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
6920
- fs.rmSync(legacyCommandsGsd, { recursive: true });
6921
- }
6922
- }
6923
-
6924
- // Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside
6925
- // the new skills/gsd/ nested layout.
6926
- if (runtime === 'hermes') {
6927
- const flatSkillsDir = path.join(configDir, 'skills');
6928
- if (fs.existsSync(flatSkillsDir)) {
6929
- for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
6930
- if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
6931
- fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
6932
- }
6933
- }
6934
- }
6935
-
6936
- // Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
6937
- // layout-driven install loop in installRuntimeArtifacts, where the exact set
6938
- // of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
6939
- // require readGsdCommandNames() which misses skills like 'dev-preferences'
6940
- // that are not in the commands directory. See _removeHermesBareStemDirs().
6941
- }
6942
-
6943
- // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
6944
- // Done after all layout cleanup so Hermes flat-dir removal does not delete the
6945
- // newly created skill dir. No-op if skill file already exists.
6946
- if (savedLegacyArtifacts) {
6947
- migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
6948
- }
6949
- }
6950
-
6951
- /**
6952
- * Run legacy uninstall cleanup that must execute BEFORE the layout-driven
6953
- * removal so old-format entries are also cleaned up.
6954
- *
6955
- * - Claude global/Qwen: remove legacy commands/gsd/ directory if present.
6956
- * For Claude LOCAL, commands/gsd/ is the current primary location (not
6957
- * legacy), so we skip removal here and let _removeGsdEntries handle it
6958
- * with gsd- prefix filtering (preserving user files like dev-preferences.md).
6959
- * - Hermes: remove pre-2841 flat skills/gsd-STAR entries.
6960
- *
6961
- * @param {string} runtime
6962
- * @param {string} configDir resolved runtime config directory
6963
- * @param {'global'|'local'} [scope]
6964
- */
6965
- function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
6966
- // commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs.
6967
- // Prior to #1367 fix, Claude-local used commands/gsd/<cmd>.md (colon-namespaced).
6968
- // After #1367, Claude-local uses flat commands/gsd-<cmd>.md. The inline uninstall
6969
- // block (1c) handles removal of flat files; this function handles the legacy
6970
- // commands/gsd/ directory for all Claude scopes (global was already included,
6971
- // local is now added since that layout is also legacy post-#1367).
6972
- // #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md
6973
- // before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md
6974
- // is deferred and returned so the caller can apply it AFTER layout-driven
6975
- // removal — this prevents the layout's gsd-* prefix removal from wiping the
6976
- // freshly created skill dir (same pattern as _runLegacyInstallMigrations).
6977
- let savedLegacyArtifacts = null;
6978
- // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global.
6979
- // Claude local is intentionally excluded: the inline uninstall block (1c) handles
6980
- // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it
6981
- // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here
6982
- // (which would redirect to skills/) conflicts with the test contract for local installs.
6983
- const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global');
6984
- if (isLegacyCommandsGsd) {
6985
- const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
6986
- if (fs.existsSync(legacyCommandsGsd)) {
6987
- savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']);
6988
- fs.rmSync(legacyCommandsGsd, { recursive: true });
6989
- }
6990
- }
6991
-
6992
- // Hermes: pre-#2841 flat skills/gsd-*/ entries
6993
- if (runtime === 'hermes') {
6994
- const flatSkillsDir = path.join(configDir, 'skills');
6995
- if (fs.existsSync(flatSkillsDir)) {
6996
- for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) {
6997
- if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
6998
- fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true });
6999
- }
7000
- }
7001
- }
7002
-
7003
- // Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
7004
- // start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
7005
- // had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
7006
- const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd');
7007
- if (fs.existsSync(nestedGsdDirForUninstall)) {
7008
- for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
7009
- if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
7010
- fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
7011
- }
7012
- }
7013
- }
7014
- }
7015
-
7016
- // Return saved artifacts so the caller can migrate after layout-driven removal.
7017
- return savedLegacyArtifacts;
7018
- }
7019
-
7020
- /**
7021
- * Layout-driven install orchestrator.
7022
- * Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to
7023
- * determine what artifact kinds to write and where.
7024
- *
7025
- * @param {string} runtime canonical runtime ID
7026
- * @param {string} configDir resolved runtime config directory
7027
- * @param {'global'|'local'} scope
7028
- * @param {Object} resolvedProfile from resolveProfile() / resolveEffectiveProfile()
7029
- */
7030
- /**
7031
- * Deep-snapshot a directory tree into a Map<relPath, Buffer>.
7032
- * Returns an empty Map if the directory doesn't exist.
7033
- * @param {string} dir
7034
- * @returns {Map<string, Buffer>}
7035
- */
7036
- function _snapshotDir(dir) {
7037
- const files = new Map();
7038
- if (!fs.existsSync(dir)) return files;
7039
- const walk = (relPath, absPath) => {
7040
- for (const e of fs.readdirSync(absPath, { withFileTypes: true })) {
7041
- const childRel = relPath ? path.join(relPath, e.name) : e.name;
7042
- const childAbs = path.join(absPath, e.name);
7043
- if (e.isDirectory()) walk(childRel, childAbs);
7044
- else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs));
7045
- }
7046
- };
7047
- walk('', dir);
7048
- return files;
7049
- }
7050
-
7051
- /**
7052
- * Restore a directory tree from a Map<relPath, Buffer> produced by _snapshotDir.
7053
- * @param {string} dir
7054
- * @param {Map<string, Buffer>} snapshot
7055
- */
7056
- function _restoreDir(dir, snapshot) {
7057
- for (const [relPath, buf] of snapshot) {
7058
- const absPath = path.join(dir, relPath);
7059
- fs.mkdirSync(path.dirname(absPath), { recursive: true });
7060
- fs.writeFileSync(absPath, buf);
7061
- }
7062
- }
7063
-
7064
- /**
7065
- * After the layout-driven install loop writes new gsd-<stem>/ dirs to
7066
- * skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
7067
- * that correspond to the newly installed gsd-<stem> entries.
7068
- *
7069
- * The removal set is derived from the ACTUAL installed skill dirs (every
7070
- * entry starting with 'gsd-' that is a directory), so it covers ALL shipped
7071
- * GSD skills — including 'dev-preferences' and future additions — without
7072
- * relying on readGsdCommandNames() which only enumerates the commands source
7073
- * tree and can miss skills that ship outside that directory.
7074
- *
7075
- * Safety: a bare dir is ONLY removed when a corresponding gsd-<stem>/ dir was
7076
- * installed this run. A user-owned dir 'skills/gsd/my-workflow/' that has no
7077
- * matching 'skills/gsd/gsd-my-workflow/' is never touched.
7078
- *
7079
- * @param {string} nestedGsdDir absolute path to skills/gsd/ category dir
7080
- */
7081
- function _removeHermesBareStemDirs(nestedGsdDir) {
7082
- if (!fs.existsSync(nestedGsdDir)) return;
7083
- const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
7084
-
7085
- // Collect the set of stems that were installed as gsd-<stem>/ this run.
7086
- const installedStems = new Set();
7087
- for (const entry of entries) {
7088
- if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
7089
- installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
7090
- }
7091
- }
7092
-
7093
- // Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
7094
- for (const entry of entries) {
7095
- if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
7096
- fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
7097
- }
7098
- }
7099
- }
7100
-
7101
- function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
7102
- // Legacy cleanup before layout-driven writes
7103
- _runLegacyInstallMigrations(runtime, configDir, scope);
7104
-
7105
- const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
7106
- const planResult = createRuntimeArtifactInstallPlan({
7107
- layout,
7108
- resolvedProfile,
7109
- homedir: () => os.homedir(),
7110
- platform: process.platform,
7111
- resolveAttribution: getCommitAttribution,
7112
- });
7113
-
7114
- const cleanupDirs = planResult.ok ? planResult.plan.cleanupDirs : planResult.cleanupDirs;
7115
- try {
7116
- if (!planResult.ok) {
7117
- throw new Error(planResult.message);
7118
- }
7119
-
7120
- const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
7121
- for (const item of planResult.plan.items) {
7122
- const kind = kindsByName.get(item.kind);
7123
- if (!kind) throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
7124
- const dest = item.destDir;
7125
- fs.mkdirSync(dest, { recursive: true });
7126
- if (kind.kind === 'skills' && fs.existsSync(dest)) {
7127
- // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it,
7128
- // then restore after. This preserves user dirs across a wipe-and-replace
7129
- // install (#2973 / #3664).
7130
- //
7131
- // All runtimes (incl. Hermes after #947) use prefix='gsd-'.
7132
- // _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
7133
- // untouched. Preserve the explicit user-owned GSD-prefixed skill
7134
- // gsd-dev-preferences, which GSD does not reinstall from source but must
7135
- // survive the prune (#2973).
7136
- const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
7137
-
7138
- {
7139
- // Preserve explicitly user-owned GSD-prefixed skill dirs.
7140
- // gsd-dev-preferences is the sole user-customisable skill in this category.
7141
- const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
7142
- for (const dirName of USER_OWNED_SKILL_DIRS) {
7143
- const skillDir = path.join(dest, dirName);
7144
- if (!fs.existsSync(skillDir)) continue;
7145
- const snap = _snapshotDir(skillDir);
7146
- if (snap.size > 0) toPreserve.set(dirName, snap);
7147
- }
7148
- }
7149
-
7150
- _removeGsdEntries(dest, kind);
7151
- _copyStaged(item.sourceDir, dest, kind);
7152
-
7153
- // Restore user-owned dirs after the prune+copy
7154
- for (const [dirName, snap] of toPreserve) {
7155
- _restoreDir(path.join(dest, dirName), snap);
7156
- }
7157
- } else {
7158
- // For non-skills kinds (commands, agents): no user content to preserve;
7159
- // just prune stale gsd-* entries and copy new ones.
7160
- _removeGsdEntries(dest, kind);
7161
- _copyStaged(item.sourceDir, dest, kind);
7162
- }
7163
- }
7164
- } finally {
7165
- for (const dir of cleanupDirs) {
7166
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
7167
- }
7168
- }
7169
-
7170
- // Hermes: after the install loop has written all gsd-<stem>/ dirs to
7171
- // skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
7172
- // correspond to the newly installed gsd-<stem> entries. This is the robust
7173
- // replacement for the readGsdCommandNames()-based pre-install cleanup that
7174
- // missed skills like 'dev-preferences' (#947 adversarial review).
7175
- //
7176
- // We run this AFTER the install loop so the installed set is authoritative:
7177
- // every gsd-<stem>/ present now was written this run (or was there before
7178
- // with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
7179
- // are untouched.
7180
- if (runtime === 'hermes') {
7181
- const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
7182
- _removeHermesBareStemDirs(nestedGsdDirForCleanup);
7183
- }
7184
- }
7185
-
7186
- /**
7187
- * Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
7188
- *
7189
- * These runtimes do NOT go through installRuntimeArtifacts (their commands use a
7190
- * bespoke flattened-command writer), so this writes ONLY the skills kind
7191
- * alongside their existing command/ + agents/ surfaces. Uninstall is already
7192
- * layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
7193
- * skills/ dir is cleaned up automatically once the layout declares it.
7194
- *
7195
- * `rawCommandsDir` MUST be the SAME staged command directory the flattened
7196
- * command writer consumes (the caller passes its `_stageSkills()` output) so the
7197
- * command/ and skills/ surfaces always cover the identical, profile-resolved set
7198
- * — including the `--minimal`/`--core-only` alias path, which stages differently
7199
- * from a plain `--profile=core`.
7200
- *
7201
- * Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite →
7202
- * attribution → command→skill conversion — guaranteeing command/ and skills/
7203
- * bodies match byte-for-byte for global, --local, and --config-dir installs.
7204
- * We deliberately do NOT use skillsKindEntry.stage(): that converts before any
7205
- * pathPrefix is known, so its bodies would carry the converter's hardcoded
7206
- * default config dir. (#784)
7207
- *
7208
- * @param {string} runtime - 'opencode' or 'kilo'
7209
- * @param {string} targetDir - resolved runtime config directory
7210
- * @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
7211
- * @param {string} pathPrefix - computed config-path prefix for body rewrites
7212
- * @returns {number} number of gsd-* skill directories written
7213
- */
7214
- function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) {
7215
- const layout = resolveRuntimeArtifactLayout(runtime, targetDir);
7216
- const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
7217
- if (!skillsKindEntry) return 0;
7218
- const rawDir = rawCommandsDir;
7219
- if (!rawDir || !fs.existsSync(rawDir)) return 0;
7220
-
7221
- const converter = runtime === 'kilo'
7222
- ? convertClaudeCommandToKiloSkill
7223
- : convertClaudeCommandToOpencodeSkill;
7224
-
7225
- const dest = path.join(targetDir, skillsKindEntry.destSubpath);
7226
- fs.mkdirSync(dest, { recursive: true });
7227
-
7228
- // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
7229
- // gsd-dev-preferences is generated by the user (via generate-dev-preferences)
7230
- // and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
7231
- // would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
7232
- // (#2973).
7233
- const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
7234
- const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
7235
- for (const dirName of USER_OWNED_SKILL_DIRS) {
7236
- const skillDir = path.join(dest, dirName);
7237
- if (!fs.existsSync(skillDir)) continue;
7238
- const snap = _snapshotDir(skillDir);
7239
- if (snap.size > 0) toPreserve.set(dirName, snap);
7240
- }
7241
-
7242
- _removeGsdEntries(dest, skillsKindEntry);
7243
-
7244
- let count = 0;
7245
- for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
7246
- if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
7247
- const stem = entry.name.slice(0, -3);
7248
- const skillName = `${skillsKindEntry.prefix}${stem}`;
7249
- let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
7250
- content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
7251
- content = processAttribution(content, getCommitAttribution(runtime));
7252
- content = converter(content, skillName);
7253
- const skillDir = path.join(dest, skillName);
7254
- fs.mkdirSync(skillDir, { recursive: true });
7255
- fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
7256
- count++;
7257
- }
7258
-
7259
- // Restore user-owned dirs after the prune+copy.
7260
- for (const [dirName, snap] of toPreserve) {
7261
- _restoreDir(path.join(dest, dirName), snap);
7262
- }
7263
-
7264
- return count;
7265
- }
7266
-
7267
- /**
7268
- * Layout-driven uninstall orchestrator.
7269
- * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
7270
- * determine which GSD-owned entries to remove.
7271
- *
7272
- * @param {string} runtime canonical runtime ID
7273
- * @param {string} configDir resolved runtime config directory
7274
- * @param {'global'|'local'} scope
7275
- */
7276
- function uninstallRuntimeArtifacts(runtime, configDir, scope) {
7277
- // Legacy cleanup before layout-driven removal (scope-aware to avoid
7278
- // removing Claude local commands/gsd/ which is the primary install dir).
7279
- // Returns saved user artifacts so we can migrate AFTER layout removal
7280
- // (the layout's gsd-* prefix pass would wipe a skill dir created here).
7281
- const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope);
7282
-
7283
- const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope);
7284
- const plan = createRuntimeArtifactUninstallPlan(layout);
7285
- const kindsByName = new Map(layout.kinds.map((kind) => [kind.kind, kind]));
7286
- for (const item of plan.items) {
7287
- const kind = kindsByName.get(item.kind);
7288
- if (!kind) {
7289
- throw new Error(`Runtime artifact uninstall plan referenced unknown kind: ${item.kind}`);
7290
- }
7291
- _removeGsdEntries(item.destDir, kind);
7292
- }
7293
-
7294
- // Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
7295
- // the GSD-managed DESCRIPTION.md and then the category dir itself if it
7296
- // contains no user content (#947). _removeGsdEntries removed gsd-* dirs
7297
- // but left the category container and DESCRIPTION.md intact.
7298
- if (runtime === 'hermes') {
7299
- const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
7300
- if (fs.existsSync(nestedGsdDir)) {
7301
- // Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
7302
- fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
7303
- // Remove the category dir if empty (no user content remaining)
7304
- const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
7305
- if (remaining.length === 0) {
7306
- fs.rmSync(nestedGsdDir, { recursive: true, force: true });
7307
- }
7308
- }
7309
- }
7310
-
7311
- // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
7312
- // runtime-aware SKILL.md location after all layout-driven removal is
7313
- // complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
7314
- if (savedLegacyArtifacts) {
7315
- migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope);
7316
- }
7317
- }
6523
+ * Data-driven dispatch table for copyWithPathReplacement (ADR-1239 Phase B).
6524
+ * Keyed by runtime id. Each entry declares ONLY what that runtime does differently.
6525
+ * The DEFAULT (no entry, or entry with no md/js key) = identity transform after
6526
+ * the uniform steps — covers claude, augment, codebuddy, kimi, etc.
6527
+ *
6528
+ * Entry shape:
6529
+ * mdSkipGenericRewrite?: boolean — skip the ~/.claude/ rewrite block (copilot, antigravity)
6530
+ * md?: (content, ctx) => string — per-runtime .md transform
6531
+ * mdReattributeAfter?: boolean — re-run processAttribution after md() (copilot, antigravity)
6532
+ * mdTomlRenameOnCommand?: boolean — when isCommand, rename dest .md → .toml (gemini)
6533
+ * js?: (content, ctx) => string — per-runtime .cjs/.js transform (absent = plain copyFileSync)
6534
+ *
6535
+ * ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName, runtime }
6536
+ */
6537
+ const RUNTIME_CONTENT_DISPATCH = {
6538
+ opencode: {
6539
+ md: (content) => convertClaudeToOpencodeFrontmatter(content),
6540
+ },
6541
+ kilo: {
6542
+ md: (content) => convertClaudeToKiloFrontmatter(content),
6543
+ },
6544
+ gemini: {
6545
+ md: (content, ctx) =>
6546
+ convertClaudeToGeminiMarkdown(content, {
6547
+ isCommand: ctx.isCommand,
6548
+ commandName: ctx.isCommand ? ctx.entryName.replace(/\.md$/, '') : null,
6549
+ }),
6550
+ mdTomlRenameOnCommand: true,
6551
+ },
6552
+ codex: {
6553
+ md: (content) => convertClaudeToCodexMarkdown(content),
6554
+ },
6555
+ copilot: {
6556
+ mdSkipGenericRewrite: true,
6557
+ md: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal),
6558
+ mdReattributeAfter: true,
6559
+ js: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal),
6560
+ },
6561
+ antigravity: {
6562
+ mdSkipGenericRewrite: true,
6563
+ md: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
6564
+ mdReattributeAfter: true,
6565
+ js: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
6566
+ },
6567
+ cursor: {
6568
+ md: (content) => convertClaudeToCursorMarkdown(content),
6569
+ js: (content) => {
6570
+ content = content.replace(/gsd:/gi, 'gsd-');
6571
+ content = content.replace(/\.claude\/skills\//g, '.cursor/skills/');
6572
+ content = content.replace(/CLAUDE\.md/g, '.cursor/rules/');
6573
+ content = content.replace(/\bClaude Code\b/g, 'Cursor');
6574
+ return content;
6575
+ },
6576
+ },
6577
+ windsurf: {
6578
+ md: (content) => convertClaudeToWindsurfMarkdown(content),
6579
+ js: (content) => {
6580
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
6581
+ content = content.replace(/gsd:/gi, 'gsd-');
6582
+ content = content.replace(/\.claude\/skills\//g, '.devin/skills/');
6583
+ content = content.replace(/CLAUDE\.md/g, '.devin/rules');
6584
+ content = content.replace(/\bClaude Code\b/g, 'Windsurf');
6585
+ return content;
6586
+ },
6587
+ },
6588
+ trae: {
6589
+ md: (content) => convertClaudeToTraeMarkdown(content),
6590
+ js: (content) => {
6591
+ content = content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
6592
+ return `/gsd-${commandName}`;
6593
+ });
6594
+ content = content.replace(/\.claude\/skills\//g, '.trae/skills/');
6595
+ content = content.replace(/CLAUDE\.md/g, '.trae/rules/');
6596
+ content = content.replace(/\bClaude Code\b/g, 'Trae');
6597
+ return content;
6598
+ },
6599
+ },
6600
+ cline: {
6601
+ md: (content) => convertClaudeToCliineMarkdown(content),
6602
+ js: (content) => {
6603
+ content = content.replace(/\.claude\/skills\//g, '.cline/skills/');
6604
+ content = content.replace(/CLAUDE\.md/g, '.clinerules');
6605
+ content = content.replace(/\bClaude Code\b/g, 'Cline');
6606
+ return content;
6607
+ },
6608
+ },
6609
+ qwen: {
6610
+ md: (content) => {
6611
+ content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6612
+ content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
6613
+ content = content.replace(/\.claude\//g, '.qwen/');
6614
+ return content;
6615
+ },
6616
+ js: (content) => {
6617
+ content = content.replace(/\.claude\/skills\//g, '.qwen/skills/');
6618
+ content = content.replace(/\.claude\//g, '.qwen/');
6619
+ content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6620
+ content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
6621
+ return content;
6622
+ },
6623
+ },
6624
+ hermes: {
6625
+ md: (content) => {
6626
+ content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6627
+ content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
6628
+ content = content.replace(/\.claude\//g, '.hermes/');
6629
+ return content;
6630
+ },
6631
+ js: (content) => {
6632
+ content = content.replace(/\.claude\/skills\//g, '.hermes/skills/');
6633
+ content = content.replace(/\.claude\//g, '.hermes/');
6634
+ content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6635
+ content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
6636
+ return content;
6637
+ },
6638
+ },
6639
+ };
7318
6640
 
7319
6641
  /**
7320
6642
  * Recursively copy directory, replacing paths in .md files
@@ -7326,22 +6648,28 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
7326
6648
  * @param {boolean} isCommand - Whether the source is a command directory
7327
6649
  * @param {boolean} isGlobal - Whether the install is global
7328
6650
  */
7329
- function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false) {
7330
- const isOpencode = runtime === 'opencode';
7331
- const isKilo = runtime === 'kilo';
7332
- const isGemini = runtime === 'gemini';
7333
- const isCodex = runtime === 'codex';
7334
- const isCopilot = runtime === 'copilot';
7335
- const isAntigravity = runtime === 'antigravity';
7336
- const isCursor = runtime === 'cursor';
7337
- const isWindsurf = runtime === 'windsurf';
7338
- const isAugment = runtime === 'augment';
7339
- const isTrae = runtime === 'trae';
7340
- const isQwen = runtime === 'qwen';
7341
- const isHermes = runtime === 'hermes';
7342
- const isCline = runtime === 'cline';
6651
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false, confinementRoot) {
7343
6652
  const dirName = getDirName(runtime);
7344
6653
 
6654
+ // ADR-1239 Phase B write-confinement: refuse to wipe/write a destDir that
6655
+ // escapes the caller-declared install root. Runs BEFORE the rmSync below so a
6656
+ // crafted destDir can never delete or write outside confinementRoot.
6657
+ if (confinementRoot === undefined) {
6658
+ throw new Error(
6659
+ 'copyWithPathReplacement: confinementRoot is required to confine writes to the install root — refusing to write',
6660
+ );
6661
+ }
6662
+ const resolvedConfinementRoot = path.resolve(confinementRoot);
6663
+ const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir);
6664
+ if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir)) {
6665
+ throw new Error(
6666
+ `copyWithPathReplacement: destDir "${destDir}" contains a symlink escaping the install root "${confinementRoot}" — refusing to write`,
6667
+ );
6668
+ }
6669
+ // Use the validated absolute path for all writes below so the gate validates
6670
+ // exactly what is written (a relative destDir would otherwise resolve to cwd).
6671
+ destDir = resolvedDestDir;
6672
+
7345
6673
  // Clean install: remove existing destination to prevent orphaned files
7346
6674
  if (fs.existsSync(destDir)) {
7347
6675
  fs.rmSync(destDir, { recursive: true });
@@ -7355,12 +6683,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7355
6683
  const destPath = path.join(destDir, entry.name);
7356
6684
 
7357
6685
  if (entry.isDirectory()) {
7358
- copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal);
6686
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot);
7359
6687
  } else if (entry.name.endsWith('.md')) {
6688
+ const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
6689
+ const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
6690
+
7360
6691
  // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
7361
- // Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths
6692
+ // Skip generic replacement for Copilot/Antigravity — their converters handle all paths
7362
6693
  let content = fs.readFileSync(srcPath, 'utf8');
7363
- if (!isCopilot && !isAntigravity) {
6694
+ if (!dispatch.mdSkipGenericRewrite) {
7364
6695
  const globalClaudeRegex = /~\/\.claude\//g;
7365
6696
  const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
7366
6697
  const localClaudeRegex = /\.\/\.claude\//g;
@@ -7395,112 +6726,25 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7395
6726
  // colon-canonical runtimes (Gemini).
7396
6727
  content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
7397
6728
 
7398
- // Convert frontmatter for opencode compatibility
7399
- if (isOpencode || isKilo) {
7400
- content = isKilo
7401
- ? convertClaudeToKiloFrontmatter(content)
7402
- : convertClaudeToOpencodeFrontmatter(content);
7403
- fs.writeFileSync(destPath, content);
7404
- } else if (isGemini) {
7405
- // Apply Gemini-specific Markdown transformations (slash commands, TOML).
7406
- // #778: thread the command name (file stem) so per-command TOML
7407
- // enrichment (live-state injection) can target a specific command.
7408
- const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null;
7409
- const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName });
7410
- const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
7411
- fs.writeFileSync(finalPath, processed);
7412
- } else if (isCodex) {
7413
- content = convertClaudeToCodexMarkdown(content);
7414
- fs.writeFileSync(destPath, content);
7415
- } else if (isCopilot) {
7416
- content = convertClaudeToCopilotContent(content, isGlobal);
7417
- content = processAttribution(content, getCommitAttribution(runtime));
7418
- fs.writeFileSync(destPath, content);
7419
- } else if (isAntigravity) {
7420
- content = convertClaudeToAntigravityContent(content, isGlobal);
7421
- content = processAttribution(content, getCommitAttribution(runtime));
7422
- fs.writeFileSync(destPath, content);
7423
- } else if (isCursor) {
7424
- content = convertClaudeToCursorMarkdown(content);
7425
- fs.writeFileSync(destPath, content);
7426
- } else if (isWindsurf) {
7427
- content = convertClaudeToWindsurfMarkdown(content);
7428
- fs.writeFileSync(destPath, content);
7429
- } else if (isTrae) {
7430
- content = convertClaudeToTraeMarkdown(content);
7431
- fs.writeFileSync(destPath, content);
7432
- } else if (isCline) {
7433
- content = convertClaudeToCliineMarkdown(content);
7434
- fs.writeFileSync(destPath, content);
7435
- } else if (isQwen) {
7436
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
7437
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
7438
- content = content.replace(/\.claude\//g, '.qwen/');
7439
- fs.writeFileSync(destPath, content);
7440
- } else if (isHermes) {
7441
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
7442
- content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
7443
- content = content.replace(/\.claude\//g, '.hermes/');
6729
+ // Apply per-runtime .md converter (if any)
6730
+ if (dispatch.md) content = dispatch.md(content, ctx);
6731
+
6732
+ // Re-run attribution after converter for runtimes that need it (copilot, antigravity)
6733
+ if (dispatch.mdReattributeAfter) content = processAttribution(content, getCommitAttribution(runtime));
6734
+
6735
+ // Gemini: rename .md → .toml for command files
6736
+ const finalPath = (dispatch.mdTomlRenameOnCommand && isCommand) ? destPath.replace(/\.md$/, '.toml') : destPath;
6737
+ fs.writeFileSync(finalPath, content);
6738
+ } else if (entry.name.endsWith('.cjs') || entry.name.endsWith('.js')) {
6739
+ const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
6740
+ if (dispatch.js) {
6741
+ const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
6742
+ let content = fs.readFileSync(srcPath, 'utf8');
6743
+ content = dispatch.js(content, ctx);
7444
6744
  fs.writeFileSync(destPath, content);
7445
6745
  } else {
7446
- fs.writeFileSync(destPath, content);
6746
+ fs.copyFileSync(srcPath, destPath);
7447
6747
  }
7448
- } else if (isCopilot && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7449
- // Copilot: also transform .cjs/.js files for CONV-06 and CONV-07
7450
- let content = fs.readFileSync(srcPath, 'utf8');
7451
- content = convertClaudeToCopilotContent(content, isGlobal);
7452
- fs.writeFileSync(destPath, content);
7453
- } else if (isAntigravity && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7454
- // Antigravity: also transform .cjs/.js files for path/command conversions
7455
- let content = fs.readFileSync(srcPath, 'utf8');
7456
- content = convertClaudeToAntigravityContent(content, isGlobal);
7457
- fs.writeFileSync(destPath, content);
7458
- } else if (isCursor && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7459
- // For Cursor, also convert Claude references in JS/CJS utility scripts
7460
- let jsContent = fs.readFileSync(srcPath, 'utf8');
7461
- jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
7462
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.cursor/skills/');
7463
- jsContent = jsContent.replace(/CLAUDE\.md/g, '.cursor/rules/');
7464
- jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
7465
- fs.writeFileSync(destPath, jsContent);
7466
- } else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7467
- // For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
7468
- // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
7469
- let jsContent = fs.readFileSync(srcPath, 'utf8');
7470
- jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
7471
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
7472
- jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
7473
- jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
7474
- fs.writeFileSync(destPath, jsContent);
7475
- } else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7476
- let jsContent = fs.readFileSync(srcPath, 'utf8');
7477
- jsContent = jsContent.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
7478
- return `/gsd-${commandName}`;
7479
- });
7480
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.trae/skills/');
7481
- jsContent = jsContent.replace(/CLAUDE\.md/g, '.trae/rules/');
7482
- jsContent = jsContent.replace(/\bClaude Code\b/g, 'Trae');
7483
- fs.writeFileSync(destPath, jsContent);
7484
- } else if (isCline && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7485
- let jsContent = fs.readFileSync(srcPath, 'utf8');
7486
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.cline/skills/');
7487
- jsContent = jsContent.replace(/CLAUDE\.md/g, '.clinerules');
7488
- jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cline');
7489
- fs.writeFileSync(destPath, jsContent);
7490
- } else if (isQwen && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7491
- let jsContent = fs.readFileSync(srcPath, 'utf8');
7492
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.qwen/skills/');
7493
- jsContent = jsContent.replace(/\.claude\//g, '.qwen/');
7494
- jsContent = jsContent.replace(/CLAUDE\.md/g, 'QWEN.md');
7495
- jsContent = jsContent.replace(/\bClaude Code\b/g, 'Qwen Code');
7496
- fs.writeFileSync(destPath, jsContent);
7497
- } else if (isHermes && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7498
- let jsContent = fs.readFileSync(srcPath, 'utf8');
7499
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.hermes/skills/');
7500
- jsContent = jsContent.replace(/\.claude\//g, '.hermes/');
7501
- jsContent = jsContent.replace(/CLAUDE\.md/g, 'HERMES.md');
7502
- jsContent = jsContent.replace(/\bClaude Code\b/g, 'Hermes Agent');
7503
- fs.writeFileSync(destPath, jsContent);
7504
6748
  } else {
7505
6749
  fs.copyFileSync(srcPath, destPath);
7506
6750
  }
@@ -7674,19 +6918,7 @@ const GSD_UNINSTALL_HOOKS = [
7674
6918
  * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot')
7675
6919
  */
7676
6920
  function uninstall(isGlobal, runtime = 'claude') {
7677
- const isOpencode = runtime === 'opencode';
7678
- const isKilo = runtime === 'kilo';
7679
- const isGemini = runtime === 'gemini';
7680
- const isCodex = runtime === 'codex';
7681
- const isCopilot = runtime === 'copilot';
7682
- const isAntigravity = runtime === 'antigravity';
7683
- const isCursor = runtime === 'cursor';
7684
- const isWindsurf = runtime === 'windsurf';
7685
- const isAugment = runtime === 'augment';
7686
- const isTrae = runtime === 'trae';
7687
- const isQwen = runtime === 'qwen';
7688
- const isHermes = runtime === 'hermes';
7689
- const isCodebuddy = runtime === 'codebuddy';
6921
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
7690
6922
  const dirName = getDirName(runtime);
7691
6923
 
7692
6924
  // Get the target directory based on runtime and install type. Cline local
@@ -7702,21 +6934,9 @@ function uninstall(isGlobal, runtime = 'claude') {
7702
6934
  ? targetDir.replace(os.homedir(), '~')
7703
6935
  : targetDir.replace(process.cwd(), '.');
7704
6936
 
7705
- let runtimeLabel = 'Claude Code';
7706
- if (runtime === 'opencode') runtimeLabel = 'OpenCode';
7707
- if (runtime === 'gemini') runtimeLabel = 'Gemini';
7708
- if (runtime === 'kilo') runtimeLabel = 'Kilo';
7709
- if (runtime === 'codex') runtimeLabel = 'Codex';
7710
- if (runtime === 'copilot') runtimeLabel = 'Copilot';
7711
- if (runtime === 'antigravity') runtimeLabel = 'Antigravity';
7712
- if (runtime === 'cursor') runtimeLabel = 'Cursor';
7713
- if (runtime === 'windsurf') runtimeLabel = 'Windsurf';
7714
- if (runtime === 'augment') runtimeLabel = 'Augment';
7715
- if (runtime === 'trae') runtimeLabel = 'Trae';
7716
- if (runtime === 'qwen') runtimeLabel = 'Qwen Code';
7717
- if (runtime === 'hermes') runtimeLabel = 'Hermes Agent';
7718
- if (runtime === 'kimi') runtimeLabel = 'Kimi CLI';
7719
- if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy';
6937
+ // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
6938
+ // Phase B / #1679) — collapses the prior 15-line assignment chain.
6939
+ const runtimeLabel = getRuntimeLabel(runtime);
7720
6940
 
7721
6941
  console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
7722
6942
 
@@ -8676,45 +7896,14 @@ function resolveInstallRelativePath(baseDir, relPath) {
8676
7896
  return { relPath: normalized, fullPath };
8677
7897
  }
8678
7898
 
8679
- function hasExistingSymlinkBetween(root, fullPath) {
8680
- const resolvedRoot = path.resolve(root);
8681
- const resolvedFullPath = path.resolve(fullPath);
8682
- if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
8683
- return true;
8684
- }
8685
-
8686
- let cursor = resolvedRoot;
8687
- if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
8688
- return true;
8689
- }
8690
-
8691
- const relative = path.relative(resolvedRoot, resolvedFullPath);
8692
- for (const segment of relative.split(path.sep)) {
8693
- if (!segment) continue;
8694
- cursor = path.join(cursor, segment);
8695
- if (!fs.existsSync(cursor)) return false;
8696
- if (fs.lstatSync(cursor).isSymbolicLink()) return true;
8697
- }
8698
-
8699
- return false;
8700
- }
7899
+ // hasExistingSymlinkBetween: moved to src/install-engine.cts (ADR-1239 Phase B).
7900
+ // Imported from installEngine above.
8701
7901
 
8702
7902
  /**
8703
7903
  * Write file manifest after installation for future modification detection
8704
7904
  */
8705
7905
  function writeManifest(configDir, runtime = 'claude', options = {}) {
8706
- const isOpencode = runtime === 'opencode';
8707
- const isKilo = runtime === 'kilo';
8708
- const isGemini = runtime === 'gemini';
8709
- const isCodex = runtime === 'codex';
8710
- const isCopilot = runtime === 'copilot';
8711
- const isAntigravity = runtime === 'antigravity';
8712
- const isCursor = runtime === 'cursor';
8713
- const isWindsurf = runtime === 'windsurf';
8714
- const isTrae = runtime === 'trae';
8715
- const isCline = runtime === 'cline';
8716
- const isKimi = runtime === 'kimi';
8717
- const isHermes = runtime === 'hermes';
7906
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
8718
7907
  const gsdDir = path.join(configDir, 'gsd-core');
8719
7908
  // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
8720
7909
  // commandsDir points to the old location for Gemini (which still uses commands/gsd/).
@@ -8943,7 +8132,7 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP
8943
8132
  const srcDir = path.join(packageSrc, top);
8944
8133
  const stageDir = path.join(stageRoot, top);
8945
8134
  if (!fs.existsSync(srcDir)) continue;
8946
- copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal);
8135
+ copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal, stageRoot);
8947
8136
  }
8948
8137
 
8949
8138
  for (const relPath of safeModified) {
@@ -9201,21 +8390,7 @@ function reportInstallerMigrationResult(result) {
9201
8390
  }
9202
8391
 
9203
8392
  function install(isGlobal, runtime = 'claude', options = {}) {
9204
- const isOpencode = runtime === 'opencode';
9205
- const isGemini = runtime === 'gemini';
9206
- const isKilo = runtime === 'kilo';
9207
- const isKimi = runtime === 'kimi';
9208
- const isCodex = runtime === 'codex';
9209
- const isCopilot = runtime === 'copilot';
9210
- const isAntigravity = runtime === 'antigravity';
9211
- const isCursor = runtime === 'cursor';
9212
- const isWindsurf = runtime === 'windsurf';
9213
- const isAugment = runtime === 'augment';
9214
- const isTrae = runtime === 'trae';
9215
- const isQwen = runtime === 'qwen';
9216
- const isHermes = runtime === 'hermes';
9217
- const isCodebuddy = runtime === 'codebuddy';
9218
- const isCline = runtime === 'cline';
8393
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
9219
8394
  const plan = resolveInstallPlan(runtime);
9220
8395
  const dirName = getDirName(runtime);
9221
8396
  const src = path.join(__dirname, '..');
@@ -9361,22 +8536,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9361
8536
  homeDir,
9362
8537
  });
9363
8538
 
9364
- let runtimeLabel = 'Claude Code';
9365
- if (isOpencode) runtimeLabel = 'OpenCode';
9366
- if (isGemini) runtimeLabel = 'Gemini';
9367
- if (isKilo) runtimeLabel = 'Kilo';
9368
- if (isCodex) runtimeLabel = 'Codex';
9369
- if (isCopilot) runtimeLabel = 'Copilot';
9370
- if (isAntigravity) runtimeLabel = 'Antigravity';
9371
- if (isCursor) runtimeLabel = 'Cursor';
9372
- if (isWindsurf) runtimeLabel = 'Windsurf';
9373
- if (isAugment) runtimeLabel = 'Augment';
9374
- if (isTrae) runtimeLabel = 'Trae';
9375
- if (isQwen) runtimeLabel = 'Qwen Code';
9376
- if (isHermes) runtimeLabel = 'Hermes Agent';
9377
- if (isKimi) runtimeLabel = 'Kimi';
9378
- if (isCodebuddy) runtimeLabel = 'CodeBuddy';
9379
- if (isCline) runtimeLabel = 'Cline';
8539
+ // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
8540
+ // Phase B / #1679) — collapses the prior 16-line assignment chain.
8541
+ const runtimeLabel = getRuntimeLabel(runtime);
9380
8542
 
9381
8543
  console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
9382
8544
 
@@ -9671,7 +8833,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9671
8833
  if (_isSkillsRuntime) {
9672
8834
  // Layout-driven install for skills-based runtimes (full and minimal modes)
9673
8835
  const scope = isGlobal ? 'global' : 'local';
9674
- installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
8836
+ installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
9675
8837
 
9676
8838
  // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
9677
8839
  // gsd-* skill dirs. Prior installs wrote these files so Codex would show a
@@ -9831,7 +8993,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9831
8993
  // Kilo support native, on-demand skills in addition to flat commands — see
9832
8994
  // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
9833
8995
  // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
9834
- const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix);
8996
+ const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution);
9835
8997
  if (_skillCount > 0) {
9836
8998
  console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
9837
8999
  } else {
@@ -9886,7 +9048,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9886
9048
  fs.mkdirSync(commandsDir, { recursive: true });
9887
9049
  const gsdSrc = _stageSkills(_commandsDir);
9888
9050
  const gsdDest = path.join(commandsDir, 'gsd');
9889
- copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
9051
+ copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal, targetDir);
9890
9052
  if (verifyInstalled(gsdDest, 'commands/gsd')) {
9891
9053
  console.log(` ${green}✓${reset} Installed commands/gsd`);
9892
9054
  } else {
@@ -9968,7 +9130,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9968
9130
  const skillSrc = path.join(src, 'gsd-core');
9969
9131
  const skillDest = path.join(targetDir, 'gsd-core');
9970
9132
  const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS);
9971
- copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal);
9133
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
9972
9134
  restoreUserArtifacts(skillDest, savedGsdArtifacts);
9973
9135
  if (verifyInstalled(skillDest, 'gsd-core')) {
9974
9136
  console.log(` ${green}✓${reset} Installed workflow assets`);
@@ -9988,7 +9150,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9988
9150
  const commandsSrc = path.join(src, 'commands', 'gsd');
9989
9151
  const commandsDest = path.join(skillDest, 'commands', 'gsd');
9990
9152
  if (fs.existsSync(commandsSrc)) {
9991
- copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal);
9153
+ copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal, targetDir);
9992
9154
  console.log(` ${green}✓${reset} Installed command bodies to gsd-core/commands/gsd/ (workflow delegation targets)`);
9993
9155
  }
9994
9156
  }
@@ -10030,11 +9192,24 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10030
9192
  agentsSrc = _stageAgents(path.join(src, 'agents'));
10031
9193
  const agentsDest = path.join(targetDir, 'agents');
10032
9194
 
9195
+ // ADR-1235 §1: runtimes that have been migrated to the descriptor-driven agent
9196
+ // path (installRuntimeArtifacts → convertedAgentsKind). The descriptor path
9197
+ // applies path-rewrite + attribution + converter + normalize via
9198
+ // stageAgentsForRuntimeWithConverter (with agentCtx pre-converter threading) in
9199
+ // createRuntimeArtifactInstallPlan. Their agents are already written ABOVE
9200
+ // (by installRuntimeArtifacts at line 8912), which also performs its own
9201
+ // stale-file prune pass. The inline stale-removal + inline loop both skip them.
9202
+ // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
9203
+ // cline is excluded: it takes a rules-only local branch and has a local/global
9204
+ // complication that the descriptor-driven path does not handle correctly.
9205
+ const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy']);
9206
+
10033
9207
  // Always remove stale gsd-* agents first so re-installing with
10034
9208
  // `--minimal` actually shrinks a previously-full install.
10035
9209
  // For Codex this also covers per-agent `.toml` files alongside the `.md`
10036
9210
  // sources so a full → minimal switch doesn't leave stale registrations.
10037
- if (fs.existsSync(agentsDest)) {
9211
+ // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes).
9212
+ if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && fs.existsSync(agentsDest)) {
10038
9213
  for (const file of fs.readdirSync(agentsDest)) {
10039
9214
  if (
10040
9215
  file.startsWith('gsd-') &&
@@ -10047,6 +9222,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10047
9222
 
10048
9223
  if (isKimi) {
10049
9224
  console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
9225
+ } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
9226
+ // installRuntimeArtifacts already wrote agents + handles stale-file cleanup
9227
+ // via its own prune pass. No further action needed.
9228
+ console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
10050
9229
  } else if (isMinimalMode(_effectiveInstallMode)) {
10051
9230
  // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
10052
9231
  // Without stripping them here, a full → minimal reinstall would leave the
@@ -11217,14 +10396,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11217
10396
  * Apply statusline config, then print completion message
11218
10397
  */
11219
10398
  function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
11220
- const isOpencode = runtime === 'opencode';
11221
- const isKilo = runtime === 'kilo';
11222
- const isCodex = runtime === 'codex';
11223
- const isCopilot = runtime === 'copilot';
11224
- const isCursor = runtime === 'cursor';
11225
- const isWindsurf = runtime === 'windsurf';
11226
- const isTrae = runtime === 'trae';
11227
- const isCline = runtime === 'cline';
10399
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
11228
10400
  const plan = resolveInstallPlan(runtime);
11229
10401
 
11230
10402
  if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
@@ -11347,37 +10519,11 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11347
10519
  }
11348
10520
  }
11349
10521
 
11350
- let program = 'Claude Code';
11351
- if (runtime === 'opencode') program = 'OpenCode';
11352
- if (runtime === 'gemini') program = 'Gemini';
11353
- if (runtime === 'kilo') program = 'Kilo';
11354
- if (runtime === 'codex') program = 'Codex';
11355
- if (runtime === 'copilot') program = 'Copilot';
11356
- if (runtime === 'antigravity') program = 'Antigravity';
11357
- if (runtime === 'cursor') program = 'Cursor';
11358
- if (runtime === 'windsurf') program = 'Windsurf';
11359
- if (runtime === 'augment') program = 'Augment';
11360
- if (runtime === 'trae') program = 'Trae';
11361
- if (runtime === 'cline') program = 'Cline';
11362
- if (runtime === 'qwen') program = 'Qwen Code';
11363
- if (runtime === 'hermes') program = 'Hermes Agent';
11364
- if (runtime === 'kimi') program = 'Kimi CLI';
11365
-
11366
- let command = '/gsd-new-project';
11367
- if (runtime === 'opencode') command = '/gsd-new-project';
11368
- if (runtime === 'kilo') command = '/gsd-new-project';
11369
- if (runtime === 'gemini') command = '/gsd:new-project';
11370
- if (runtime === 'codex') command = '$gsd-new-project';
11371
- if (runtime === 'copilot') command = '/gsd-new-project';
11372
- if (runtime === 'antigravity') command = '/gsd-new-project';
11373
- if (runtime === 'cursor') command = 'gsd-new-project (mention the skill name)';
11374
- if (runtime === 'windsurf') command = '/gsd-new-project';
11375
- if (runtime === 'augment') command = '/gsd-new-project';
11376
- if (runtime === 'trae') command = '/gsd-new-project';
11377
- if (runtime === 'cline') command = '/gsd-new-project';
11378
- if (runtime === 'qwen') command = '/gsd-new-project';
11379
- if (runtime === 'hermes') command = '/gsd-new-project';
11380
- if (runtime === 'kimi') command = '/skill:gsd-new-project';
10522
+ // program + command are now single-source lookups (ADR-1239 Phase B / #1679):
10523
+ // program is the runtime display label; command is the per-host /gsd-new-project
10524
+ // invocation syntax.
10525
+ const program = getRuntimeLabel(runtime);
10526
+ const command = getRuntimeNewProjectCommand(runtime);
11381
10527
 
11382
10528
  // Claude Code global installs use the skills/ format (CC 2.1.88+).
11383
10529
  // Restart is required for CC to pick up newly-installed skills, and the
@@ -12404,6 +11550,8 @@ module.exports = {
12404
11550
  // runtimeArtifactConversion spread (#1559).
12405
11551
  processAttribution,
12406
11552
  applyRuntimeContentRewritesForCommandsInPlace,
11553
+ _copyStaged,
11554
+ copyWithPathReplacement,
12407
11555
  };
12408
11556
 
12409
11557
  // Main logic — only run when not loaded as a module for testing