@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0-rc.6

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-ui-checker.md +2 -0
  4. package/agents/gsd-ui-researcher.md +1 -0
  5. package/bin/install.js +970 -192
  6. package/gsd-core/bin/lib/capability-registry.cjs +501 -85
  7. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  8. package/gsd-core/bin/lib/host-integration.cjs +33 -8
  9. package/gsd-core/bin/lib/init.cjs +33 -40
  10. package/gsd-core/bin/lib/install-engine.cjs +90 -20
  11. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  12. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  13. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  14. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +235 -38
  15. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -13
  16. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  17. package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
  18. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +501 -12
  19. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  20. package/gsd-core/bin/lib/security.cjs +6 -36
  21. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  22. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  23. package/gsd-core/bin/lib/surface.cjs +10 -6
  24. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  25. package/gsd-core/bin/shared/model-catalog.json +8 -3
  26. package/gsd-core/references/ui-consideration-probe.md +73 -0
  27. package/gsd-core/templates/UI-SPEC.md +25 -0
  28. package/gsd-core/templates/VALIDATION.md +2 -0
  29. package/gsd-core/workflows/audit-milestone.md +7 -4
  30. package/gsd-core/workflows/plan-phase.md +6 -0
  31. package/gsd-core/workflows/settings-advanced.md +7 -4
  32. package/gsd-core/workflows/ui-phase.md +146 -1
  33. package/gsd-core/workflows/validate-phase.md +2 -2
  34. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  35. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  36. package/hooks/dist/managed-hooks-registry.cjs +2 -0
  37. package/hooks/gsd-windsurf-pre-command.js +275 -0
  38. package/hooks/gsd-windsurf-pre-write.js +132 -0
  39. package/hooks/managed-hooks-registry.cjs +2 -0
  40. package/package.json +8 -4
  41. package/pi/gsd.cjs +354 -0
  42. package/scripts/build-hooks.js +3 -0
  43. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  44. package/scripts/gen-registry.cjs +128 -0
  45. package/scripts/lint-test-file-count.allowlist.json +2 -1
  46. package/scripts/registry-schema.cjs +565 -0
  47. package/scripts/validate-registry.cjs +117 -0
  48. package/vscode/browser.js +197 -0
  49. package/vscode/extension.js +383 -0
  50. package/vscode/host-binding.js +113 -0
  51. package/vscode/package.json +96 -0
package/bin/install.js CHANGED
@@ -32,6 +32,7 @@ const {
32
32
  resolveAntigravityGlobalDir,
33
33
  getGlobalConfigDir,
34
34
  getGlobalSkillsBase,
35
+ resolveKimiHooksTomlDir,
35
36
  } = require('../gsd-core/bin/lib/runtime-homes.cjs');
36
37
  // getDirName (runtime -> local config dir name) is relocated out of this
37
38
  // installer to the runtime-name-policy leaf (ADR-1508 / #1510 Phase 1) so the
@@ -58,25 +59,20 @@ const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
58
59
  const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
59
60
 
60
61
  /**
61
- * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
62
- * verbatim (only branding swaps, no namespace conversion), so retired
63
- * `/gsd:<cmd>` colon refs leak into installed agent prose. Sibling fixes
62
+ * #3677 predicate — true when an agent body needs `/gsd:<cmd>` → `/gsd-<cmd>`
63
+ * normalization at install time. Descriptor-driven
64
+ * (capabilities/<runtime>/capability.json -> runtime.hostBehaviors.hyphenNameAgentBody)
65
+ * instead of a hardcoded runtime allow-list (ADR-1239 / #2086). Sibling fixes
64
66
  * #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime
65
67
  * emissions — this is the agent-body surface (#3677).
66
68
  *
67
- * Explicit allow-list rather than deny-list so unknown / future runtimes
68
- * default to "no rewrite" (better to leak than to mangle a runtime whose
69
- * namespace behavior we haven't verified).
70
- */
71
- const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']);
72
-
73
- /**
74
- * #3677 predicate — true when an agent body needs `/gsd:<cmd>` → `/gsd-<cmd>`
75
- * normalization at install time.
69
+ * Unknown / future runtimes that don't declare the flag default to "no
70
+ * rewrite" (better to leak than to mangle a runtime whose namespace
71
+ * behavior we haven't verified).
76
72
  */
77
73
  function shouldNormalizeHyphenNamespaceInAgentBody(runtime) {
78
74
  if (typeof runtime !== 'string' || runtime === '') return false;
79
- return HYPHEN_NAME_AGENT_RUNTIMES.has(runtime);
75
+ return _hostBehaviors(runtime).hyphenNameAgentBody === true;
80
76
  }
81
77
 
