@opengsd/gsd-core 1.6.1 → 1.7.0-rc.2

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 (119) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +5 -2
  27. package/bin/gsd-mcp-server.js +31 -0
  28. package/bin/install.js +411 -1146
  29. package/commands/gsd/review.md +6 -0
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +134 -8
  32. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  33. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  35. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  36. package/gsd-core/bin/lib/capability-loader.cjs +45 -9
  37. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  38. package/gsd-core/bin/lib/capability-registry.cjs +891 -82
  39. package/gsd-core/bin/lib/capability-source.cjs +26 -11
  40. package/gsd-core/bin/lib/capability-validator.cjs +222 -2
  41. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  43. package/gsd-core/bin/lib/commands.cjs +2 -1
  44. package/gsd-core/bin/lib/config.cjs +27 -0
  45. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  46. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  47. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  48. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  49. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  50. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  51. package/gsd-core/bin/lib/host-integration.cjs +469 -0
  52. package/gsd-core/bin/lib/init.cjs +35 -7
  53. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  54. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  55. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  56. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  57. package/gsd-core/bin/lib/milestone.cjs +68 -40
  58. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  59. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  60. package/gsd-core/bin/lib/phase.cjs +57 -90
  61. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  62. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  63. package/gsd-core/bin/lib/probe-core.cjs +132 -2
  64. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  68. package/gsd-core/bin/lib/roadmap.cjs +33 -22
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  70. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  71. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  73. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
  75. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  76. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  77. package/gsd-core/bin/lib/state-io.cjs +55 -0
  78. package/gsd-core/bin/lib/state-transition.cjs +1603 -0
  79. package/gsd-core/bin/lib/state.cjs +327 -683
  80. package/gsd-core/bin/lib/surface.cjs +4 -1
  81. package/gsd-core/bin/lib/validate.cjs +2 -1
  82. package/gsd-core/bin/lib/verify.cjs +6 -4
  83. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  84. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  85. package/gsd-core/bin/lib/workstream.cjs +4 -4
  86. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  87. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  88. package/gsd-core/references/honest-verifier.md +105 -0
  89. package/gsd-core/references/model-profiles.md +27 -0
  90. package/gsd-core/references/reviewer-instances.md +99 -0
  91. package/gsd-core/workflows/autonomous.md +30 -32
  92. package/gsd-core/workflows/complete-milestone.md +6 -10
  93. package/gsd-core/workflows/execute-phase.md +1 -1
  94. package/gsd-core/workflows/forensics.md +3 -3
  95. package/gsd-core/workflows/help/modes/full.md +1 -1
  96. package/gsd-core/workflows/manager.md +15 -15
  97. package/gsd-core/workflows/milestone-summary.md +3 -3
  98. package/gsd-core/workflows/new-milestone.md +6 -0
  99. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  100. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  101. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  102. package/gsd-core/workflows/plan-phase.md +4 -159
  103. package/gsd-core/workflows/review.md +33 -2
  104. package/gsd-core/workflows/thread.md +4 -4
  105. package/gsd-core/workflows/verify-phase.md +11 -4
  106. package/gsd-core/workflows/verify-work.md +1 -2
  107. package/hooks/dist/gsd-graphify-update.sh +7 -1
  108. package/hooks/gsd-graphify-update.sh +7 -1
  109. package/package.json +6 -4
  110. package/scripts/ci-test-scope.cjs +38 -9
  111. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  112. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  113. package/scripts/lint-test-file-count.allowlist.json +19 -5
  114. package/scripts/mutation-matrix.cjs +45 -3
  115. package/scripts/prompt-injection-scan.sh +8 -0
  116. package/scripts/run-tests.cjs +51 -1
  117. package/scripts/sync-manifest-versions.cjs +66 -14
  118. package/skills/gsd-review/SKILL.md +6 -0
  119. 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'));
@@ -373,8 +374,34 @@ const {
373
374
  } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'legacy-cleanup.cjs'));
374
375
  const {
375
376
  updateCacheFileName,
377
+ PACKAGE_NAME,
376
378
  } = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
377
379
 
380
+ // ADR-1239 Phase B: runtime-artifact install cluster extracted to install-engine.cjs.
381
+ // getCommitAttribution STAYS here (impure install-time config I/O); it is injected
382
+ // into the engine functions via the resolveAttribution parameter at each call site.
383
+ const installEngine = require(path.join(_gsdLibDir, 'install-engine.cjs'));
384
+ const {
385
+ installRuntimeArtifacts,
386
+ uninstallRuntimeArtifacts,
387
+ installOpencodeFamilySkills,
388
+ _copyStaged,
389
+ hasExistingSymlinkBetween,
390
+ preserveUserArtifacts,
391
+ restoreUserArtifacts,
392
+ migrateLegacyDevPreferencesToSkill,
393
+ applyOpencodeFamilyPathPrefix,
394
+ convertClaudeCommandToOpencodeSkill,
395
+ convertClaudeCommandToKiloSkill,
396
+ USER_OWNED_ARTIFACTS,
397
+ _runLegacyInstallMigrations,
398
+ _runLegacyUninstallCleanup,
399
+ _removeGsdEntries,
400
+ _snapshotDir,
401
+ _restoreDir,
402
+ _removeHermesBareStemDirs,
403
+ } = installEngine;
404
+
378
405
  // Parse args