82
78
  /**
@@ -282,6 +278,28 @@ const GSD_CURSOR_HOOK_SCRIPTS = [
282
278
  // Marker comment embedded in managed hook entries so GSD can find+remove them.
283
279
  const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
284
280
 
281
+ // #2100 Stage 2 — Windsurf/Cascade lifecycle hook constants.
282
+ // Windsurf/Cascade reads hook configs from <project-root>/.windsurf/hooks.json
283
+ // (local) or ~/.codeium/windsurf/hooks.json (global) with the shape
284
+ // { hooks: { <event>: [ { command, ... } ] } } — note: no top-level `version`
285
+ // field, and each entry carries a bare `command` shell string (no `type`
286
+ // field), unlike Cursor's hooks.json. GSD registers two managed BLOCKING
287
+ // hooks (exit code 2 to block, vs. Cursor's stdout-JSON form):
288
+ // pre_write_code → gsd-windsurf-pre-write.js (write-path guard)
289
+ // pre_run_command → gsd-windsurf-pre-command.js (destructive-command guard)
290
+ // Cascade has no context-injection channel, so the 4 advisory hooks GSD
291
+ // registers on Cursor (sessionStart, postToolUse, stop, subagentStart/Stop)
292
+ // have no Windsurf counterpart and are deliberately NOT ported.
293
+ // Cascade hooks docs (reference): https://docs.windsurf.com/llms-full.txt ,
294
+ // https://docs.devin.ai/desktop/cascade/hooks
295
+ const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
296
+ const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
297
+ // All GSD-managed Windsurf hook scripts (used by uninstall cleanup).
298
+ const GSD_WINDSURF_HOOK_SCRIPTS = [
299
+ GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
300
+ GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
301
+ ];
302
+
285
303
  // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
286
304
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
287
305
  const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
@@ -386,7 +404,16 @@ const FALLBACK_HOST_BEHAVIORS = Object.freeze({
386
404
  settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
387
405
  permissionsSchema: 'claude',
388
406
  sourceMarkerFile: '.gsd-source',
407
+ hyphenNameAgentBody: true,
408
+ legacyCommandsGsdInstallMigration: true,
409
+ legacyCommandsGsdUninstall: 'global',
389
410
  }),
411
+ // antigravity's global config dir is resolved dynamically (env-overridable,
412
+ // multi-segment) via resolveAntigravityGlobalDir in getConfigDirFromHome. If the
413
+ // registry fails to load, this floor keeps that routing intact instead of
414
+ // silently falling through to the generic getGlobalConfigHomeFragment default
415
+ // (which would return the wrong '.claude' fragment). (ADR-1239 / #2096)
416
+ antigravity: Object.freeze({ globalDirResolver: 'antigravity' }),
390
417
  });
391
418
 
392
419
  /**
@@ -480,6 +507,7 @@ const {
480
507
  installRuntimeArtifacts,
481
508
  uninstallRuntimeArtifacts,
482
509
  installOpencodeFamilySkills,
510
+ _installNativePluginIfDeclared,
483
511
  _copyStaged,
484
512
  hasExistingSymlinkBetween,
485
513
  preserveUserArtifacts,
@@ -529,7 +557,7 @@ if (hasMinimal && _profileArgRaw) {
529
557
 
530
558
  function selectRuntimesFromArgs(runtimeArgs) {
531
559
  if (runtimeArgs.includes('--all')) {
532
- return ['claude', 'kimi', 'kilo', 'opencode', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'zcode'];
560
+ return ['claude', 'kimi', 'kilo', 'opencode', 'pi', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'zcode'];
533
561
  }
534
562
  if (runtimeArgs.includes('--both')) {
535
563
  return ['claude', 'opencode'];
@@ -538,6 +566,7 @@ function selectRuntimesFromArgs(runtimeArgs) {
538
566
  const selected = [];
539
567
  if (runtimeArgs.includes('--claude')) selected.push('claude');
540
568
  if (runtimeArgs.includes('--opencode')) selected.push('opencode');
569
+ if (runtimeArgs.includes('--pi')) selected.push('pi');
541
570
  if (runtimeArgs.includes('--kilo')) selected.push('kilo');
542
571
  if (runtimeArgs.includes('--codex')) selected.push('codex');
543
572
  if (runtimeArgs.includes('--copilot')) selected.push('copilot');
@@ -641,7 +670,15 @@ function getConfigDirFromHome(runtime, isGlobal) {
641
670
  // multi-segment via resolveAntigravityGlobalDir + path.relative) — not a table
642
671
  // entry. (The prior inner `if (!isGlobal) return "'.agents'"` was unreachable:
643
672
  // !isGlobal returns at the top of this function.)
644
- if (runtime === 'antigravity') {
673
+ // Descriptor-driven (ADR-1239 / #2096): folded from a hardcoded
674
+ // `runtime === 'antigravity'` literal into a read of the runtime's
675
+ // `hostBehaviors.globalDirResolver` descriptor field (via _hostBehaviors, which
676
+ // also degrades to FALLBACK_HOST_BEHAVIORS on registry-load failure). This is
677
+ // antigravity-unique: unlike `configHome.kind === 'dot-home-nested'` (which
678
+ // windsurf also declares — see capabilities/windsurf/capability.json — and
679
+ // would wrongly route windsurf's global dir through
680
+ // resolveAntigravityGlobalDir), `globalDirResolver` is only set by antigravity.
681
+ if (_hostBehaviors(runtime).globalDirResolver === 'antigravity') {
645
682
  const antigravityDir = resolveAntigravityGlobalDir();
646
683
  const rel = path.relative(os.homedir(), antigravityDir);
647
684
  const segments = rel.split(path.sep).filter(Boolean);
@@ -675,7 +712,7 @@ const banner = '\n' +
675
712
  ' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
676
713
  ' Git. Ship. Done.\n' +
677
714
  ' A meta-prompting, context engineering and spec-driven\n' +
678
- ' development workflows for Claude Code, OpenCode, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline, CodeBuddy and ZCode.\n';
715
+ ' development workflows for Claude Code, OpenCode, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline, CodeBuddy, ZCode and pi.\n';
679
716
 
680
717
  // Pure seam: parse --config-dir / -c from an arbitrary args array.
681
718
  // Returns the path string, '' for an empty equals-form value, or null when the
@@ -756,6 +793,10 @@ const referencesHook = hooksSurface.referencesHook;
756
793
  // applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
757
794
  // hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
758
795
  const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
796
+ // writeKimiHooksToml / removeKimiHooksToml: kimi's native config.toml [[hooks]]
797
+ // surface (#2095 EoS/kimi Upgrade 1) — separate from settings.json entirely.
798
+ const writeKimiHooksToml = hooksSurface.writeKimiHooksToml;
799
+ const removeKimiHooksToml = hooksSurface.removeKimiHooksToml;
759
800
  // processAttribution: pure Co-Authored-By content transform, relocated to the
760
801
  // conversion module (ADR-1508 / #1510 Phase 1). Bound here so install.js
761
802
  // callers continue to work and there is a single implementation. (All call
@@ -1852,10 +1893,12 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
1852
1893
  // Track GSD's package version so Hermes' skill_view() reports a stable
1853
1894
  // identifier per install.
1854
1895
  if (_hostBehaviors(runtime).skillFrontmatterVersion) fm += `version: ${yamlQuote(pkg.version)}\n`;
1855
- // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
1856
- // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
1857
- // we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
1858
- if (runtime === 'qwen') {
1896
+ // #778 (b) — numeric priority for /skills ordering, declared on the runtime
1897
+ // descriptor (runtime.hostBehaviors.skillPriorityFrontmatter). Scoped to
1898
+ // runtimes that declare the flag so Claude/Hermes skill frontmatter is
1899
+ // unchanged (they ignore the field, but we keep their output byte-stable).
1900
+ // skillName is the `gsd-<stem>` dir name. (ADR-1239 / #2086)
1901
+ if (_hostBehaviors(runtime).skillPriorityFrontmatter) {
1859
1902
  const stem = typeof skillName === 'string' && skillName.startsWith('gsd-')
1860
1903
  ? skillName.slice(4)
1861
1904
  : skillName;
@@ -1900,6 +1943,14 @@ function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) {
1900
1943
  .replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`);
1901
1944
  }
1902
1945
 
1946
+ // DEFECT.GENERATIVE-FIX: this body is mirrored in
1947
+ // src/runtime-artifact-conversion.cts's convertClaudeCommandToKimiSkill (dead
1948
+ // for the live skills-install path, which routes here via
1949
+ // install-engine.cts's SKILLS_CONVERTER_REGISTRY through the kimi capability
1950
+ // descriptor's artifactLayout `converter: "convertClaudeCommandToKimiSkill"`;
1951
+ // kept for bin/install.js's own module-level export/test surface). Neither
1952
+ // copy re-exports the other — mirror any behavior change into both. Guarded
1953
+ // by the output-parity test in tests/runtime-converters.test.cjs (#2095).
1903
1954
  function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cmdNames = null) {
1904
1955
  const { frontmatter, body } = extractFrontmatterAndBody(content);
1905
1956
  const kimiSkillName = normalizeKimiSkillName(skillName);
@@ -2044,6 +2095,16 @@ function buildKimiSubagentYaml({ name, description, tools }) {
2044
2095
  return `${lines.join('\n')}\n`;
2045
2096
  }
2046
2097
 
2098
+ // DEFECT.GENERATIVE-FIX: this body is mirrored in
2099
+ // src/runtime-artifact-conversion.cts's buildKimiAgentArtifacts (dead for the
2100
+ // live install path, which routes here via runtime-artifact-layout.cts's
2101
+ // kimiAgentsKind — see its `conversionExports['buildKimiAgentArtifacts']`
2102
+ // dynamic lookup against the compiled runtime-artifact-conversion.cjs; kept
2103
+ // for bin/install.js's own module-level export/test surface). Neither copy
2104
+ // re-exports the other — mirror any behavior change into both, including the
2105
+ // kimi_cli.tools.agent:Agent grant that enables background dispatch
2106
+ // (#2095 Upgrade 2). Guarded by the output-parity test in
2107
+ // tests/runtime-converters.test.cjs (#2095).
2047
2108
  function buildKimiAgentArtifacts({
2048
2109
  rootAgent = '',
2049
2110
  subagents = [],
@@ -2569,14 +2630,6 @@ function convertClaudeAgentToWindsurfAgent(content) {
2569
2630
  // Augment uses a tool set similar to Cursor/Windsurf.
2570
2631
  // Config lives in .augment/ (local) and ~/.augment/ (global).
2571
2632
 
2572
- const claudeToAugmentTools = {
2573
- Bash: 'launch-process',
2574
- Edit: 'str-replace-editor',
2575
- AskUserQuestion: null,
2576
- SlashCommand: null,
2577
- TodoWrite: 'add_tasks',
2578
- };
2579
-
2580
2633
  // #1675 (ADR-1508): the augment converter family below was a byte-identical
2581
2634
  // duplicate of runtime-artifact-conversion.cjs:
2582
2635
  // convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown,
@@ -2618,6 +2671,14 @@ function convertClaudeToTraeMarkdown(content) {
2618
2671
  return converted;
2619
2672
  }
2620
2673
 
2674
+ // DEFECT.GENERATIVE-FIX: this body is mirrored in
2675
+ // src/runtime-artifact-conversion.cts's convertClaudeCommandToTraeSkill (used
2676
+ // by src/install-engine.cts's skills-install path via
2677
+ // SKILLS_CONVERTER_REGISTRY). This bin/install.js copy is dead for the live
2678
+ // skills-install path — kept for this file's own module-level export/test
2679
+ // surface. Neither copy re-exports the other — mirror any behavior change
2680
+ // into both. Guarded by the output-parity test in
2681
+ // tests/runtime-converters.test.cjs (#2094).
2621
2682
  function convertClaudeCommandToTraeSkill(content, skillName) {
2622
2683
  const converted = convertClaudeToTraeMarkdown(content);
2623
2684
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
@@ -2632,7 +2693,16 @@ function convertClaudeCommandToTraeSkill(content, skillName) {
2632
2693
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2633
2694
  // #2876: quote so YAML flow indicators (`[BETA] …`) don't break Trae's
2634
2695
  // frontmatter parser.
2635
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`;
2696
+ let fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n`;
2697
+ // #2094: emit `stage:` so Trae's SOLO agent can auto-invoke GSD skills at
2698
+ // the corresponding stage (docs.trae.ai/ide/agent). The field name/schema
2699
+ // is not formally documented (thin SPA docs) — descriptor-driven, single
2700
+ // fixed GSD-side value (runtime.hostBehaviors.soloStageMetadata), inferred/
2701
+ // best-effort.
2702
+ const soloStage = _hostBehaviors('trae').soloStageMetadata;
2703
+ if (soloStage) fm += `stage: ${soloStage}\n`;
2704
+ fm += '---';
2705
+ return `${fm}\n${body}`;
2636
2706
  }
2637
2707
 
2638
2708
  function convertClaudeAgentToTraeAgent(content) {
@@ -5773,6 +5843,37 @@ function removeCursorHooksJson(targetDir) {
5773
5843
  return hooksSurface.removeCursorHooksJson(targetDir);
5774
5844
  }
5775
5845
 
5846
+ /**
5847
+ * #2100 Stage 2 — Write GSD-managed Windsurf/Cascade lifecycle hooks into
5848
+ * <targetDir>/hooks.json. Both managed hook scripts
5849
+ * (gsd-windsurf-pre-write.js, gsd-windsurf-pre-command.js) are copied from
5850
+ * the GSD hooks/ source to <targetDir>/hooks/ first, so the hooks.json
5851
+ * entries never reference a script that wasn't installed. Mirrors
5852
+ * writeCursorHooksJson's structure; Cascade's blocking protocol (exit code 2)
5853
+ * and entry shape (bare `command` string, no `type` field) are distinct from
5854
+ * Cursor's.
5855
+ *
5856
+ * @param {string} targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
5857
+ * @param {string} src - The GSD install source root (for copying hook scripts)
5858
+ * @param {{ platform?: string }} opts
5859
+ * @returns {{ hooksJsonPath: string, changed: boolean }}
5860
+ */
5861
+ function writeWindsurfHooksJson(targetDir, src, opts) {
5862
+ return hooksSurface.writeWindsurfHooksJson(targetDir, src, opts);
5863
+ }
5864
+
5865
+ /**
5866
+ * Remove all GSD-managed Windsurf/Cascade lifecycle hook entries from
5867
+ * hooks.json. User-owned entries are preserved. If the file becomes empty,
5868
+ * it is removed.
5869
+ *
5870
+ * @param {string} targetDir - The Windsurf config dir
5871
+ * @returns {{ changed: boolean }}
5872
+ */
5873
+ function removeWindsurfHooksJson(targetDir) {
5874
+ return hooksSurface.removeWindsurfHooksJson(targetDir);
5875
+ }
5876
+
5776
5877
  /**
5777
5878
  * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
5778
5879
  *
@@ -6077,7 +6178,12 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
6077
6178
  }
6078
6179
 
6079
6180
  // Kilo CLI — same conversion logic as OpenCode, different config paths.
6080
- function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6181
+ // DEFECT.GENERATIVE-FIX: this body is mirrored in
6182
+ // src/runtime-artifact-conversion.cts's convertClaudeToKiloFrontmatter (used by
6183
+ // src/install-engine.cts's install path). Neither copy re-exports the other —
6184
+ // mirror any behavior change into both. Guarded by the output-parity test in
6185
+ // tests/runtime-converters.test.cjs (#2093).
6186
+ function convertClaudeToKiloFrontmatter(content, { isAgent = false, modelOverride = null } = {}) {
6081
6187
  // Replace tool name references in content (applies to all files)
6082
6188
  let convertedContent = content;
6083
6189
  convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
@@ -6236,6 +6342,13 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6236
6342
  // For agents: add required Kilo agent fields
6237
6343
  if (isAgent) {
6238
6344
  newLines.push('mode: subagent');
6345
+ // Embed model override from ~/.gsd/defaults.json so model_overrides is
6346
+ // respected on Kilo (which uses static agent frontmatter, not inline
6347
+ // Task() model parameters) — mirrors convertClaudeToOpencodeFrontmatter's
6348
+ // model emission exactly (#2093 UPGRADE 2 / ADR-1239). See #2256.
6349
+ if (modelOverride) {
6350
+ newLines.push(['model:', modelOverride].join(' '));
6351
+ }
6239
6352
  newLines.push(...buildKiloAgentPermissionBlock(agentTools));
6240
6353
  }
6241
6354
 
@@ -6456,33 +6569,53 @@ const RUNTIME_CONTENT_DISPATCH = {
6456
6569
  return content;
6457
6570
  },
6458
6571
  },
6572
+ // qwen/hermes: brand VALUES are descriptor-driven (ADR-1239 / #2092) via
6573
+ // _hostBehaviors(ctx.runtime).brandingRewrites — EXACT regexes/ordering
6574
+ // preserved from the prior hardcoded-literal versions (including the
6575
+ // qwen-specific `.claude/skills/` -> `.qwen/skills/` pre-rewrite, whose
6576
+ // target is derived as `${b['.claude/']}skills/`).
6459
6577
  qwen: {
6460
- md: (content) => {
6461
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6462
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
6463
- content = content.replace(/\.claude\//g, '.qwen/');
6578
+ md: (content, ctx) => {
6579
+ // Guarded (post-review #2092): degrade closed to a no-op if the
6580
+ // registry fails to load, instead of throwing on `b['CLAUDE.md']`.
6581
+ const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6582
+ if (b) {
6583
+ content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
6584
+ content = content.replace(/\bClaude Code\b/g, b['Claude Code']);
6585
+ content = content.replace(/\.claude\//g, b['.claude/']);
6586
+ }
6464
6587
  return content;
6465
6588
  },
6466
- js: (content) => {
6467
- content = content.replace(/\.claude\/skills\//g, '.qwen/skills/');
6468
- content = content.replace(/\.claude\//g, '.qwen/');
6469
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6470
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
6589
+ js: (content, ctx) => {
6590
+ const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6591
+ if (b) {
6592
+ content = content.replace(/\.claude\/skills\//g, `${b['.claude/']}skills/`);
6593
+ content = content.replace(/\.claude\//g, b['.claude/']);
6594
+ content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
6595
+ content = content.replace(/\bClaude Code\b/g, b['Claude Code']);
6596
+ }
6471
6597
  return content;
6472
6598
  },
6473
6599
  },
6474
6600
  hermes: {
6475
- md: (content) => {
6476
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6477
- content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
6478
- content = content.replace(/\.claude\//g, '.hermes/');
6601
+ md: (content, ctx) => {
6602
+ // Guarded (post-review #2092): see qwen entry above.
6603
+ const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6604
+ if (b) {
6605
+ content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
6606
+ content = content.replace(/\bClaude Code\b/g, b['Claude Code']);
6607
+ content = content.replace(/\.claude\//g, b['.claude/']);
6608
+ }
6479
6609
  return content;
6480
6610
  },
6481
- js: (content) => {
6482
- content = content.replace(/\.claude\/skills\//g, '.hermes/skills/');
6483
- content = content.replace(/\.claude\//g, '.hermes/');
6484
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6485
- content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
6611
+ js: (content, ctx) => {
6612
+ const b = _hostBehaviors(ctx.runtime).brandingRewrites;
6613
+ if (b) {
6614
+ content = content.replace(/\.claude\/skills\//g, `${b['.claude/']}skills/`);
6615
+ content = content.replace(/\.claude\//g, b['.claude/']);
6616
+ content = content.replace(/CLAUDE\.md/g, b['CLAUDE.md']);
6617
+ content = content.replace(/\bClaude Code\b/g, b['Claude Code']);
6618
+ }
6486
6619
  return content;
6487
6620
  },
6488
6621
  },
@@ -6741,25 +6874,21 @@ function validateHookFields(settings) {
6741
6874
  * GSD hook filenames removed during uninstall.
6742
6875
  * Module-level so tests can assert structurally instead of regex-parsing source
6743
6876
  * (retires pending-migration-to-typed-ir on hooks-opt-in.test.cjs, per #455).
6877
+ *
6878
+ * Derived from _HOOKS_TO_COPY (scripts/build-hooks.js — the SAME single source
6879
+ * of truth INSTALLED_HOOK_FILES uses for manifest-tracking above) instead of a
6880
+ * separately hand-maintained literal array. The hand-maintained array had
6881
+ * silently drifted out of sync with the install-time set — missing
6882
+ * gsd-check-update-worker.js, gsd-ensure-canonical-path.js,
6883
+ * managed-hooks-registry.cjs, gsd-cursor-pre-tool.js, gsd-cursor-stop.js,
6884
+ * gsd-cursor-subagent-start.js, gsd-cursor-subagent-stop.js, and
6885
+ * gsd-worktree-path-guard.js — so every one of those files (and the hooks/ dir
6886
+ * itself, via the non-empty-dir rmdir guard) was left behind on uninstall for
6887
+ * every settings-json-hook runtime. `gsd-check-update.cmd` is added on top: a
6888
+ * Windows-only SessionStart shim generated at install time (not copied from
6889
+ * hooks/dist/, so it is not in _HOOKS_TO_COPY).
6744
6890
  */