379
406
  const args = process.argv.slice(2);
380
407
  const hasGlobal = args.includes('--global') || args.includes('-g');
@@ -484,18 +511,11 @@ function getConfigDirFromHome(runtime, isGlobal) {
484
511
  // Local installs use the same dir name pattern
485
512
  return `'${getDirName(runtime)}'`;
486
513
  }
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'";
514
+ // Global installs. antigravity's home is resolved dynamically (env-overridable,
515
+ // multi-segment via resolveAntigravityGlobalDir + path.relative) — not a table
516
+ // entry. (The prior inner `if (!isGlobal) return "'.agents'"` was unreachable:
517
+ // !isGlobal returns at the top of this function.)
497
518
  if (runtime === 'antigravity') {
498
- if (!isGlobal) return "'.agents'";
499
519
  const antigravityDir = resolveAntigravityGlobalDir();
500
520
  const rel = path.relative(os.homedir(), antigravityDir);
501
521
  const segments = rel.split(path.sep).filter(Boolean);
@@ -506,16 +526,9 @@ function getConfigDirFromHome(runtime, isGlobal) {
506
526
  // stable legacy template so generated path.join() calls remain valid.
507
527
  return "'.gemini', 'antigravity'";
508
528
  }
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'";
529
+ // All other runtimes: single source-of-truth fragment table (ADR-1239 Phase B,
530
+ // #1679). claude/unknown fall through to the table's default '.claude'.
531
+ return getGlobalConfigHomeFragment(runtime);
519
532
  }
520
533
 
521
534
  /**
@@ -630,6 +643,15 @@ const processAttribution = runtimeArtifactConversion.processAttribution;
630
643
  const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
631
644
  const applyRuntimeContentRewritesInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesInPlace;
632
645
  const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace;
646
+ // #1675 (ADR-1508): the augment converter family is single-sourced in the
647
+ // conversion module. install.js re-binds (does not re-define) these so there
648
+ // is exactly one body — the generative-drift hazard the dedup removes. The two
649
+ // private helpers (getAugmentSkillAdapterHeader, convertSlashCommandsToAugmentSkillMentions)
650
+ // live only in the conversion module now; they are no longer duplicated here.
651
+ // (All call sites are below this line → no TDZ hazard.)
652
+ const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
653
+ const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
654
+ const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
633
655
 
634
656
  function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
635
657
  return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
@@ -2580,105 +2602,14 @@ const claudeToAugmentTools = {
2580
2602
  TodoWrite: 'add_tasks',
2581
2603
  };
2582
2604
 
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
- */
2605
+ // #1675 (ADR-1508): the augment converter family below was a byte-identical
2606
+ // duplicate of runtime-artifact-conversion.cjs:
2607
+ // convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown,
2608
+ // getAugmentSkillAdapterHeader, convertClaudeCommandToAugmentSkill,
2609
+ // convertClaudeAgentToAugmentAgent
2610
+ // Deleted here and bound from runtimeArtifactConversion above (single source).
2611
+ // The DEFECT.GENERATIVE-FIX parity guard in
2612
+ // tests/enh-1511-rewrite-engine-relocation.test.cjs asserts reference identity.
2682
2613
 
2683
2614
  function convertSlashCommandsToTraeSkillMentions(content) {
2684
2615
  return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
@@ -5710,8 +5641,21 @@ function writeCopilotHookConfig(targetDir) {
5710
5641
  * Reads agent .md files from source, extracts metadata, writes .toml configs.
5711
5642
  */
5712
5643
  function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-sandbox') {
5713
- const configPath = path.join(targetDir, 'config.toml');
5714
- const agentsTomlDir = path.join(targetDir, 'agents');
5644
+ // ADR-1239 Phase B write-confinement: every Codex config write stays under targetDir.
5645
+ const configPath = assertDestWithinConfigHome(targetDir, 'config.toml');
5646
+ const agentsTomlDir = assertDestWithinConfigHome(targetDir, 'agents');
5647
+ const resolvedTargetRoot = path.resolve(targetDir);
5648
+ // Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the
5649
+ // lexical gate above does not resolve symlinks, so a pre-existing config.toml or
5650
+ // agents/ symlink could redirect writes outside targetDir. Reject those.
5651
+ if (
5652
+ hasExistingSymlinkBetween(resolvedTargetRoot, configPath) ||
5653
+ hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir))
5654
+ ) {
5655
+ throw new Error(
5656
+ `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink escaping the install root — refusing to write`,
5657
+ );
5658
+ }
5715
5659
  fs.mkdirSync(agentsTomlDir, { recursive: true });
5716
5660
 
5717
5661
  const agentEntries = fs.readdirSync(agentsSrc).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
@@ -5756,7 +5700,16 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san
5756
5700
  // follows the same config-driven precedence as the Claude .md effort key.
5757
5701
  const effortCfg = readGsdEffectiveEffortConfig(targetDir);
5758
5702
  const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver, effortCfg, sandboxTier);
5759
- fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent);
5703
+ // Confine the per-agent write to the agents/ dir itself: a crafted agent
5704
+ // `name` containing path separators must not escape agents/ (which would let
5705
+ // it clobber config.toml or write elsewhere under the configHome).
5706
+ const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`);
5707
+ if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath)) {
5708
+ throw new Error(
5709
+ `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink escaping the install root — refusing to write`,
5710
+ );
5711
+ }
5712
+ fs.writeFileSync(agentTomlPath, tomlContent);
5760
5713
  }
5761
5714
 
5762
5715
  const gsdBlock = generateCodexConfigBlock(agents, targetDir);
@@ -6326,69 +6279,9 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6326
6279
  return `---\n${newFrontmatter}\n---${body}`;
6327
6280
  }
6328
6281
 
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
- }
6282
+ // convertClaudeCommandToOpencodeFamilySkill, convertClaudeCommandToOpencodeSkill,
6283
+ // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
6284
+ // Imported from installEngine above.
6392
6285
 
6393
6286
  /**
6394
6287
  * Convert Claude Code markdown command to Gemini TOML format
@@ -6471,30 +6364,8 @@ function convertClaudeToGeminiToml(content, { commandName = null } = {}) {
6471
6364
  * @param {string} pathPrefix - Path prefix for file references
6472
6365
  * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
6473
6366
  */
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
- }
6367
+ // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
6368
+ // Imported from installEngine above.
6498
6369
 
6499
6370
  function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
6500
6371
  if (!fs.existsSync(srcDir)) {
@@ -6633,688 +6504,140 @@ function writeHermesCategoryDescription(categoryDir) {
6633
6504
  * @param {boolean} isGlobal - Whether this is a global install
6634
6505
  */
6635
6506
 
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
- }
6507
+ // USER_OWNED_ARTIFACTS, preserveUserArtifacts, restoreUserArtifacts,
6508
+ // migrateLegacyDevPreferencesToSkill, _copyStaged, _removeGsdEntries,
6509
+ // _runLegacyInstallMigrations, _runLegacyUninstallCleanup, _snapshotDir,
6510
+ // _restoreDir, _removeHermesBareStemDirs, installRuntimeArtifacts,
6511
+ // installOpencodeFamilySkills, uninstallRuntimeArtifacts:
6512
+ // ALL moved to src/install-engine.cts (ADR-1239 Phase B).
6513
+ // Imported from installEngine above.
6733
6514
 
6734
6515
  // ---------------------------------------------------------------------------
6735
- // Phase 2 — Layout-driven install/uninstall orchestrators
6516
+ // Phase 2 — Layout-driven install/uninstall orchestrators (moved to engine)
6517
+ // _applyRuntimeRewrites / _stampNonClaudeRuntimeDefaults remain here for
6518
+ // call sites in copyWithPathReplacement (not moved).
6736
6519
  // ---------------------------------------------------------------------------
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
6520
  const _applyRuntimeRewrites = runtimeArtifactConversion._applyRuntimeRewrites;
6796
6521
  const _stampNonClaudeRuntimeDefaults = runtimeArtifactConversion._stampNonClaudeRuntimeDefaults;
6797
6522
 
6798
6523
  /**
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
- }
6524
+ * Data-driven dispatch table for copyWithPathReplacement (ADR-1239 Phase B).
6525
+ * Keyed by runtime id. Each entry declares ONLY what that runtime does differently.
6526
+ * The DEFAULT (no entry, or entry with no md/js key) = identity transform after
6527
+ * the uniform steps — covers claude, augment, codebuddy, kimi, etc.
6528
+ *
6529
+ * Entry shape:
6530
+ * mdSkipGenericRewrite?: boolean — skip the ~/.claude/ rewrite block (copilot, antigravity)
6531
+ * md?: (content, ctx) => string — per-runtime .md transform
6532
+ * mdReattributeAfter?: boolean — re-run processAttribution after md() (copilot, antigravity)
6533
+ * mdTomlRenameOnCommand?: boolean — when isCommand, rename dest .md → .toml (gemini)
6534
+ * js?: (content, ctx) => string — per-runtime .cjs/.js transform (absent = plain copyFileSync)
6535
+ *
6536
+ * ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName, runtime }
6537
+ */
6538
+ const RUNTIME_CONTENT_DISPATCH = {
6539
+ opencode: {
6540
+ md: (content) => convertClaudeToOpencodeFrontmatter(content),
6541
+ },
6542
+ kilo: {
6543
+ md: (content) => convertClaudeToKiloFrontmatter(content),
6544
+ },
6545
+ gemini: {
6546
+ md: (content, ctx) =>
6547
+ convertClaudeToGeminiMarkdown(content, {
6548
+ isCommand: ctx.isCommand,
6549
+ commandName: ctx.isCommand ? ctx.entryName.replace(/\.md$/, '') : null,
6550
+ }),
6551
+ mdTomlRenameOnCommand: true,
6552
+ },
6553
+ codex: {
6554
+ md: (content) => convertClaudeToCodexMarkdown(content),
6555
+ },
6556
+ copilot: {
6557
+ mdSkipGenericRewrite: true,
6558
+ md: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal),
6559
+ mdReattributeAfter: true,
6560
+ js: (content, ctx) => convertClaudeToCopilotContent(content, ctx.isGlobal),
6561
+ },
6562
+ antigravity: {
6563
+ mdSkipGenericRewrite: true,
6564
+ md: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
6565
+ mdReattributeAfter: true,
6566
+ js: (content, ctx) => convertClaudeToAntigravityContent(content, ctx.isGlobal),
6567
+ },
6568
+ cursor: {
6569
+ md: (content) => convertClaudeToCursorMarkdown(content),
6570
+ js: (content) => {
6571
+ content = content.replace(/gsd:/gi, 'gsd-');
6572
+ content = content.replace(/\.claude\/skills\//g, '.cursor/skills/');
6573
+ content = content.replace(/CLAUDE\.md/g, '.cursor/rules/');
6574
+ content = content.replace(/\bClaude Code\b/g, 'Cursor');
6575
+ return content;
6576
+ },
6577
+ },
6578
+ windsurf: {
6579
+ md: (content) => convertClaudeToWindsurfMarkdown(content),
6580
+ js: (content) => {
6581
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
6582
+ content = content.replace(/gsd:/gi, 'gsd-');
6583
+ content = content.replace(/\.claude\/skills\//g, '.devin/skills/');
6584
+ content = content.replace(/CLAUDE\.md/g, '.devin/rules');
6585
+ content = content.replace(/\bClaude Code\b/g, 'Windsurf');
6586
+ return content;
6587
+ },
6588
+ },
6589
+ trae: {
6590
+ md: (content) => convertClaudeToTraeMarkdown(content),
6591
+ js: (content) => {
6592
+ content = content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
6593
+ return `/gsd-${commandName}`;
6594
+ });
6595
+ content = content.replace(/\.claude\/skills\//g, '.trae/skills/');
6596
+ content = content.replace(/CLAUDE\.md/g, '.trae/rules/');
6597
+ content = content.replace(/\bClaude Code\b/g, 'Trae');
6598
+ return content;
6599
+ },
6600
+ },
6601
+ cline: {
6602
+ md: (content) => convertClaudeToCliineMarkdown(content),
6603
+ js: (content) => {
6604
+ content = content.replace(/\.claude\/skills\//g, '.cline/skills/');
6605
+ content = content.replace(/CLAUDE\.md/g, '.clinerules');
6606
+ content = content.replace(/\bClaude Code\b/g, 'Cline');
6607
+ return content;
6608
+ },
6609
+ },
6610
+ qwen: {
6611
+ md: (content) => {
6612
+ content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6613
+ content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
6614
+ content = content.replace(/\.claude\//g, '.qwen/');
6615
+ return content;
6616
+ },
6617
+ js: (content) => {
6618
+ content = content.replace(/\.claude\/skills\//g, '.qwen/skills/');
6619
+ content = content.replace(/\.claude\//g, '.qwen/');
6620
+ content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6621
+ content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
6622
+ return content;
6623
+ },
6624
+ },
6625
+ hermes: {
6626
+ md: (content) => {
6627
+ content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6628
+ content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
6629
+ content = content.replace(/\.claude\//g, '.hermes/');
6630
+ return content;
6631
+ },
6632
+ js: (content) => {
6633
+ content = content.replace(/\.claude\/skills\//g, '.hermes/skills/');
6634
+ content = content.replace(/\.claude\//g, '.hermes/');
6635
+ content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6636
+ content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
6637
+ return content;
6638
+ },
6639
+ },
6640
+ };
7318
6641
 
7319
6642
  /**
7320
6643
  * Recursively copy directory, replacing paths in .md files
@@ -7326,22 +6649,28 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
7326
6649
  * @param {boolean} isCommand - Whether the source is a command directory
7327
6650
  * @param {boolean} isGlobal - Whether the install is global
7328
6651
  */
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';
6652
+ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand = false, isGlobal = false, confinementRoot) {
7343
6653
  const dirName = getDirName(runtime);
7344
6654
 
6655
+ // ADR-1239 Phase B write-confinement: refuse to wipe/write a destDir that
6656
+ // escapes the caller-declared install root. Runs BEFORE the rmSync below so a
6657
+ // crafted destDir can never delete or write outside confinementRoot.
6658
+ if (confinementRoot === undefined) {
6659
+ throw new Error(
6660
+ 'copyWithPathReplacement: confinementRoot is required to confine writes to the install root — refusing to write',
6661
+ );
6662
+ }
6663
+ const resolvedConfinementRoot = path.resolve(confinementRoot);
6664
+ const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir);
6665
+ if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir)) {
6666
+ throw new Error(
6667
+ `copyWithPathReplacement: destDir "${destDir}" contains a symlink escaping the install root "${confinementRoot}" — refusing to write`,
6668
+ );
6669
+ }
6670
+ // Use the validated absolute path for all writes below so the gate validates
6671
+ // exactly what is written (a relative destDir would otherwise resolve to cwd).
6672
+ destDir = resolvedDestDir;
6673
+
7345
6674
  // Clean install: remove existing destination to prevent orphaned files
7346
6675
  if (fs.existsSync(destDir)) {
7347
6676
  fs.rmSync(destDir, { recursive: true });
@@ -7355,12 +6684,15 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7355
6684
  const destPath = path.join(destDir, entry.name);
7356
6685
 
7357
6686
  if (entry.isDirectory()) {
7358
- copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal);
6687
+ copyWithPathReplacement(srcPath, destPath, pathPrefix, runtime, isCommand, isGlobal, confinementRoot);
7359
6688
  } else if (entry.name.endsWith('.md')) {
6689
+ const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
6690
+ const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
6691
+
7360
6692
  // Replace ~/.claude/ and $HOME/.claude/ and ./.claude/ with runtime-appropriate paths
7361
- // Skip generic replacement for Copilot — convertClaudeToCopilotContent handles all paths
6693
+ // Skip generic replacement for Copilot/Antigravity — their converters handle all paths
7362
6694
  let content = fs.readFileSync(srcPath, 'utf8');
7363
- if (!isCopilot && !isAntigravity) {
6695
+ if (!dispatch.mdSkipGenericRewrite) {
7364
6696
  const globalClaudeRegex = /~\/\.claude\//g;
7365
6697
  const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
7366
6698
  const localClaudeRegex = /\.\/\.claude\//g;
@@ -7395,112 +6727,25 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7395
6727
  // colon-canonical runtimes (Gemini).
7396
6728
  content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
7397
6729
 
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/');
6730
+ // Apply per-runtime .md converter (if any)
6731
+ if (dispatch.md) content = dispatch.md(content, ctx);
6732
+
6733
+ // Re-run attribution after converter for runtimes that need it (copilot, antigravity)
6734
+ if (dispatch.mdReattributeAfter) content = processAttribution(content, getCommitAttribution(runtime));
6735
+
6736
+ // Gemini: rename .md → .toml for command files
6737
+ const finalPath = (dispatch.mdTomlRenameOnCommand && isCommand) ? destPath.replace(/\.md$/, '.toml') : destPath;
6738
+ fs.writeFileSync(finalPath, content);
6739
+ } else if (entry.name.endsWith('.cjs') || entry.name.endsWith('.js')) {
6740
+ const dispatch = RUNTIME_CONTENT_DISPATCH[runtime] || {};
6741
+ if (dispatch.js) {
6742
+ const ctx = { isCommand, isGlobal, dirName, pathPrefix, entryName: entry.name, runtime };
6743
+ let content = fs.readFileSync(srcPath, 'utf8');
6744
+ content = dispatch.js(content, ctx);
7444
6745
  fs.writeFileSync(destPath, content);
7445
6746
  } else {
7446
- fs.writeFileSync(destPath, content);
6747
+ fs.copyFileSync(srcPath, destPath);
7447
6748
  }
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
6749
  } else {
7505
6750
  fs.copyFileSync(srcPath, destPath);
7506
6751
  }
@@ -7674,19 +6919,7 @@ const GSD_UNINSTALL_HOOKS = [
7674
6919
  * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini', 'codex', 'copilot')
7675
6920
  */
7676
6921
  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';
6922
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
7690
6923
  const dirName = getDirName(runtime);
7691
6924
 
7692
6925
  // Get the target directory based on runtime and install type. Cline local
@@ -7702,21 +6935,9 @@ function uninstall(isGlobal, runtime = 'claude') {
7702
6935
  ? targetDir.replace(os.homedir(), '~')
7703
6936
  : targetDir.replace(process.cwd(), '.');
7704
6937
 
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';
6938
+ // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
6939
+ // Phase B / #1679) — collapses the prior 15-line assignment chain.
6940
+ const runtimeLabel = getRuntimeLabel(runtime);
7720
6941
 
7721
6942
  console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
7722
6943
 
@@ -8096,6 +7317,22 @@ function uninstall(isGlobal, runtime = 'claude') {
8096
7317
  }
8097
7318
  }
8098
7319
 
7320
+ // 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
7321
+ // plugin file is removed; the plugins/ dir is pruned only if it becomes
7322
+ // empty, preserving any user-authored OpenCode plugins.
7323
+ if (isOpencode) {
7324
+ const pluginsDir = path.join(targetDir, 'plugins');
7325
+ const pluginPath = path.join(pluginsDir, 'gsd-core.js');
7326
+ if (fs.existsSync(pluginPath)) {
7327
+ try {
7328
+ fs.unlinkSync(pluginPath);
7329
+ removedCount++;
7330
+ console.log(` ${green}✓${reset} Removed OpenCode plugin`);
7331
+ } catch (_) { /* best-effort */ }
7332
+ try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
7333
+ }
7334
+ }
7335
+
8099
7336
  // 4a. Remove scripts/changeset/ and scripts/lib/ (#935)
8100
7337
  // GSD-managed files only: enumerate the exact set the installer writes.
8101
7338
  // Any file NOT in this set is user-owned and must survive uninstall.
@@ -8140,6 +7377,11 @@ function uninstall(isGlobal, runtime = 'claude') {
8140
7377
  const fixSlashUninstallPath = path.join(targetDir, 'scripts', 'fix-slash-commands.cjs');
8141
7378
  try { fs.unlinkSync(fixSlashUninstallPath); } catch (_) { /* best-effort */ }
8142
7379
 
7380
+ // Remove the capability registry generator scripts (#1920) — before the scripts/ rmdir
7381
+ for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
7382
+ try { fs.unlinkSync(path.join(targetDir, 'scripts', gen)); } catch (_) { /* best-effort */ }
7383
+ }
7384
+
8143
7385
  // If scripts/ dir is now empty, remove it too
8144
7386
  const scriptsUninstallDir = path.join(targetDir, 'scripts');
8145
7387
  if (fs.existsSync(scriptsUninstallDir)) {
@@ -8482,7 +7724,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
8482
7724
  modified = true;
8483
7725
  }
8484
7726
 
8485
- // Configure external_directory permission (the safety guard for paths outside project)
7727
+ // Configure external_directory permission (the safety guard for paths outside)
8486
7728
  if (!config.permission.external_directory || typeof config.permission.external_directory !== 'object') {
8487
7729
  config.permission.external_directory = {};
8488
7730
  }
@@ -8491,6 +7733,25 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
8491
7733
  modified = true;
8492
7734
  }
8493
7735
 
7736
+ // ADR-1239 Phase D / #1682 — register the companion MCP server (Phase 4) so
7737
+ // OpenCode connects to GSD's command (point 1) + state-IO (point 5) surface
7738
+ // with NO bespoke plugin. Idempotent + non-clobbering: only added when
7739
+ // `mcp.gsd` is absent (a user-defined `mcp.gsd` is respected — Hyrum's Law).
7740
+ // Local-stdio schema per OpenCode config (packages/core/src/config/mcp.ts).
7741
+ // `-p @opengsd/gsd-core` resolves the `gsd-mcp-server` bin from this package
7742
+ // (bin name != package name) regardless of global-install state.
7743
+ if (!config.mcp || typeof config.mcp !== 'object') {
7744
+ config.mcp = {};
7745
+ }
7746
+ if (config.mcp.gsd === undefined) {
7747
+ config.mcp.gsd = {
7748
+ type: 'local',
7749
+ command: ['npx', '-y', '-p', PACKAGE_NAME, 'gsd-mcp-server'],
7750
+ enabled: true,
7751
+ };
7752
+ modified = true;
7753
+ }
7754
+
8494
7755
  if (!modified) {
8495
7756
  return; // Already configured
8496
7757
  }
@@ -8676,45 +7937,14 @@ function resolveInstallRelativePath(baseDir, relPath) {
8676
7937
  return { relPath: normalized, fullPath };
8677
7938
  }
8678
7939
 
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
- }
7940
+ // hasExistingSymlinkBetween: moved to src/install-engine.cts (ADR-1239 Phase B).
7941
+ // Imported from installEngine above.
8701
7942
 
8702
7943
  /**
8703
7944
  * Write file manifest after installation for future modification detection
8704
7945
  */
8705
7946
  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';
7947
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
8718
7948
  const gsdDir = path.join(configDir, 'gsd-core');
8719
7949
  // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
8720
7950
  // commandsDir points to the old location for Gemini (which still uses commands/gsd/).
@@ -8875,6 +8105,24 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
8875
8105
  manifest.files['scripts/fix-slash-commands.cjs'] = fileHash(fixSlashInstallPath);
8876
8106
  }
8877
8107
 
8108
+ // Track the capability registry generator scripts (#1920) — top-level scripts/ files
8109
+ // not covered by the changeset/lib loops.
8110
+ for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
8111
+ const genInstallPath = path.join(configDir, 'scripts', gen);
8112
+ if (fs.existsSync(genInstallPath)) {
8113
+ manifest.files['scripts/' + gen] = fileHash(genInstallPath);
8114
+ }
8115
+ }
8116
+
8117
+ // Track the OpenCode native plugin adapter (#1914) so update/drift detection
8118
+ // and uninstall can account for it.
8119
+ if (isOpencode) {
8120
+ const pluginInstallPath = path.join(configDir, 'plugins', 'gsd-core.js');
8121
+ if (fs.existsSync(pluginInstallPath)) {
8122
+ manifest.files['plugins/gsd-core.js'] = fileHash(pluginInstallPath);
8123
+ }
8124
+ }
8125
+
8878
8126
  fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
8879
8127
  return manifest;
8880
8128
  }
@@ -8943,7 +8191,7 @@ function populatePristineDir({ packageSrc, pristineDir, modified, runtime, pathP
8943
8191
  const srcDir = path.join(packageSrc, top);
8944
8192
  const stageDir = path.join(stageRoot, top);
8945
8193
  if (!fs.existsSync(srcDir)) continue;
8946
- copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal);
8194
+ copyWithPathReplacement(srcDir, stageDir, pathPrefix, runtime, false, isGlobal, stageRoot);
8947
8195
  }
8948
8196
 
8949
8197
  for (const relPath of safeModified) {
@@ -9201,21 +8449,7 @@ function reportInstallerMigrationResult(result) {
9201
8449
  }
9202
8450
 
9203
8451
  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';
8452
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
9219
8453
  const plan = resolveInstallPlan(runtime);
9220
8454
  const dirName = getDirName(runtime);
9221
8455
  const src = path.join(__dirname, '..');
@@ -9361,22 +8595,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9361
8595
  homeDir,
9362
8596
  });
9363
8597
 
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';
8598
+ // runtimeLabel is now the single-source getRuntimeLabel lookup (ADR-1239
8599
+ // Phase B / #1679) — collapses the prior 16-line assignment chain.
8600
+ const runtimeLabel = getRuntimeLabel(runtime);
9380
8601
 
9381
8602
  console.log(` Installing for ${cyan}${runtimeLabel}${reset} to ${cyan}${locationLabel}${reset}\n`);
9382
8603
 
@@ -9671,7 +8892,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9671
8892
  if (_isSkillsRuntime) {
9672
8893
  // Layout-driven install for skills-based runtimes (full and minimal modes)
9673
8894
  const scope = isGlobal ? 'global' : 'local';
9674
- installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
8895
+ installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
9675
8896
 
9676
8897
  // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
9677
8898
  // gsd-* skill dirs. Prior installs wrote these files so Codex would show a
@@ -9831,12 +9052,45 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9831
9052
  // Kilo support native, on-demand skills in addition to flat commands — see
9832
9053
  // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
9833
9054
  // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
9834
- const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix);
9055
+ const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution);
9835
9056
  if (_skillCount > 0) {
9836
9057
  console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
9837
9058
  } else {
9838
9059
  failures.push('skills/gsd-*');
9839
9060
  }
9061
+
9062
+ // OpenCode-only: install the native plugin adapter (#1914). OpenCode
9063
+ // declares hooksSurface: 'none', so GSD's lifecycle hooks are never
9064
+ // registered as settings.json hooks the way Claude Code does — the hook
9065
+ // *scripts* ship to <configDir>/hooks/ but nothing invokes them. This
9066
+ // plugin bridges OpenCode's event bus onto those existing hook scripts
9067
+ // (prompt guard, read guard, injection scanner, context monitor, ...),
9068
+ // spawning them as subprocesses. OpenCode auto-discovers plugin files under
9069
+ // <configDir>/plugins/ at startup — no opencode.json registration needed
9070
+ // (its `plugin` array is for npm packages, not local file paths).
9071
+ //
9072
+ // The file MUST land as `.js`: OpenCode's loader globs
9073
+ // `{plugin,plugins}/*.{ts,js}` (verified against its source) — a `.cjs`
9074
+ // extension would never be discovered. The config dir carries a
9075
+ // `{"type":"commonjs"}` package.json (written above), so the `.js` file is
9076
+ // interpreted as CommonJS, matching the adapter's module.exports/require.
9077
+ // Kilo has no plugin surface, so this is gated to OpenCode only.
9078
+ if (isOpencode) {
9079
+ const pluginSrc = path.join(src, '.opencode', 'plugins', 'gsd-core.js');
9080
+ const pluginDestDir = path.join(targetDir, 'plugins');
9081
+ const pluginDest = path.join(pluginDestDir, 'gsd-core.js');
9082
+ if (fs.existsSync(pluginSrc)) {
9083
+ fs.mkdirSync(pluginDestDir, { recursive: true });
9084
+ fs.copyFileSync(pluginSrc, pluginDest);
9085
+ if (fs.existsSync(pluginDest)) {
9086
+ console.log(` ${green}✓${reset} Installed OpenCode plugin (bridges GSD hooks)`);
9087
+ } else {
9088
+ failures.push('plugins/gsd-core.js');
9089
+ }
9090
+ } else {
9091
+ failures.push('plugins/gsd-core.js');
9092
+ }
9093
+ }
9840
9094
  } else if (isCline) {
9841
9095
  // Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
9842
9096
  // No skills/commands directory needed for local installs.
@@ -9886,7 +9140,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9886
9140
  fs.mkdirSync(commandsDir, { recursive: true });
9887
9141
  const gsdSrc = _stageSkills(_commandsDir);
9888
9142
  const gsdDest = path.join(commandsDir, 'gsd');
9889
- copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
9143
+ copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal, targetDir);
9890
9144
  if (verifyInstalled(gsdDest, 'commands/gsd')) {
9891
9145
  console.log(` ${green}✓${reset} Installed commands/gsd`);
9892
9146
  } else {
@@ -9968,7 +9222,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9968
9222
  const skillSrc = path.join(src, 'gsd-core');
9969
9223
  const skillDest = path.join(targetDir, 'gsd-core');
9970
9224
  const savedGsdArtifacts = preserveUserArtifacts(skillDest, USER_OWNED_ARTIFACTS);
9971
- copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal);
9225
+ copyWithPathReplacement(skillSrc, skillDest, pathPrefix, runtime, false, isGlobal, targetDir);
9972
9226
  restoreUserArtifacts(skillDest, savedGsdArtifacts);
9973
9227
  if (verifyInstalled(skillDest, 'gsd-core')) {
9974
9228
  console.log(` ${green}✓${reset} Installed workflow assets`);
@@ -9988,7 +9242,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9988
9242
  const commandsSrc = path.join(src, 'commands', 'gsd');
9989
9243
  const commandsDest = path.join(skillDest, 'commands', 'gsd');
9990
9244
  if (fs.existsSync(commandsSrc)) {
9991
- copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal);
9245
+ copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal, targetDir);
9992
9246
  console.log(` ${green}✓${reset} Installed command bodies to gsd-core/commands/gsd/ (workflow delegation targets)`);
9993
9247
  }
9994
9248
  }
@@ -10030,11 +9284,24 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10030
9284
  agentsSrc = _stageAgents(path.join(src, 'agents'));
10031
9285
  const agentsDest = path.join(targetDir, 'agents');
10032
9286
 
9287
+ // ADR-1235 §1: runtimes that have been migrated to the descriptor-driven agent
9288
+ // path (installRuntimeArtifacts → convertedAgentsKind). The descriptor path
9289
+ // applies path-rewrite + attribution + converter + normalize via
9290
+ // stageAgentsForRuntimeWithConverter (with agentCtx pre-converter threading) in
9291
+ // createRuntimeArtifactInstallPlan. Their agents are already written ABOVE
9292
+ // (by installRuntimeArtifacts at line 8912), which also performs its own
9293
+ // stale-file prune pass. The inline stale-removal + inline loop both skip them.
9294
+ // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
9295
+ // cline is excluded: it takes a rules-only local branch and has a local/global
9296
+ // complication that the descriptor-driven path does not handle correctly.
9297
+ const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy']);
9298
+
10033
9299
  // Always remove stale gsd-* agents first so re-installing with
10034
9300
  // `--minimal` actually shrinks a previously-full install.
10035
9301
  // For Codex this also covers per-agent `.toml` files alongside the `.md`
10036
9302
  // sources so a full → minimal switch doesn't leave stale registrations.
10037
- if (fs.existsSync(agentsDest)) {
9303
+ // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes).
9304
+ if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && fs.existsSync(agentsDest)) {
10038
9305
  for (const file of fs.readdirSync(agentsDest)) {
10039
9306
  if (
10040
9307
  file.startsWith('gsd-') &&
@@ -10047,6 +9314,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10047
9314
 
10048
9315
  if (isKimi) {
10049
9316
  console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
9317
+ } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
9318
+ // installRuntimeArtifacts already wrote agents + handles stale-file cleanup
9319
+ // via its own prune pass. No further action needed.
9320
+ console.log(` ${dim}↳${reset} Agents installed via descriptor-driven layout (${runtime})`);
10050
9321
  } else if (isMinimalMode(_effectiveInstallMode)) {
10051
9322
  // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
10052
9323
  // Without stripping them here, a full → minimal reinstall would leave the
@@ -10372,6 +9643,31 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10372
9643
  }
10373
9644
  }
10374
9645
 
9646
+ // Copy scripts/gen-capability-registry.cjs + scripts/gen-loop-host-contract.cjs —
9647
+ // required by gsd-core/bin/lib/capability-loader.cjs at overlay-composition time via
9648
+ // require('../../../scripts/gen-capability-registry.cjs') (which itself requires
9649
+ // gen-loop-host-contract.cjs). Without these, the loader's never-crash invariant
9650
+ // discards EVERY third-party capability overlay and silently falls back to the frozen
9651
+ // first-party registry, so installed capabilities are inert (#1920). Same class of
9652
+ // gap as #1223 (fix-slash-commands.cjs) and copied unconditionally for the same reason:
9653
+ // any runtime that installs gsd-core/ needs the capability system to compose.
9654
+ {
9655
+ const capGenDestDir = path.join(targetDir, 'scripts');
9656
+ fs.mkdirSync(capGenDestDir, { recursive: true });
9657
+ for (const gen of ['gen-capability-registry.cjs', 'gen-loop-host-contract.cjs']) {
9658
+ const genSrc = path.join(src, 'scripts', gen);
9659
+ const genDest = path.join(capGenDestDir, gen);
9660
+ if (!fs.existsSync(genSrc)) {
9661
+ failures.push(`scripts/${gen} (source missing from package — reinstall from npm)`);
9662
+ } else {
9663
+ fs.copyFileSync(genSrc, genDest);
9664
+ if (!verifyFileInstalled(genDest, `scripts/${gen}`)) {
9665
+ failures.push(`scripts/${gen}`);
9666
+ }
9667
+ }
9668
+ }
9669
+ }
9670
+
10375
9671
  // Remove legacy get-shit-done-cc artifacts and stale update caches (#607).
10376
9672
  // cleanupLegacyGsdCc handles both the legacy shared cache and the per-package
10377
9673
  // cache (formerly an inline unlinkSync here). A cleanup failure must never
@@ -11217,14 +10513,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11217
10513
  * Apply statusline config, then print completion message
11218
10514
  */
11219
10515
  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';
10516
+ const { isOpencode, isKilo, isGemini, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
11228
10517
  const plan = resolveInstallPlan(runtime);
11229
10518
 
11230
10519
  if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
@@ -11347,37 +10636,11 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11347
10636
  }
11348
10637
  }
11349
10638
 
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';
10639
+ // program + command are now single-source lookups (ADR-1239 Phase B / #1679):
10640
+ // program is the runtime display label; command is the per-host /gsd-new-project
10641
+ // invocation syntax.
10642
+ const program = getRuntimeLabel(runtime);
10643
+ const command = getRuntimeNewProjectCommand(runtime);
11381
10644
 
11382
10645
  // Claude Code global installs use the skills/ format (CC 2.1.88+).
11383
10646
  // Restart is required for CC to pick up newly-installed skills, and the
@@ -12404,6 +11667,8 @@ module.exports = {
12404
11667
  // runtimeArtifactConversion spread (#1559).
12405
11668
  processAttribution,
12406
11669
  applyRuntimeContentRewritesForCommandsInPlace,
11670
+ _copyStaged,
11671
+ copyWithPathReplacement,
12407
11672
  };
12408
11673
 
12409
11674
  // Main logic — only run when not loaded as a module for testing