6745
- const GSD_UNINSTALL_HOOKS = [
6746
- 'gsd-statusline.js',
6747
- 'gsd-check-update.js',
6748
- 'gsd-check-update.cmd',
6749
- 'gsd-config-reload.js',
6750
- 'gsd-context-monitor.js',
6751
- 'gsd-cursor-session-start.js',
6752
- 'gsd-cursor-post-tool.js',
6753
- 'gsd-prompt-guard.js',
6754
- 'gsd-read-guard.js',
6755
- 'gsd-read-injection-scanner.js',
6756
- 'gsd-update-banner.js',
6757
- 'gsd-workflow-guard.js',
6758
- 'gsd-session-state.sh',
6759
- 'gsd-validate-commit.sh',
6760
- 'gsd-phase-boundary.sh',
6761
- 'gsd-graphify-update.sh',
6762
- ];
6891
+ const GSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'gsd-check-update.cmd'];
6763
6892
 
6764
6893
  /**
6765
6894
  * Uninstall GSD from the specified directory for a specific runtime
@@ -6768,7 +6897,19 @@ const GSD_UNINSTALL_HOOKS = [
6768
6897
  * @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', 'copilot')
6769
6898
  */
6770
6899
  function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
6771
- const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
6900
+ // #2093: isKilo dropped — the Kilo permission-cleanup branch below is
6901
+ // descriptor-driven (resolveInstallPlan(runtime).finishPermissionWriter),
6902
+ // not gated on this flag.
6903
+ // #2094: isTrae dropped — unused in this function after the
6904
+ // skipSharedHooksInstall fold (was never referenced here besides the
6905
+ // destructure). #2095: isKimi likewise dropped — kimi is now a hooks/
6906
+ // consumer, so its former `&& !isKimi` uninstall guards were removed.
6907
+ // #2096: isAntigravity dropped — unused in this function.
6908
+ // #2098: isCodebuddy dropped — unused in this function.
6909
+ // #2099: isCopilot dropped — both Copilot side-effect branches below are now
6910
+ // gated on resolveInstallPlan(runtime).installSurface === 'copilot-instructions'.
6911
+ // #2100: isWindsurf dropped — unused in this function.
6912
+ const { isOpencode, isCodex, isCursor, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
6772
6913
  const dirName = getDirName(runtime);
6773
6914
 
6774
6915
  // Get the target directory based on runtime and install type. Cline local
@@ -6797,7 +6938,13 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
6797
6938
  // #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot
6798
6939
  // installs, so its cleanup must run even when .github (targetDir) was already
6799
6940
  // removed — i.e. BEFORE the "target directory missing" early-return below.
6800
- if (isCopilot && !isGlobal) {
6941
+ // #2099: descriptor-driven via resolveInstallPlan(runtime).installSurface ===
6942
+ // 'copilot-instructions' (was hardcoded `isCopilot`). Mirrors the install-time
6943
+ // gate at the 'copilot-instructions' branch below (~line 10471 equivalent),
6944
+ // which writes this same repo-root AGENTS.md only for local ('!isGlobal')
6945
+ // installs — 'copilot-instructions' is unique to copilot's descriptor, so
6946
+ // this is byte-parity.
6947
+ if (resolveInstallPlan(runtime).installSurface === 'copilot-instructions' && !isGlobal) {
6801
6948
  const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
6802
6949
  if (fs.existsSync(agentsMdPath)) {
6803
6950
  const content = fs.readFileSync(agentsMdPath, 'utf8');
@@ -6907,8 +7054,84 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
6907
7054
  }
6908
7055
  }
6909
7056
 
7057
+ // 1a-kimi. Non-layout Kimi side-effect (#2095 EoS/kimi Upgrade 1): kimi's
7058
+ // native config.toml lives outside targetDir entirely (resolveKimiHooksTomlDir
7059
+ // resolves ~/.kimi, a sibling of targetDir's ~/.config/agents), so its
7060
+ // cleanup can't be driven by anything under targetDir the way every other
7061
+ // hook surface above is.
7062
+ if (resolveInstallPlan(runtime).hooksSurface === 'kimi-hooks-toml') {
7063
+ const kimiHooksRoot = resolveKimiHooksTomlDir();
7064
+ const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
7065
+ const kimiHooksCleanup = removeKimiHooksToml(kimiHooksTomlPath);
7066
+ if (kimiHooksCleanup.changed) {
7067
+ removedCount++;
7068
+ console.log(` ${green}✓${reset} Removed GSD hooks from ${kimiHooksTomlPath}`);
7069
+ }
7070
+
7071
+ // Kimi's shared hook scripts + CommonJS package.json marker are installed
7072
+ // into this SAME ~/.kimi root (installSharedHooksBundle, install()'s
7073
+ // kimi-hooks-toml branch) rather than under targetDir — mirror steps "4.
7074
+ // Remove GSD hooks" / "5. Remove GSD package.json" below, but scoped to
7075
+ // kimiHooksRoot. ~/.kimi is Kimi's own native config home (shared space —
7076
+ // may hold the user's real config.toml/providers), so only the exact
7077
+ // GSD-owned filenames are removed, and directories are pruned only if left
7078
+ // empty by that removal.
7079
+ const kimiHooksDir = path.join(kimiHooksRoot, 'hooks');
7080
+ if (fs.existsSync(kimiHooksDir)) {
7081
+ let kimiHookCount = 0;
7082
+ for (const hook of GSD_UNINSTALL_HOOKS) {
7083
+ const hookPath = path.join(kimiHooksDir, hook);
7084
+ if (fs.existsSync(hookPath)) {
7085
+ fs.unlinkSync(hookPath);
7086
+ kimiHookCount++;
7087
+ }
7088
+ }
7089
+ if (kimiHookCount > 0) {
7090
+ removedCount++;
7091
+ console.log(` ${green}✓${reset} Removed ${kimiHookCount} GSD hooks from ${kimiHooksDir}`);
7092
+ }
7093
+
7094
+ const kimiHooksLibDir = path.join(kimiHooksDir, 'lib');
7095
+ if (fs.existsSync(kimiHooksLibDir)) {
7096
+ let removedKimiLibFiles = 0;
7097
+ for (const file of GSD_HOOK_LIB_FILES) {
7098
+ try {
7099
+ fs.unlinkSync(path.join(kimiHooksLibDir, file));
7100
+ removedKimiLibFiles++;
7101
+ } catch (_) { /* best-effort */ }
7102
+ }
7103
+ try { fs.rmdirSync(kimiHooksLibDir); } catch (_) { /* not empty or other error — leave it */ }
7104
+ if (removedKimiLibFiles > 0) {
7105
+ removedCount++;
7106
+ console.log(` ${green}✓${reset} Removed ${removedKimiLibFiles} hooks/lib/ helper(s) from ${kimiHooksLibDir}`);
7107
+ }
7108
+ }
7109
+
7110
+ try {
7111
+ if (fs.readdirSync(kimiHooksDir).length === 0) fs.rmdirSync(kimiHooksDir);
7112
+ } catch (_) { /* not empty — leave it */ }
7113
+ }
7114
+
7115
+ const kimiPkgJsonPath = path.join(kimiHooksRoot, 'package.json');
7116
+ if (fs.existsSync(kimiPkgJsonPath)) {
7117
+ try {
7118
+ const content = fs.readFileSync(kimiPkgJsonPath, 'utf8').trim();
7119
+ if (content === '{"type":"commonjs"}') {
7120
+ fs.unlinkSync(kimiPkgJsonPath);
7121
+ removedCount++;
7122
+ console.log(` ${green}✓${reset} Removed GSD package.json from ${kimiHooksRoot}`);
7123
+ }
7124
+ } catch (e) {
7125
+ // Ignore read errors
7126
+ }
7127
+ }
7128
+ }
7129
+
6910
7130
  // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
6911
- if (isCopilot) {
7131
+ // #2099: descriptor-driven via resolveInstallPlan(runtime).installSurface ===
7132
+ // 'copilot-instructions' (was hardcoded `isCopilot`), mirroring the same
7133
+ // gate used at the install-time 'copilot-instructions' branch.
7134
+ if (resolveInstallPlan(runtime).installSurface === 'copilot-instructions') {
6912
7135
  const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
6913
7136
  if (fs.existsSync(instructionsPath)) {
6914
7137
  const content = fs.readFileSync(instructionsPath, 'utf8');
@@ -7023,6 +7246,38 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7023
7246
  } catch { /* best-effort */ }
7024
7247
  }
7025
7248
 
7249
+ // 1b-windsurf. Descriptor-driven hook-bus cleanup (ADR-1239 / #2100 Stage 2):
7250
+ // remove GSD-managed Cascade hook entries from hooks.json and clean up the
7251
+ // managed hook scripts. Gated on resolveInstallPlan(runtime).hooksSurface
7252
+ // === 'windsurf-hooks-json' (mirrors the kimi-hooks-toml gate above) —
7253
+ // NOT the shared hostBehaviors.hooksJsonSurface flag the Cursor block above
7254
+ // uses, since that flag drives Cursor's own remove function + script list
7255
+ // and is not (and must not be) set for Windsurf.
7256
+ if (resolveInstallPlan(runtime).hooksSurface === 'windsurf-hooks-json') {
7257
+ const windsurfHooksJsonCleanup = removeWindsurfHooksJson(targetDir);
7258
+ if (windsurfHooksJsonCleanup.changed) {
7259
+ removedCount++;
7260
+ console.log(` ${green}✓${reset} Removed GSD-managed Windsurf hooks from hooks.json`);
7261
+ }
7262
+ // Remove all GSD-managed hook scripts (pre_write_code, pre_run_command).
7263
+ const windsurfHooksDir = path.join(targetDir, 'hooks');
7264
+ for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
7265
+ const p = path.join(windsurfHooksDir, script);
7266
+ try {
7267
+ if (fs.existsSync(p)) {
7268
+ fs.unlinkSync(p);
7269
+ removedCount++;
7270
+ }
7271
+ } catch { /* best-effort */ }
7272
+ }
7273
+ // Prune hooks/ if empty.
7274
+ try {
7275
+ if (fs.existsSync(windsurfHooksDir) && fs.readdirSync(windsurfHooksDir).length === 0) {
7276
+ fs.rmdirSync(windsurfHooksDir);
7277
+ }
7278
+ } catch { /* best-effort */ }
7279
+ }
7280
+
7026
7281
  // 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout,
7027
7282
  // #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs.
7028
7283
  if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') {
@@ -7067,7 +7322,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7067
7322
  // removes the directory; we must preserve/restore user artifacts before that path.
7068
7323
  // This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory
7069
7324
  // was already removed and skip if so (idempotent).
7070
- if (isQwen || _hostBehaviors(runtime).legacyCommandsGsdCleanup === true) {
7325
+ if (_hostBehaviors(runtime).legacyCommandsGsdCleanup === true) {
7071
7326
  // dev-preferences may have survived in skills/ as SKILL.md — nothing to do for
7072
7327
  // that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed),
7073
7328
  // attempt migration. In practice _runLegacyUninstallCleanup removes it first,
@@ -7177,9 +7432,11 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7177
7432
  }
7178
7433
  }
7179
7434
 
7180
- // 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
7181
- // plugin file is removed; the plugins/ dir is pruned only if it becomes
7182
- // empty, preserving any user-authored OpenCode plugins.
7435
+ // 4z. Remove the native plugin adapter (#1914, extended to Kilo by #2093).
7436
+ // Descriptor-driven via hostBehaviors.nativePlugin — covers every runtime
7437
+ // that declares the block (OpenCode, Kilo, ...), not just OpenCode. Only
7438
+ // GSD's own plugin file is removed; the plugins/ dir is pruned only if it
7439
+ // becomes empty, preserving any user-authored plugins for that host.
7183
7440
  const _np = _hostBehaviors(runtime).nativePlugin;
7184
7441
  if (_np) {
7185
7442
  const pluginsDir = path.join(targetDir, _np.dir);
@@ -7188,7 +7445,7 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7188
7445
  try {
7189
7446
  fs.unlinkSync(pluginPath);
7190
7447
  removedCount++;
7191
- console.log(` ${green}✓${reset} Removed OpenCode plugin`);
7448
+ console.log(` ${green}✓${reset} Removed native plugin adapter (${runtime})`);
7192
7449
  } catch (_) { /* best-effort */ }
7193
7450
  try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
7194
7451
  }
@@ -7355,6 +7612,47 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7355
7612
  }
7356
7613
  }
7357
7614
 
7615
+ // #2096 Phase B Upgrade 1 — Remove GSD-owned Antigravity permissions.allow
7616
+ // rules from settings.json. Symmetric to the Claude branch above: filters
7617
+ // only the exact GSD-owned rule strings (regenerated from the current
7618
+ // configDir) to preserve any user-added allow entries and all deny/ask.
7619
+ if (resolveInstallPlan(runtime).finishPermissionWriter === 'antigravity' && settings.permissions) {
7620
+ let antigravityPermissionsModified = false;
7621
+ if (Array.isArray(settings.permissions.allow)) {
7622
+ const gsdRules = new Set(buildAntigravityAllowRules(targetDir));
7623
+ const before = settings.permissions.allow.length;
7624
+ settings.permissions.allow = settings.permissions.allow.filter((e) => !gsdRules.has(e));
7625
+ if (settings.permissions.allow.length !== before) {
7626
+ antigravityPermissionsModified = true;
7627
+ }
7628
+ if (settings.permissions.allow.length === 0) {
7629
+ delete settings.permissions.allow;
7630
+ }
7631
+ }
7632
+ if (Object.keys(settings.permissions).length === 0) {
7633
+ delete settings.permissions;
7634
+ }
7635
+ if (antigravityPermissionsModified) {
7636
+ settingsModified = true;
7637
+ console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`);
7638
+ }
7639
+ }
7640
+
7641
+ // #2097 UPGRADE 3 — Remove the MCP companion entry from settings.json for
7642
+ // runtimes that host MCP there (Augment), symmetric to the mcp_config.json
7643
+ // removal for Antigravity below. Only the GSD-owned mcpServers.gsd key is
7644
+ // removed — any other user-configured MCP servers are preserved.
7645
+ if (_hostBehaviors(runtime).mcpCompanion === 'settings-json' &&
7646
+ settings.mcpServers && typeof settings.mcpServers === 'object' &&
7647
+ settings.mcpServers.gsd !== undefined) {
7648
+ delete settings.mcpServers.gsd;
7649
+ if (Object.keys(settings.mcpServers).length === 0) {
7650
+ delete settings.mcpServers;
7651
+ }
7652
+ settingsModified = true;
7653
+ console.log(` ${green}✓${reset} Removed GSD MCP companion server from settings.json`);
7654
+ }
7655
+
7358
7656
  if (settingsModified) {
7359
7657
  writeSettings(settingsPath, settings);
7360
7658
  removedCount++;
@@ -7403,7 +7701,9 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7403
7701
  }
7404
7702
 
7405
7703
  // 7. For Kilo, clean up permissions from kilo.json or kilo.jsonc
7406
- if (isKilo) {
7704
+ // #2093: descriptor-driven via resolveInstallPlan(runtime).finishPermissionWriter,
7705
+ // mirroring the OpenCode branch above (was hardcoded `isKilo`).
7706
+ if (resolveInstallPlan(runtime).finishPermissionWriter === 'kilo') {
7407
7707
  const configPath = resolveKiloConfigPath(targetDir);
7408
7708
  if (fs.existsSync(configPath)) {
7409
7709
  try {
@@ -7443,6 +7743,29 @@ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
7443
7743
  }
7444
7744
  }
7445
7745
 
7746
+ // 8. For Antigravity, remove the MCP companion entry from mcp_config.json
7747
+ // (#2096 Phase B Upgrade 2). Only the GSD-owned mcpServers.gsd key is
7748
+ // removed — any other user-configured MCP servers are preserved.
7749
+ if (resolveInstallPlan(runtime).finishPermissionWriter === 'antigravity') {
7750
+ const mcpConfigPath = path.join(targetDir, 'mcp_config.json');
7751
+ if (fs.existsSync(mcpConfigPath)) {
7752
+ try {
7753
+ const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8'));
7754
+ if (mcpConfig && typeof mcpConfig === 'object' && mcpConfig.mcpServers && mcpConfig.mcpServers.gsd !== undefined) {
7755
+ delete mcpConfig.mcpServers.gsd;
7756
+ if (Object.keys(mcpConfig.mcpServers).length === 0) {
7757
+ delete mcpConfig.mcpServers;
7758
+ }
7759
+ fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n');
7760
+ removedCount++;
7761
+ console.log(` ${green}✓${reset} Removed GSD MCP companion server from mcp_config.json`);
7762
+ }
7763
+ } catch (e) {
7764
+ // Ignore JSON parse errors
7765
+ }
7766
+ }
7767
+ }
7768
+
7446
7769
  // Remove the file manifest that the installer wrote at install time.
7447
7770
  // Without this step the metadata file persists after uninstall (#1908).
7448
7771
  const manifestPath = path.join(targetDir, MANIFEST_NAME);
@@ -7696,6 +8019,193 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
7696
8019
  console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
7697
8020
  }
7698
8021
 
8022
+ /**
8023
+ * Convert an absolute path to a `~`-relative form when it lives under the
8024
+ * user's home directory (generalizes configureKiloPermissions'
8025
+ * single-default-dir shorthand to Antigravity's three probed sibling config
8026
+ * dirs — antigravity/antigravity-ide/antigravity-cli under ~/.gemini — none of
8027
+ * which is a single fixed "default").
8028
+ */
8029
+ function toTildePosixPath(absPath) {
8030
+ const posixPath = absPath.replace(/\\/g, '/');
8031
+ const posixHome = os.homedir().replace(/\\/g, '/');
8032
+ return posixPath === posixHome || posixPath.startsWith(`${posixHome}/`)
8033
+ ? `~${posixPath.slice(posixHome.length)}`
8034
+ : posixPath;
8035
+ }
8036
+
8037
+ /**
8038
+ * Antigravity permission rule strings this installer contributes.
8039
+ * Schema: antigravity.google/docs/cli/permissions — "action(target)" rule
8040
+ * strings in permissions.{allow,deny,ask}, evaluated deny > ask > allow. GSD
8041
+ * only ever contributes to `allow` — never deny/ask (those are user-owned risk
8042
+ * decisions this installer has no business making).
8043
+ */
8044
+ function buildAntigravityAllowRules(configDir) {
8045
+ const gsdPath = toTildePosixPath(configDir);
8046
+ return [
8047
+ `read_file(${gsdPath}/gsd-core/*)`,
8048
+ `read_file(${gsdPath}/agents/gsd-*)`,
8049
+ `read_file(${gsdPath}/skills/gsd-*)`,
8050
+ `command(node ${gsdPath}/hooks/*)`,
8051
+ ];
8052
+ }
8053
+
8054
+ /**
8055
+ * Configure Antigravity permissions to allow reading/executing GSD's installed
8056
+ * tree without per-call approval prompts (#2096 Phase B Upgrade 1 — mirrors
8057
+ * configureKiloPermissions/configureOpencodePermissions).
8058
+ *
8059
+ * Antigravity's permission schema (antigravity.google/docs/cli/permissions) is
8060
+ * `{"permissions":{"allow":[...],"deny":[...],"ask":[...]}}`, living in the
8061
+ * SAME settings.json GSD's own hook registration writes for this runtime
8062
+ * (installSurface: 'settings-json', writesSharedSettings: true) — unlike
8063
+ * Kilo/OpenCode, which write a separate native config file. This function
8064
+ * re-reads the file (already containing GSD's hooks by the time finishInstall
8065
+ * reaches this call) and only appends to permissions.allow.
8066
+ *
8067
+ * Non-destructive + idempotent: only `permissions.allow` is touched; an
8068
+ * existing user permissions block (including any deny/ask entries, or
8069
+ * unrelated allow entries) is preserved untouched.
8070
+ *
8071
+ * @param {boolean} isGlobal - Whether this is a global or local install
8072
+ * @param {string|null} configDir - Resolved config directory when already known
8073
+ */
8074
+ function configureAntigravityPermissions(isGlobal = true, configDir = null) {
8075
+ // For local installs, use ./.agents/ (GSD's antigravity localConfigDir)
8076
+ // For global installs, use the resolved ~/.gemini/antigravity{,-ide,-cli}
8077
+ const antigravityConfigDir = configDir || (isGlobal
8078
+ ? getGlobalConfigDir('antigravity', explicitConfigDir)
8079
+ : path.join(process.cwd(), '.agents'));
8080
+ // Ensure config directory exists
8081
+ fs.mkdirSync(antigravityConfigDir, { recursive: true });
8082
+
8083
+ const configPath = path.join(antigravityConfigDir, 'settings.json');
8084
+
8085
+ // Read existing settings.json (readSettings tolerates JSONC + missing file;
8086
+ // returns null — and warns — only when the file exists but fails to parse).
8087
+ const config = readSettings(configPath);
8088
+ if (config === null) {
8089
+ // Cannot parse — DO NOT overwrite user's config (readSettings already warned).
8090
+ return;
8091
+ }
8092
+
8093
+ // Ensure permission structure exists
8094
+ if (!config.permissions || typeof config.permissions !== 'object' || Array.isArray(config.permissions)) {
8095
+ config.permissions = {};
8096
+ }
8097
+ if (!Array.isArray(config.permissions.allow)) {
8098
+ config.permissions.allow = [];
8099
+ }
8100
+
8101
+ let modified = false;
8102
+ for (const rule of buildAntigravityAllowRules(antigravityConfigDir)) {
8103
+ if (!config.permissions.allow.includes(rule)) {
8104
+ config.permissions.allow.push(rule);
8105
+ modified = true;
8106
+ }
8107
+ }
8108
+
8109
+ if (!modified) {
8110
+ return; // Already configured
8111
+ }
8112
+
8113
+ writeSettings(configPath, config);
8114
+ console.log(` ${green}✓${reset} Configured Antigravity permissions for GSD paths`);
8115
+ }
8116
+
8117
+ /**
8118
+ * Configure Antigravity's MCP companion server config (#2096 Phase B
8119
+ * Upgrade 2).
8120
+ *
8121
+ * Antigravity CLI manages MCP servers via standalone `mcp_config.json`
8122
+ * profiles rather than nesting them in settings.json (antigravity.google/docs/
8123
+ * cli/gcli-migration: "Antigravity CLI uses standalone mcp_config.json
8124
+ * profiles in ~/.gemini/config/ for global servers and .agents/mcp_config.json
8125
+ * for workspace servers"). The raw schema for the Antigravity IDE surface
8126
+ * itself is unpublished (docs are JS-rendered), so this follows the CLI's
8127
+ * documented standalone-profile convention plus the standard Gemini/MCP
8128
+ * `mcpServers` shape.
8129
+ *
8130
+ * BEST-EFFORT PATH CHOICE: rather than the CLI doc's separate `~/.gemini/config/`
8131
+ * directory for global scope, this writes `<configDir>/mcp_config.json` — the
8132
+ * SAME resolved configDir as settings.json (configureAntigravityPermissions) —
8133
+ * because (1) GSD's own antigravity configDir resolution already varies
8134
+ * per-user across three sibling dirs (antigravity/antigravity-ide/
8135
+ * antigravity-cli — see resolveAntigravityGlobalDir), so a hardcoded separate
8136
+ * shared path would not track that resolution, and (2) it matches the doc's
8137
+ * OWN workspace-scope convention exactly (`.agents/mcp_config.json`, which IS
8138
+ * GSD's local configDir for antigravity), keeping global/local symmetric and
8139
+ * consistent with the configDir-relative convention every other GSD
8140
+ * permission writer (kilo/opencode) already uses.
8141
+ *
8142
+ * Non-destructive + idempotent: only adds mcpServers.gsd when entirely absent;
8143
+ * any other user-configured mcpServers entries (or a user's OWN "gsd" override)
8144
+ * are preserved untouched (Hyrum's Law — mirrors OpenCode's config.mcp.gsd guard).
8145
+ *
8146
+ * @param {boolean} isGlobal - Whether this is a global or local install
8147
+ * @param {string|null} configDir - Resolved config directory when already known
8148
+ */
8149
+ function configureAntigravityMcpConfig(isGlobal = true, configDir = null) {
8150
+ const antigravityConfigDir = configDir || (isGlobal
8151
+ ? getGlobalConfigDir('antigravity', explicitConfigDir)
8152
+ : path.join(process.cwd(), '.agents'));
8153
+ fs.mkdirSync(antigravityConfigDir, { recursive: true });
8154
+
8155
+ const configPath = path.join(antigravityConfigDir, 'mcp_config.json');
8156
+
8157
+ let config = {};
8158
+ if (fs.existsSync(configPath)) {
8159
+ try {
8160
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
8161
+ config = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
8162
+ } catch (e) {
8163
+ // Cannot parse - DO NOT overwrite user's config
8164
+ console.log(` ${yellow}⚠${reset} Could not parse mcp_config.json - skipping MCP companion config`);
8165
+ console.log(` ${dim}Reason: ${e.message}${reset}`);
8166
+ console.log(` ${dim}Your config was NOT modified. Fix the syntax manually if needed.${reset}`);
8167
+ return;
8168
+ }
8169
+ }
8170
+
8171
+ if (!config.mcpServers || typeof config.mcpServers !== 'object' || Array.isArray(config.mcpServers)) {
8172
+ config.mcpServers = {};
8173
+ }
8174
+
8175
+ if (config.mcpServers.gsd !== undefined) {
8176
+ return; // Already configured (or a user-owned override) — never clobber.
8177
+ }
8178
+
8179
+ config.mcpServers.gsd = {
8180
+ command: 'npx',
8181
+ args: ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server'],
8182
+ };
8183
+
8184
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
8185
+ console.log(` ${green}✓${reset} Configured Antigravity MCP companion server (gsd)`);
8186
+ }
8187
+
8188
+ /**
8189
+ * #2097 (ADR-1239 transport:mcp): register the GSD companion MCP server inside a
8190
+ * runtime's settings.json (Augment hosts MCP in settings.json.mcpServers, unlike
8191
+ * Antigravity's standalone mcp_config.json). Mutates the in-memory settings object
8192
+ * that finishInstall already writes — non-destructive + idempotent: only sets
8193
+ * mcpServers.gsd, preserving any user-defined servers (a user's own `gsd` override
8194
+ * is respected — Hyrum's Law).
8195
+ * @param {object} settings - the in-memory settings object finishInstall will write
8196
+ */
8197
+ function mergeGsdMcpServerIntoSettings(settings) {
8198
+ if (!settings.mcpServers || typeof settings.mcpServers !== 'object' || Array.isArray(settings.mcpServers)) {
8199
+ settings.mcpServers = {};
8200
+ }
8201
+ if (settings.mcpServers.gsd === undefined) {
8202
+ settings.mcpServers.gsd = {
8203
+ command: 'npx',
8204
+ args: ['-y', '-p', PACKAGE_NAME, 'gsd-mcp-server'],
8205
+ };
8206
+ }
8207
+ }
8208
+
7699
8209
  /**
7700
8210
  * Verify a directory exists and contains files
7701
8211
  */
@@ -7805,7 +8315,18 @@ function resolveInstallRelativePath(baseDir, relPath) {
7805
8315
  * Write file manifest after installation for future modification detection
7806
8316
  */
7807
8317
  function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
7808
- const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
8318
+ // #2093: isKilo dropped — unused in this function.
8319
+ // #2094: isTrae dropped — was only used in the hooks-tracking conditional
8320
+ // above, now covered by hostBehaviors.skipSharedHooksInstall.
8321
+ // #2095: isKimi dropped — kimi is now a hooks/ consumer like every other
8322
+ // settings-json-adjacent runtime, so the `&& !isKimi` term below was removed.
8323
+ // #2096: isAntigravity dropped — unused in this function.
8324
+ // #2098: isCodebuddy dropped — unused in this function.
8325
+ // #2099: isCopilot dropped — was only used in the hooks-tracking conditional
8326
+ // above, now covered by hostBehaviors.skipSharedHooksInstall.
8327
+ // #2100: isWindsurf dropped — was only used in the hooks-tracking conditional
8328
+ // above, now covered by hostBehaviors.skipSharedHooksInstall.
8329
+ const { isOpencode, isCodex, isCursor, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
7809
8330
  const gsdDir = path.join(configDir, 'gsd-core');
7810
8331
  // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
7811
8332
  // Claude local uses flatCommandsDir instead for manifest recording.
@@ -7879,7 +8400,7 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
7879
8400
  }
7880
8401
  }
7881
8402
  }
7882
- if (isKimi && fs.existsSync(agentsDir)) {
8403
+ if (_hostBehaviors(runtime).agentManifestStyle === 'kimi-nested' && fs.existsSync(agentsDir)) {
7883
8404
  const agentHashes = generateManifest(agentsDir);
7884
8405
  for (const [rel, hash] of Object.entries(agentHashes)) {
7885
8406
  const isRootAgent = rel === 'gsd.yaml' || rel === 'gsd.md';
@@ -7913,7 +8434,15 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
7913
8434
  // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
7914
8435
  // Descriptor-driven (ADR-1239 / #2089+#2090): cline's exclusion is via
7915
8436
  // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline).
7916
- if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi) {
8437
+ // #2094: Trae's exclusion is likewise descriptor-driven (trae declares
8438
+ // skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
8439
+ // #2095: kimi is now a hooks/ consumer (native config.toml [[hooks]] bus) —
8440
+ // the redundant `&& !isKimi` was removed so its hook files are tracked too.
8441
+ // #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares
8442
+ // skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed.
8443
+ // #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares
8444
+ // skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed.
8445
+ if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
7917
8446
  const hooksDir = path.join(configDir, 'hooks');
7918
8447
  if (fs.existsSync(hooksDir)) {
7919
8448
  // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
@@ -8274,11 +8803,7 @@ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
8274
8803
  try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
8275
8804
 
8276
8805
  if (meta.files && meta.files.length > 0) {
8277
- const reapplyCommand = _hostBehaviors(runtime).reapplyCommand
8278
- ? _hostBehaviors(runtime).reapplyCommand
8279
- : runtime === 'kimi'
8280
- ? '/skill:gsd-update --reapply'
8281
- : '/gsd-update --reapply';
8806
+ const reapplyCommand = _hostBehaviors(runtime).reapplyCommand || '/gsd-update --reapply';
8282
8807
  console.log('');
8283
8808
  console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
8284
8809
  for (const f of meta.files) {
@@ -8305,12 +8830,44 @@ function reportInstallerMigrationResult(result) {
8305
8830
  }
8306
8831
 
8307
8832
  function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8308
- const { isOpencode, isKilo, isZcode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
8833
+ // #2093: isKilo dropped — Kilo's agent/model-override handling below reads
8834
+ // _hostBehaviors(runtime).frontmatterDialect === 'kilo' instead of this flag.
8835
+ // #2095: isKimi dropped — kimi is now a hooks/ consumer like every other
8836
+ // settings-json-adjacent runtime; the two `&& !isKimi` hooks-copy guards
8837
+ // below were removed, leaving isKimi unused in this function (the kimi
8838
+ // local-install-deferred branch above already reads
8839
+ // _hostBehaviors(runtime).localInstallDeferred instead of this flag).
8840
+ // #2096: isAntigravity dropped — antigravity is in
8841
+ // _DESCRIPTOR_AGENTS_RUNTIMES below, so its two legacy-agent-loop branches
8842
+ // (the path-rewrite skip and the converter dispatch) were unreachable dead
8843
+ // code; both were removed rather than re-gated on hostBehaviors.
8844
+ // #2098: isCodebuddy dropped — codebuddy is also in
8845
+ // _DESCRIPTOR_AGENTS_RUNTIMES below, so its legacy converter-dispatch branch
8846
+ // (the `isCodebuddy` arm calling convertClaudeAgentToCodebuddyAgent) was
8847
+ // unreachable dead code and was removed rather than re-gated.
8848
+ // #2099: isCopilot dropped — copilot is also in _DESCRIPTOR_AGENTS_RUNTIMES
8849
+ // below, so its three legacy-agent-loop branches (the path-rewrite skip,
8850
+ // the converter dispatch, and the .agent.md destName ternary) were
8851
+ // unreachable dead code and were removed rather than re-gated; the
8852
+ // .agent.md suffix now lives on hostBehaviors.agentFileExtension in
8853
+ // src/install-engine.cts, and the skipSharedHooksInstall check above no
8854
+ // longer needs `&& !isCopilot`.
8855
+ // #2100: isWindsurf dropped — its four former isWindsurf-gated branches
8856
+ // (legacy .devin/skills/gsd-* cleanup, the #1629 command-bodies copy, the
8857
+ // workflow-verification report, and the shared-hooks-install exclusion) are
8858
+ // now descriptor-driven via hostBehaviors.legacyDevinSkillsCleanup,
8859
+ // hostBehaviors.installsCommandBodiesForWorkflowDelegation,
8860
+ // hostBehaviors.verificationStyle === 'windsurf-workflows', and
8861
+ // hostBehaviors.skipSharedHooksInstall respectively; its legacy-agent-loop
8862
+ // converter arm was likewise unreachable dead code (windsurf is in
8863
+ // _DESCRIPTOR_AGENTS_RUNTIMES) and was removed above.
8864
+ // #2101: isZcode dropped — folded onto hostBehaviors.skipSharedHooksInstall.
8865
+ const { isOpencode, isCodex, isCursor, isAugment, isTrae, isQwen, isHermes, isCline } = runtimeFlags(runtime);
8309
8866
  const plan = resolveInstallPlan(runtime);
8310
8867
  const dirName = getDirName(runtime);
8311
8868
  const src = path.join(__dirname, '..');
8312
8869
 
8313
- if (isKimi && !isGlobal) {
8870
+ if (_hostBehaviors(runtime).localInstallDeferred && !isGlobal) {
8314
8871
  console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
8315
8872
  console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
8316
8873
  console.log(` Project-level Kimi install semantics remain deferred.`);
@@ -8815,7 +9372,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8815
9372
  // dirs from pre-#1615 installs. #1615 moved Windsurf to .windsurf/workflows/
8816
9373
  // but never cleaned up the old .devin/skills/ layout (#1085). User-owned
8817
9374
  // content is preserved (non-gsd- dirs, gsd-dev-preferences, symlinks).
8818
- if (isWindsurf && !isGlobal) {
9375
+ // Descriptor-driven (ADR-1239 / #2100): folded from `isWindsurf` into
9376
+ // hostBehaviors.legacyDevinSkillsCleanup (windsurf is the only runtime that
9377
+ // declares it, so this is byte-parity).
9378
+ if (_hostBehaviors(runtime).legacyDevinSkillsCleanup && !isGlobal) {
8819
9379
  const removedCount = cleanupWindsurfLegacyDevinSkills(process.cwd());
8820
9380
  if (removedCount > 0) {
8821
9381
  console.log(` ${green}✓${reset} Removed ${removedCount} legacy .devin/skills/gsd-* dir(s) (pre-#1615 Windsurf layout)`);
@@ -8842,7 +9402,7 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8842
9402
  } else {
8843
9403
  failures.push('skills/gsd/*');
8844
9404
  }
8845
- } else if (isKimi) {
9405
+ } else if (_hostBehaviors(runtime).verificationStyle === 'kimi') {
8846
9406
  const skillsDir = path.join(targetDir, 'skills');
8847
9407
  const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml');
8848
9408
  if (fs.existsSync(skillsDir)) {
@@ -8862,7 +9422,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8862
9422
  } else {
8863
9423
  failures.push('agents/gsd.yaml');
8864
9424
  }
8865
- } else if (isWindsurf) {
9425
+ // Descriptor-driven (ADR-1239 / #2100): folded from `isWindsurf` into
9426
+ // hostBehaviors.verificationStyle === 'windsurf-workflows' (extends the
9427
+ // same mechanism the 'kimi' verificationStyle branch above uses; windsurf
9428
+ // is the only runtime that declares this value, so this is byte-parity).
9429
+ } else if (_hostBehaviors(runtime).verificationStyle === 'windsurf-workflows') {
8866
9430
  if (isGlobal) {
8867
9431
  console.log(` ${green}✓${reset} Windsurf global install skipped workflow artifacts (workspace-only)`);
8868
9432
  } else {
@@ -8924,22 +9488,6 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8924
9488
  failures.push('commands/gsd-*');
8925
9489
  }
8926
9490
  }
8927
-
8928
- // CodeBuddy only: also report the commands/ output (#789 — slash commands)
8929
- if (isCodebuddy) {
8930
- const commandsDir = path.join(targetDir, 'commands');
8931
- if (fs.existsSync(commandsDir)) {
8932
- const cmdCount = fs.readdirSync(commandsDir)
8933
- .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
8934
- if (cmdCount > 0) {
8935
- console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
8936
- } else {
8937
- failures.push('commands/gsd-*');
8938
- }
8939
- } else {
8940
- failures.push('commands/gsd-*');
8941
- }
8942
- }
8943
9491
  }
8944
9492
  } else if (_hostBehaviors(runtime).localCommandsViaRules) {
8945
9493
  // Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
@@ -8948,6 +9496,16 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8948
9496
  // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
8949
9497
  // hostBehaviors.localCommandsViaRules.
8950
9498
  console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
9499
+ } else if (_hostBehaviors(runtime).pluginOnlyInstall) {
9500
+ // pi (ADR-1239 / #2102 Stage 1): plugin-only install — pi's /gsd command is
9501
+ // registered programmatically by the native extension (pi/gsd.cjs →
9502
+ // extensions/gsd.cjs, staged separately below) and dispatches in-process
9503
+ // through the embedded gsd-core command-routing hub. pi has no host-read
9504
+ // markdown surface (unlike Claude/OpenCode/etc., which scan commands/ or
9505
+ // command/ directories), so writing flat gsd-<cmd>.md files here would be
9506
+ // dead weight the extension never reads. Skip the flat-commands fallback
9507
+ // entirely for pluginOnlyInstall runtimes.
9508
+ console.log(` ${green}✓${reset} pi: /gsd registered via native extension (no declarative command files)`);
8951
9509
  } else {
8952
9510
  // Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
8953
9511
  // commands from .claude/commands/ using the filename stem as the command
@@ -9018,6 +9576,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9018
9576
  }
9019
9577
  }
9020
9578
 
9579
+ // Native-extension/plugin staging for runtimes OUTSIDE the layout-driven
9580
+ // _isSkillsRuntime branch above (ADR-1239 / #2102 Stage 1: pi). OpenCode/Kilo
9581
+ // already get their nativePlugin file from installOpencodeFamilyArtifacts
9582
+ // (called inside the _isSkillsRuntime branch, since both declare a non-empty
9583
+ // artifactLayout) — guard on `!_isSkillsRuntime` so this standalone call never
9584
+ // double-stages their plugin file. A runtime like pi, whose artifactLayout is
9585
+ // intentionally empty for both scopes (`_isSkillsRuntime` is false), still
9586
+ // needs its declared hostBehaviors.nativePlugin file copied into targetDir.
9587
+ if (!_isSkillsRuntime && _hostBehaviors(runtime).nativePlugin) {
9588
+ _installNativePluginIfDeclared(runtime, targetDir, _hostBehaviors(runtime), src);
9589
+ }
9590
+
9021
9591
  // Copy gsd-core skill with path replacement
9022
9592
  // Preserve user-generated files before the wipe-and-copy so they survive re-install
9023
9593
  const skillSrc = path.join(src, 'gsd-core');
@@ -9069,7 +9639,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9069
9639
  // this copy, every /gsd-* workflow in Cascade references a missing file and the LLM
9070
9640
  // cannot execute the command body. Surfaced by the #1629 regression test after the
9071
9641
  // original adversarial review of #1622 missed it.
9072
- if (isWindsurf && !isGlobal) {
9642
+ // Descriptor-driven (ADR-1239 / #2100): folded from `isWindsurf` into
9643
+ // hostBehaviors.installsCommandBodiesForWorkflowDelegation (windsurf is the
9644
+ // only runtime that declares it, so this is byte-parity — the #1629 fix
9645
+ // itself is unchanged).
9646
+ if (_hostBehaviors(runtime).installsCommandBodiesForWorkflowDelegation && !isGlobal) {
9073
9647
  const commandsSrc = path.join(src, 'commands', 'gsd');
9074
9648
  const commandsDest = path.join(skillDest, 'commands', 'gsd');
9075
9649
  if (fs.existsSync(commandsSrc)) {
@@ -9125,16 +9699,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9125
9699
  // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
9126
9700
  // #1575: copilot and antigravity cut over — copilot gets .agent.md filename
9127
9701
  // rename via _copyStaged(runtime); antigravity uses scope-aware converter.
9702
+ // #2092 Phase B Upgrade 1: qwen cut over — native .qwen/agents/*.md subagent
9703
+ // projection via convertClaudeAgentToQwenAgent. Without this exclusion the
9704
+ // legacy inline loop below deletes+re-copies qwen's agents RAW (bypassing the
9705
+ // new converter entirely, since qwen has no dedicated branch in the inline
9706
+ // loop's if/else-if chain — it would silently fall through to the generic
9707
+ // brandingRewrites-only branch).
9128
9708
  // cline remains excluded: rules-only local branch + local/global complication
9129
9709
  // that the descriptor-driven path does not handle correctly.
9130
- const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity']);
9710
+ const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity', 'qwen', 'kimi']);
9131
9711
 
9132
9712
  // Always remove stale gsd-* agents first so re-installing with
9133
9713
  // `--minimal` actually shrinks a previously-full install.
9134
9714
  // For Codex this also covers per-agent `.toml` files alongside the `.md`
9135
9715
  // sources so a full → minimal switch doesn't leave stale registrations.
9136
- // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes).
9137
- if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && fs.existsSync(agentsDest)) {
9716
+ // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes) and
9717
+ // for pluginOnlyInstall runtimes (pi, ADR-1239 / #2102 Stage 1 — no agents/
9718
+ // dir is ever written for them, see the leading branch below).
9719
+ if (!_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime) && !_hostBehaviors(runtime).pluginOnlyInstall && fs.existsSync(agentsDest)) {
9138
9720
  for (const file of fs.readdirSync(agentsDest)) {
9139
9721
  if (
9140
9722
  file.startsWith('gsd-') &&
@@ -9145,8 +9727,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9145
9727
  }
9146
9728
  }
9147
9729
 
9148
- if (isKimi) {
9149
- console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
9730
+ if (_hostBehaviors(runtime).pluginOnlyInstall) {
9731
+ // pi (ADR-1239 / #2102 Stage 1): programmatic dispatch has no named-dispatch
9732
+ // subagent toolkit (dispatch.subagentToolkit: "undocumented", no Agent-tool
9733
+ // equivalent) and no host-read markdown surface — skip writing agents/ entirely.
9734
+ console.log(` ${green}✓${reset} pi: no subagent files (programmatic dispatch, no named-dispatch toolkit)`);
9150
9735
  } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
9151
9736
  // installRuntimeArtifacts already wrote agents + handles stale-file cleanup
9152
9737
  // via its own prune pass. No further action needed.
@@ -9183,12 +9768,18 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9183
9768
  const bareDirRegex = /~\/\.claude\b/g;
9184
9769
  const bareHomeDirRegex = /\$HOME\/\.claude\b/g;
9185
9770
  const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
9186
- if (!isCopilot && !isAntigravity) {
9187
- content = content.replace(dirRegex, pathPrefix);
9188
- content = content.replace(homeDirRegex, pathPrefix);
9189
- content = content.replace(bareDirRegex, normalizedPathPrefix);
9190
- content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
9191
- }
9771
+ // #2096: `&& !isAntigravity` dropped — antigravity is in
9772
+ // _DESCRIPTOR_AGENTS_RUNTIMES above, so this whole branch is already
9773
+ // unreachable for it; the path-rewrite skip for antigravity now lives
9774
+ // in the descriptor-driven `applyAgentPathRewrites` (hostBehaviors.noPathRewrite).
9775
+ // #2099: `if (!isCopilot)` guard dropped — copilot is ALSO in
9776
+ // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9564 above), so this whole
9777
+ // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
9778
+ // isCopilot was therefore always false here, making the guard a no-op.
9779
+ content = content.replace(dirRegex, pathPrefix);
9780
+ content = content.replace(homeDirRegex, pathPrefix);
9781
+ content = content.replace(bareDirRegex, normalizedPathPrefix);
9782
+ content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
9192
9783
  content = processAttribution(content, getCommitAttribution(runtime));
9193
9784
  // Convert frontmatter for runtime compatibility (agents need different handling)
9194
9785
  if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
@@ -9211,33 +9802,52 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9211
9802
  }
9212
9803
  content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
9213
9804
  } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
9214
- content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
9805
+ // Resolve per-agent model for Kilo agents (#2093 UPGRADE 2; Kilo is an
9806
+ // OpenCode fork with the same static-frontmatter model constraint).
9807
+ // Precedence: model_overrides[agent] > model_profile_overrides.kilo.<tier> > omit.
9808
+ // model_overrides (#2256): explicit per-agent override, highest precedence.
9809
+ // model_profile_overrides (#2794): tier-based runtime resolver, same parity as OpenCode.
9810
+ const _kiloAgentName = entry.name.replace(/\.md$/, '');
9811
+ const _kiloModelOverrides = readGsdEffectiveModelOverrides(targetDir);
9812
+ let _kiloModelOverride = _kiloModelOverrides?.[_kiloAgentName] || null;
9813
+ if (!_kiloModelOverride) {
9814
+ // Fall back to tier-based resolution via model_profile_overrides.kilo.<tier>.
9815
+ const _kiloRuntimeResolver = readGsdRuntimeProfileResolver(targetDir);
9816
+ if (_kiloRuntimeResolver) {
9817
+ const _kiloEntry = _kiloRuntimeResolver.resolve(_kiloAgentName);
9818
+ if (_kiloEntry?.model) {
9819
+ _kiloModelOverride = _kiloEntry.model;
9820
+ }
9821
+ }
9822
+ }
9823
+ content = convertClaudeToKiloFrontmatter(content, { isAgent: true, modelOverride: _kiloModelOverride });
9215
9824
  } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
9216
9825
  content = convertClaudeAgentToCodexAgent(content);
9217
- } else if (isCopilot) {
9218
- content = convertClaudeAgentToCopilotAgent(content, isGlobal);
9219
- } else if (isAntigravity) {
9220
- content = convertClaudeAgentToAntigravityAgent(content, isGlobal);
9221
- } else if (isWindsurf) {
9222
- content = convertClaudeAgentToWindsurfAgent(content);
9223
- } else if (isAugment) {
9224
- content = convertClaudeAgentToAugmentAgent(content);
9225
- } else if (isTrae) {
9226
- content = convertClaudeAgentToTraeAgent(content);
9227
- } else if (isCodebuddy) {
9228
- content = convertClaudeAgentToCodebuddyAgent(content);
9826
+ // #2099: `else if (isCopilot)` arm dropped — copilot is unreachable
9827
+ // here (see the isCopilot-guard-drop comment above); its content
9828
+ // conversion is applied pre-staging via the descriptor's
9829
+ // artifactLayout.converter (runtime-artifact-layout.cts), independent
9830
+ // of this legacy loop.
9831
+ // #2100: `else if (isWindsurf)` arm dropped — windsurf is ALSO in
9832
+ // _DESCRIPTOR_AGENTS_RUNTIMES (line ~9575 above), so this whole
9833
+ // `else if (fs.existsSync(agentsSrc))` branch is unreachable for it;
9834
+ // isWindsurf was therefore always false here, making the arm dead.
9835
+ // Its content conversion is applied pre-staging via the descriptor's
9836
+ // artifactLayout.converter (convertClaudeAgentToWindsurfAgent),
9837
+ // independent of this legacy loop.
9229
9838
  } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
9230
9839
  // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
9231
9840
  // hostBehaviors.frontmatterDialect === 'cline'.
9232
9841
  content = convertClaudeAgentToClineAgent(content);
9233
- } else if (isQwen) {
9234
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9235
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9236
- content = content.replace(/\.claude\//g, '.qwen/');
9237
9842
  } else if (_hostBehaviors(runtime).brandingRewrites) {
9238
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9239
- content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
9240
- content = content.replace(/\.claude\//g, '.hermes/');
9843
+ // Descriptor-driven (ADR-1239 / #2092): folded from separate
9844
+ // `isQwen` / hermes-hardcoded branches into a single read of
9845
+ // runtime.hostBehaviors.brandingRewrites (qwen -> QWEN.md/Qwen
9846
+ // Code/.qwen/, hermes -> HERMES.md/Hermes Agent/.hermes/).
9847
+ const _b = _hostBehaviors(runtime).brandingRewrites;
9848
+ content = content.replace(/CLAUDE\.md/g, _b['CLAUDE.md']);
9849
+ content = content.replace(/\bClaude Code\b/g, _b['Claude Code']);
9850
+ content = content.replace(/\.claude\//g, _b['.claude/']);
9241
9851
  }
9242
9852
  // #443 — Inject `effort:` into the Claude .md frontmatter ONLY.
9243
9853
  // OpenCode/Qwen/Hermes also produce .md files but break on
@@ -9262,7 +9872,12 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9262
9872
  // shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the
9263
9873
  // SKILL.md-body fix shipped via #3629.
9264
9874
  content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
9265
- const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name;
9875
+ // #2099: `isCopilot ? ... : entry.name` ternary dropped — copilot is
9876
+ // unreachable here (see the isCopilot-guard-drop comment above), so
9877
+ // the ternary always evaluated to entry.name in practice; its
9878
+ // .agent.md suffix is applied by the descriptor-driven fold in
9879
+ // src/install-engine.cts (hostBehaviors.agentFileExtension).
9880
+ const destName = entry.name;
9266
9881
  fs.writeFileSync(path.join(agentsDest, destName), content);
9267
9882
  }
9268
9883
  }
@@ -9294,29 +9909,41 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9294
9909
  failures.push('VERSION');
9295
9910
  }
9296
9911
 
9297
- // #1821: Kilo and ZCode declare hooksSurface:'none' AND have no plugin surface,
9298
- // so the staged hook scripts are dead weight for them — exclude both here.
9299
- // OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
9300
- // its native plugin adapter (#1914, installed above under plugins/gsd-core.js)
9301
- // spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
9302
- // them and the CommonJS package.json marker written below.
9303
- // #2089: Cursor's exclusion is now descriptor-driven via
9304
- // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
9305
- // #2090: Cline's exclusion is likewise descriptor-driven (cline declares
9306
- // skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
9307
- if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode) {
9912
+ // Reusable: copy hooks/dist/ + hooks/lib/ into destRootDir, writing the
9913
+ // CommonJS package.json marker alongside them. Used below for the generic
9914
+ // configDir install path (guarded by hostBehaviors.skipSharedHooksInstall),
9915
+ // and — since #2095 — for Kimi's OWN native hook-install root (~/.kimi,
9916
+ // resolved by resolveKimiHooksTomlDir), a directory entirely separate from
9917
+ // Kimi's configDir/agents-root. Kimi's contract forbids hooks/ or
9918
+ // package.json under its generic Agent-Skills root (see
9919
+ // capabilities/kimi/capability.json hostBehaviors.skipSharedHooksInstall
9920
+ // and the kimi-hooks-toml branch further below), so its shared-hooks bundle
9921
+ // is installed into its own root via this same helper instead.
9922
+ // Returns false when hooks/dist/ exists but failed to verify post-copy (a
9923
+ // genuine failure the caller should surface); true otherwise (including
9924
+ // when hooks/dist/ is absent from the package — nothing to verify).
9925
+ function installSharedHooksBundle(destRootDir) {
9926
+ // destRootDir already exists for the generic call site (targetDir — created
9927
+ // earlier in install() by the skills/agents writes above). It does NOT yet
9928
+ // exist for kimi's call site (~/.kimi, resolved by resolveKimiHooksTomlDir):
9929
+ // a fresh install has never created that dir before. mkdirSync recursive is
9930
+ // a safe no-op when the dir is already present.
9931
+ fs.mkdirSync(destRootDir, { recursive: true });
9932
+
9308
9933
  // Write package.json to force CommonJS mode for GSD scripts
9309
9934
  // Prevents "require is not defined" errors when project has "type": "module"
9310
9935
  // Node.js walks up looking for package.json - this stops inheritance from project
9311
- const pkgJsonDest = path.join(targetDir, 'package.json');
9936
+ const pkgJsonDest = path.join(destRootDir, 'package.json');
9312
9937
  fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
9313
9938
  console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
9314
9939
 
9940
+ let hooksOk = true;
9941
+
9315
9942
  // Copy hooks from dist/ (bundled with dependencies)
9316
9943
  // Template paths for the target runtime (replaces '.claude' with correct config dir)
9317
9944
  const hooksSrc = path.join(src, 'hooks', 'dist');
9318
9945
  if (fs.existsSync(hooksSrc)) {
9319
- const hooksDest = path.join(targetDir, 'hooks');
9946
+ const hooksDest = path.join(destRootDir, 'hooks');
9320
9947
  fs.mkdirSync(hooksDest, { recursive: true });
9321
9948
  const hookEntries = fs.readdirSync(hooksSrc);
9322
9949
  const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
@@ -9329,13 +9956,15 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9329
9956
  content = content.replace(/'\.claude'/g, configDirReplacement);
9330
9957
  content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`);
9331
9958
  content = content.replace(/\.claude\//g, `${getDirName(runtime)}/`);
9332
- if (isQwen) {
9333
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9334
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9335
- }
9336
- if (_hostBehaviors(runtime).brandingRewrites) {
9337
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9338
- content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
9959
+ // Descriptor-driven (ADR-1239 / #2092): folded from separate
9960
+ // `isQwen` / hermes-hardcoded branches into a single read of
9961
+ // runtime.hostBehaviors.brandingRewrites. This site only
9962
+ // rewrites the two brand-name keys (no `.claude/` here — the
9963
+ // config-dir replace above already handled path fragments).
9964
+ const _b2 = _hostBehaviors(runtime).brandingRewrites;
9965
+ if (_b2) {
9966
+ content = content.replace(/CLAUDE\.md/g, _b2['CLAUDE.md']);
9967
+ content = content.replace(/\bClaude Code\b/g, _b2['Claude Code']);
9339
9968
  }
9340
9969
  // #376: rewrite gsd: → gsd- for hyphen-namespace runtimes
9341
9970
  if (shouldNormalizeHyphenNamespaceInAgentBody(runtime)) {
@@ -9388,27 +10017,63 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
9388
10017
  }
9389
10018
  }
9390
10019
  } else {
9391
- failures.push('hooks');
10020
+ hooksOk = false;
9392
10021
  }
9393
10022
  }
10023
+
10024
+ // Gate hooks/lib/ install on the same set of runtimes that receive hooks/.
10025
+ // Codex/Copilot/Cursor/Windsurf/Trae/Cline/Kilo do not use the shared
10026
+ // hooks/lib/ helpers (Cursor uses standalone .js hook scripts registered
10027
+ // via hooks.json — gated descriptor-driven via
10028
+ // hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise #2090; Kilo
10029
+ // likewise #2093; Trae likewise #2094; Codex uses hooks.json directly;
10030
+ // the others skip hooks entirely); Kilo and ZCode also skip hooks entirely
10031
+ // (hooksSurface:'none' with no plugin surface — #1821). None of the
10032
+ // excluded runtimes must receive the hooks/lib/ helpers — otherwise the
10033
+ // Codex comment downstream ("we deliberately do *not* copy hooks/lib/ for
10034
+ // Codex") is contradicted in practice. (Gating lives at the call sites
10035
+ // below; this helper itself only checks source presence.)
10036
+ const hooksLibSrc = path.join(src, 'hooks', 'lib');
10037
+ if (fs.existsSync(hooksLibSrc)) {
10038
+ const hooksLibDest = path.join(destRootDir, 'hooks', 'lib');
10039
+ fs.mkdirSync(hooksLibDest, { recursive: true });
10040
+ copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
10041
+ console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
10042
+ }
10043
+
10044
+ return hooksOk;
9394
10045
  }
9395
10046
 
9396
- // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
9397
- // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
9398
- // (Cursor uses standalone .js hook scripts registered via hooks.json — gated
9399
- // descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise
9400
- // #2090; Codex uses hooks.json directly; the others skip hooks entirely); Kilo and
9401
- // ZCode also skip hooks entirely (hooksSurface:'none' with no plugin surface — #1821).
9402
- // OpenCode is NOT excluded: its #1914 plugin adapter spawns the staged hooks and
9403
- // requires hooks/lib/ helpers. None of the excluded runtimes must receive the
9404
- // hooks/lib/ helpers — otherwise the Codex comment downstream ("we deliberately do
9405
- // *not* copy hooks/lib/ for Codex") is contradicted in practice.
9406
- const hooksLibSrc = path.join(src, 'hooks', 'lib');
9407
- if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) {
9408
- const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
9409
- fs.mkdirSync(hooksLibDest, { recursive: true });
9410
- copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
9411
- console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
10047
+ // #1821: Kilo and ZCode declare hooksSurface:'none' AND have no plugin surface,
10048
+ // so the staged hook scripts are dead weight for them — exclude both here.
10049
+ // OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
10050
+ // its native plugin adapter (#1914, installed above under plugins/gsd-core.js)
10051
+ // spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
10052
+ // them and the CommonJS package.json marker written below.
10053
+ // #2089: Cursor's exclusion is now descriptor-driven via
10054
+ // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
10055
+ // #2090: Cline's exclusion is likewise descriptor-driven (cline declares
10056
+ // skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
10057
+ // #2093: Kilo's exclusion is likewise descriptor-driven (kilo declares
10058
+ // skipSharedHooksInstall:true) — the redundant `&& !isKilo` was removed.
10059
+ // #2094: Trae's exclusion is likewise descriptor-driven (trae declares
10060
+ // skipSharedHooksInstall:true) — the redundant `&& !isTrae` was removed.
10061
+ // #2101: ZCode's exclusion is likewise descriptor-driven (zcode declares
10062
+ // skipSharedHooksInstall:true) — the redundant `&& !isZcode` was removed.
10063
+ // #2095: Kimi's exclusion is likewise descriptor-driven (kimi declares
10064
+ // skipSharedHooksInstall:true) — kimi's shared hooks/ + package.json marker
10065
+ // are instead installed into its OWN native hook root (~/.kimi, resolved by
10066
+ // resolveKimiHooksTomlDir) via installSharedHooksBundle, at the
10067
+ // kimi-hooks-toml branch further below — never under the generic
10068
+ // Agent-Skills configDir GSD installs skills/agents into for kimi.
10069
+ // #2099: Copilot's exclusion is likewise descriptor-driven (copilot declares
10070
+ // skipSharedHooksInstall:true) — the redundant `&& !isCopilot` was removed.
10071
+ // #2100: Windsurf's exclusion is likewise descriptor-driven (windsurf declares
10072
+ // skipSharedHooksInstall:true) — the redundant `&& !isWindsurf` was removed.
10073
+ if (!isCodex && _hostBehaviors(runtime).skipSharedHooksInstall !== true) {
10074
+ if (!installSharedHooksBundle(targetDir)) {
10075
+ failures.push('hooks');
10076
+ }
9412
10077
  }
9413
10078
 
9414
10079
  // Install scripts/changeset/ and scripts/lib/ into <configDir>/scripts/
@@ -10030,14 +10695,86 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10030
10695
  } else {
10031
10696
  console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
10032
10697
  }
10033
- // Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
10698
+ // Re-run the manifest pass to capture any files the hooks-json write path
10699
+ // produced. NOTE: hooks.json and the gsd-cursor-*.js scripts are NOT
10700
+ // manifest-tracked (verified) — uninstall removes them explicitly via
10701
+ // removeCursorHooksJson + its script list, and reconcile is idempotent.
10702
+ // The re-run is retained for parity with the settings.json install path.
10034
10703
  writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
10035
10704
  persistActiveProfileMarker();
10036
10705
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
10037
10706
  }
10038
10707
 
10039
10708
  if (plan.installSurface === 'profile-marker-only') {
10040
- // Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
10709
+ // Windsurf/Trae use artifact-only surfaces — no config.toml or settings.json
10710
+ // hooks needed. Kimi is also artifact-only for its INSTALL surface (skills +
10711
+ // kimi-agents, no settings.json) but #2095 Upgrade 1 gives it its own
10712
+ // independent hooksSurface: kimi's native config.toml [[hooks]] array, which
10713
+ // lives outside targetDir entirely (resolveKimiHooksTomlDir resolves ~/.kimi,
10714
+ // a sibling of targetDir's ~/.config/agents) — hence writing it here, inside
10715
+ // this early-return, rather than requiring installSurface to change.
10716
+ //
10717
+ // GATED TO GLOBAL ONLY (belt-and-suspenders): kimi local installs already
10718
+ // return early at the top of install() via hostBehaviors.localInstallDeferred,
10719
+ // long before this point is ever reached — so `isGlobal` is always true here
10720
+ // in practice. The explicit check documents that invariant and fails closed
10721
+ // if that early-return is ever refactored away.
10722
+ //
10723
+ // Kimi's contract forbids hooks/ or package.json under its generic
10724
+ // Agent-Skills configDir (targetDir) — capabilities/kimi/capability.json
10725
+ // declares hostBehaviors.skipSharedHooksInstall:true, which excludes it from
10726
+ // the shared installSharedHooksBundle(targetDir) call above. Kimi still needs
10727
+ // those SAME hook scripts + the CommonJS package.json marker, but SELF-
10728
+ // CONTAINED under its own native hook root instead — so install them there,
10729
+ // and point buildHookCommand (via writeKimiHooksToml's second arg) at that
10730
+ // same root so the generated [[hooks]] command paths reference
10731
+ // ~/.kimi/hooks/<script> rather than a script that doesn't exist under
10732
+ // targetDir/hooks (which kimi no longer receives).
10733
+ if (plan.hooksSurface === 'kimi-hooks-toml' && isGlobal) {
10734
+ const kimiHooksRoot = resolveKimiHooksTomlDir();
10735
+ // Note: the `failures` array's hard-fail gate (`if (failures.length > 0)
10736
+ // process.exit(1)`) runs earlier in this function, before this
10737
+ // profile-marker-only branch is ever reached — pushing to it here would
10738
+ // be silently ineffective. Warn instead; a failed hooks copy still
10739
+ // leaves kimi's skills/agents artifacts installed correctly.
10740
+ if (!installSharedHooksBundle(kimiHooksRoot)) {
10741
+ console.warn(` ${yellow}⚠${reset} Kimi hook bundle did not verify at ${path.join(kimiHooksRoot, 'hooks')} — GSD lifecycle hooks may be incomplete`);
10742
+ }
10743
+ const kimiHookOpts = { portableHooks: hasPortableHooks, runtime };
10744
+ const kimiHooksTomlPath = path.join(kimiHooksRoot, 'config.toml');
10745
+ const kimiHooksResult = writeKimiHooksToml(kimiHooksTomlPath, kimiHooksRoot, { hookOpts: kimiHookOpts });
10746
+ if (kimiHooksResult.changed) {
10747
+ console.log(` ${green}✓${reset} Configured ${kimiHooksResult.entryCount} GSD hook(s) in ${kimiHooksTomlPath}`);
10748
+ }
10749
+ }
10750
+
10751
+ // ADR-1239 / #2100 Stage 2: Windsurf's own independent hooksSurface —
10752
+ // Cascade's native hooks.json blocking hook bus (pre_write_code,
10753
+ // pre_run_command), wired via runtime-hooks-surface.cts exactly like
10754
+ // Cursor's writeCursorHooksJson but with Cascade's exit-code-2 blocking
10755
+ // protocol instead of Cursor's stdout-JSON form. Unlike kimi's branch
10756
+ // above, this is NOT gated to `isGlobal` — Windsurf has no
10757
+ // hostBehaviors.localInstallDeferred early-return, so both local
10758
+ // (.windsurf/hooks.json) and global (~/.codeium/windsurf/hooks.json)
10759
+ // installs reach this branch and must get the hook bus wired.
10760
+ if (plan.hooksSurface === 'windsurf-hooks-json') {
10761
+ const windsurfHookResult = writeWindsurfHooksJson(targetDir, src, {
10762
+ platform: process.platform,
10763
+ });
10764
+ if (windsurfHookResult.changed) {
10765
+ console.log(` ${green}✓${reset} Configured Windsurf lifecycle hooks (pre_write_code, pre_run_command)`);
10766
+ } else {
10767
+ console.log(` ${green}✓${reset} Windsurf lifecycle hooks already up to date`);
10768
+ }
10769
+ // Re-run the manifest pass, mirroring the cursor writer's pattern above
10770
+ // for parity. This does NOT hash-track hooks.json or the
10771
+ // gsd-windsurf-*.js scripts (same as cursor): uninstall removes them
10772
+ // explicitly via removeWindsurfHooksJson, and reconcileWindsurfHooksJson
10773
+ // is idempotent on repeated installs, so manifest tracking isn't needed
10774
+ // for correctness here.
10775
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
10776
+ }
10777
+
10041
10778
  persistActiveProfileMarker();
10042
10779
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
10043
10780
  }
@@ -10165,7 +10902,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10165
10902
  // Claude Code sets $CLAUDE_PROJECT_DIR; Antigravity does not — and on
10166
10903
  // Windows its own substitution logic doubles the path (#2557). It runs
10167
10904
  // project hooks with the project dir as cwd, so bare relative paths work.
10168
- const localPrefix = projectLocalHookPrefix({ runtime, dirName });
10905
+ // Descriptor-driven (ADR-1239 / #2096): hookPathStyle comes from the
10906
+ // runtime's hostBehaviors instead of a hardcoded `runtime === 'antigravity'`
10907
+ // check inside projectLocalHookPrefix.
10908
+ const localPrefix = projectLocalHookPrefix({ runtime, dirName, hookPathStyle: _hostBehaviors(runtime).hookPathStyle });
10169
10909
  const hookOpts = { portableHooks: hasPortableHooks, runtime };
10170
10910
  // #2979: local-install hook commands also use the absolute node path so
10171
10911
  // GUI/minimal-PATH runtimes can resolve them. Bare `node` fails when the
@@ -10338,7 +11078,16 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
10338
11078
  * Apply statusline config, then print completion message
10339
11079
  */
10340
11080
  function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) {
10341
- const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
11081
+ // #2093: isKilo dropped — the Kilo permissions-writer call below is gated
11082
+ // on plan.finishPermissionWriter === 'kilo' (descriptor-driven), not this flag.
11083
+ // #2094: isTrae dropped — unused in this function.
11084
+ // #2095: isKimi dropped — the Kimi "Done!" banner below reads
11085
+ // _hostBehaviors(runtime).doneBannerStyle === 'kimi-agent-file' (descriptor-driven), not this flag.
11086
+ // #2096: isAntigravity dropped — unused in this function.
11087
+ // #2098: isCodebuddy dropped — unused in this function.
11088
+ // #2099: isCopilot dropped — unused in this function.
11089
+ // #2100: isWindsurf dropped — unused in this function.
11090
+ const { isOpencode, isCodex, isCursor, isAugment, isQwen, isHermes, isCline } = runtimeFlags(runtime);
10342
11091
  const plan = resolveInstallPlan(runtime);
10343
11092
 
10344
11093
  if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
@@ -10400,6 +11149,12 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10400
11149
  mergeClaudePermissions(settings);
10401
11150
  }
10402
11151
 
11152
+ // #2097 UPGRADE 3 (transport:mcp): companion MCP server for runtimes that host
11153
+ // MCP in settings.json (Augment). settings.json is golden-excluded, so no golden change.
11154
+ if (_hostBehaviors(runtime).mcpCompanion === 'settings-json' && settings && plan.writesSharedSettings) {
11155
+ mergeGsdMcpServerIntoSettings(settings);
11156
+ }
11157
+
10403
11158
  // Write settings when runtime supports settings.json.
10404
11159
  // #3002 CR: defense-in-depth — re-run validateHookFields right before
10405
11160
  // serialization. The push-site guards above already skip null-command
@@ -10421,6 +11176,15 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10421
11176
  configureKiloPermissions(isGlobal, configDir);
10422
11177
  }
10423
11178
 
11179
+ // Configure Antigravity permissions + MCP companion server (#2096 Phase B
11180
+ // Upgrades 1+2). Not GSD_TEST_MODE-gated — mirrors Kilo's dispatch exactly;
11181
+ // both writers target files (settings.json, mcp_config.json) scoped under
11182
+ // this runtime's own configDir, so they are safe to run unconditionally.
11183
+ if (plan.finishPermissionWriter === 'antigravity') {
11184
+ configureAntigravityPermissions(isGlobal, configDir);
11185
+ configureAntigravityMcpConfig(isGlobal, configDir);
11186
+ }
11187
+
10424
11188
  // For non-Claude runtimes, DEFAULT resolve_model_ids to "omit" in ~/.gsd/defaults.json
10425
11189
  // when it is absent or falsy, so resolveModelInternal() returns '' instead of Claude
10426
11190
  // aliases (opus/sonnet/haiku) the runtime can't resolve. An explicit `true` opt-in
@@ -10480,7 +11244,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10480
11244
  return;
10481
11245
  }
10482
11246
 
10483
- if (runtime === 'kimi') {
11247
+ if (_hostBehaviors(runtime).doneBannerStyle === 'kimi-agent-file') {
10484
11248
  const agentPath = configDir ? path.join(configDir, 'agents', 'gsd.yaml') : 'agents/gsd.yaml';
10485
11249
  console.log(`
10486
11250
  ${green}Done!${reset} Start ${program} with ${cyan}kimi --agent-file ${agentPath}${reset}, then run ${cyan}${command}${reset}.
@@ -10568,13 +11332,14 @@ const runtimeMap = {
10568
11332
  '10': 'kimi',
10569
11333
  '11': 'kilo',
10570
11334
  '12': 'opencode',
10571
- '13': 'qwen',
10572
- '14': 'trae',
10573
- '15': 'windsurf',
10574
- '16': 'zcode'
11335
+ '13': 'pi',
11336
+ '14': 'qwen',
11337
+ '15': 'trae',
11338
+ '16': 'windsurf',
11339
+ '17': 'zcode'
10575
11340
  };
10576
- const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf', 'zcode'];
10577
- const ALL_RUNTIMES_OPTION = '17';
11341
+ const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'hermes', 'kimi', 'kilo', 'opencode', 'pi', 'qwen', 'trae', 'windsurf', 'zcode'];
11342
+ const ALL_RUNTIMES_OPTION = '18';
10578
11343
 
10579
11344
  /**
10580
11345
  * Build the runtime-selection prompt text shown by the interactive installer.
@@ -10594,11 +11359,12 @@ function buildRuntimePromptText() {
10594
11359
  ${cyan}10${reset}) Kimi ${dim}(~/.config/agents, then ~/.agents if existing)${reset}
10595
11360
  ${cyan}11${reset}) Kilo ${dim}(~/.config/kilo)${reset}
10596
11361
  ${cyan}12${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
10597
- ${cyan}13${reset}) Qwen Code ${dim}(~/.qwen)${reset}
10598
- ${cyan}14${reset}) Trae ${dim}(~/.trae)${reset}
10599
- ${cyan}15${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
10600
- ${cyan}16${reset}) ZCode ${dim}(~/.zcode)${reset}
10601
- ${cyan}17${reset}) All
11362
+ ${cyan}13${reset}) pi ${dim}(~/.pi/agent)${reset}
11363
+ ${cyan}14${reset}) Qwen Code ${dim}(~/.qwen)${reset}
11364
+ ${cyan}15${reset}) Trae ${dim}(~/.trae)${reset}
11365
+ ${cyan}16${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
11366
+ ${cyan}17${reset}) ZCode ${dim}(~/.zcode)${reset}
11367
+ ${cyan}18${reset}) All
10602
11368
 
10603
11369
  ${dim}Select multiple: 1,2,6 or 1 2 6${reset}
10604
11370
  `;
@@ -11145,8 +11911,8 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
11145
11911
  '.agents', // antigravity local form (canonical, #791)
11146
11912
  '.agent', // antigravity local form (legacy, backward-compat)
11147
11913
  '.cursor',
11148
- '.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
11149
- '.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
11914
+ '.devin', // windsurf local form (legacy, pre-#1615; Devin Desktop preferred dir, #1085)
11915
+ '.windsurf', // windsurf local form (canonical since #1615; capability.json localConfigDir)
11150
11916
  '.codeium/windsurf',
11151
11917
  '.augment',
11152
11918
  '.trae',
@@ -11398,6 +12164,13 @@ module.exports = {
11398
12164
  getConfigDirFromHome,
11399
12165
  resolveKiloConfigPath,
11400
12166
  configureKiloPermissions,
12167
+ // #2096 Phase B Upgrades 1+2 — Antigravity permission-writer + MCP companion
12168
+ toTildePosixPath,
12169
+ buildAntigravityAllowRules,
12170
+ configureAntigravityPermissions,
12171
+ configureAntigravityMcpConfig,
12172
+ // #2097 UPGRADE 3 — Augment MCP companion (settings.json-hosted)
12173
+ mergeGsdMcpServerIntoSettings,
11401
12174
  claudeToCopilotTools,
11402
12175
  convertCopilotToolName,
11403
12176
  convertClaudeToCopilotContent,
@@ -11450,6 +12223,11 @@ module.exports = {
11450
12223
  reconcileCursorHooksJson,
11451
12224
  writeCursorHooksJson,
11452
12225
  removeCursorHooksJson,
12226
+ GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
12227
+ GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
12228
+ GSD_WINDSURF_HOOK_SCRIPTS,
12229
+ writeWindsurfHooksJson,
12230
+ removeWindsurfHooksJson,
11453
12231
  stripGsdFromAgentsMd,
11454
12232
  GSD_AGENTS_MD_MARKER,
11455
12233
  GSD_AGENTS_MD_CLOSE_MARKER,