@opengsd/gsd-core 1.7.0-rc.4 → 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 (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +20 -0
  4. package/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +30 -0
  7. package/agents/gsd-ui-researcher.md +1 -0
  8. package/bin/install.js +1568 -622
  9. package/gsd-core/bin/gsd-tools.cjs +40 -1
  10. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  11. package/gsd-core/bin/lib/audit.cjs +6 -3
  12. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  13. package/gsd-core/bin/lib/capability-registry.cjs +761 -84
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  16. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  17. package/gsd-core/bin/lib/commands.cjs +7 -5
  18. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +96 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  21. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  22. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +45 -4
  24. package/gsd-core/bin/lib/init.cjs +76 -39
  25. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  26. package/gsd-core/bin/lib/install-engine.cjs +228 -18
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
  28. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  29. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  30. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  31. package/gsd-core/bin/lib/milestone.cjs +3 -3
  32. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  34. package/gsd-core/bin/lib/phase.cjs +78 -16
  35. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  36. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  37. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  39. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  40. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
  42. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
  44. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  45. package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
  46. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
  47. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  48. package/gsd-core/bin/lib/security.cjs +6 -36
  49. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  50. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  51. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  52. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  53. package/gsd-core/bin/lib/state.cjs +24 -24
  54. package/gsd-core/bin/lib/surface.cjs +40 -6
  55. package/gsd-core/bin/lib/uat.cjs +4 -1
  56. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  57. package/gsd-core/bin/lib/validate.cjs +15 -6
  58. package/gsd-core/bin/lib/verify.cjs +33 -37
  59. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  60. package/gsd-core/bin/shared/model-catalog.json +14 -9
  61. package/gsd-core/references/api-coverage.md +104 -0
  62. package/gsd-core/references/model-profiles.md +2 -2
  63. package/gsd-core/references/planning-config.md +2 -0
  64. package/gsd-core/references/specless-probe-fallback.md +172 -0
  65. package/gsd-core/references/ui-consideration-probe.md +73 -0
  66. package/gsd-core/templates/UI-SPEC.md +25 -0
  67. package/gsd-core/templates/VALIDATION.md +2 -0
  68. package/gsd-core/templates/config.json +2 -1
  69. package/gsd-core/workflows/audit-fix.md +9 -1
  70. package/gsd-core/workflows/audit-milestone.md +7 -4
  71. package/gsd-core/workflows/code-review-fix.md +7 -3
  72. package/gsd-core/workflows/code-review.md +4 -1
  73. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  74. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  75. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  76. package/gsd-core/workflows/execute-phase.md +1 -25
  77. package/gsd-core/workflows/plan-phase.md +37 -2
  78. package/gsd-core/workflows/quick.md +2 -2
  79. package/gsd-core/workflows/review.md +59 -13
  80. package/gsd-core/workflows/settings-advanced.md +12 -9
  81. package/gsd-core/workflows/settings.md +2 -2
  82. package/gsd-core/workflows/ui-phase.md +146 -1
  83. package/gsd-core/workflows/validate-phase.md +2 -2
  84. package/gsd-core/workflows/verify-phase.md +3 -2
  85. package/gsd-core/workflows/verify-work.md +38 -0
  86. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  87. package/hooks/dist/gsd-cursor-stop.js +48 -0
  88. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  89. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  90. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  91. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  92. package/hooks/dist/managed-hooks-registry.cjs +6 -0
  93. package/hooks/gsd-cursor-pre-tool.js +76 -0
  94. package/hooks/gsd-cursor-stop.js +48 -0
  95. package/hooks/gsd-cursor-subagent-start.js +50 -0
  96. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  97. package/hooks/gsd-windsurf-pre-command.js +275 -0
  98. package/hooks/gsd-windsurf-pre-write.js +132 -0
  99. package/hooks/managed-hooks-registry.cjs +6 -0
  100. package/package.json +9 -4
  101. package/pi/gsd.cjs +354 -0
  102. package/scripts/build-hooks.js +8 -1
  103. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  104. package/scripts/gen-registry.cjs +128 -0
  105. package/scripts/lint-phase-id-drift.cjs +150 -0
  106. package/scripts/lint-test-file-count.allowlist.json +2 -1
  107. package/scripts/registry-schema.cjs +565 -0
  108. package/scripts/run-tests.cjs +21 -1
  109. package/scripts/validate-registry.cjs +117 -0
  110. package/vscode/browser.js +197 -0
  111. package/vscode/extension.js +383 -0
  112. package/vscode/host-binding.js +113 -0
  113. 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
@@ -43,6 +44,7 @@ const {
43
44
  readBaseRefFromSettings,
44
45
  } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
45
46
  const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
47
+ const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
46
48
  const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
47
49
  // Canonical set of hook files shipped to users. Imported here so writeManifest()
48
50
  // records exactly the same set that build-hooks.js copies to hooks/dist/, making
@@ -57,25 +59,20 @@ const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
57
59
  const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
58
60
 
59
61
  /**
60
- * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
61
- * verbatim (only branding swaps, no namespace conversion), so retired
62
- * `/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
63
66
  * #3583 / #3629 covered SKILL.md bodies, #3584 / #3606 covered runtime
64
67
  * emissions — this is the agent-body surface (#3677).
65
68
  *
66
- * Explicit allow-list rather than deny-list so unknown / future runtimes
67
- * default to "no rewrite" (better to leak than to mangle a runtime whose
68
- * namespace behavior we haven't verified).
69
- */
70
- const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']);
71
-
72
- /**
73
- * #3677 predicate — true when an agent body needs `/gsd:<cmd>` → `/gsd-<cmd>`
74
- * 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).
75
72
  */
76
73
  function shouldNormalizeHyphenNamespaceInAgentBody(runtime) {
77
74
  if (typeof runtime !== 'string' || runtime === '') return false;
78
- return HYPHEN_NAME_AGENT_RUNTIMES.has(runtime);
75
+ return _hostBehaviors(runtime).hyphenNameAgentBody === true;
79
76
  }
80
77
 
81
78
  /**
@@ -101,6 +98,45 @@ const reset = '\x1b[0m';
101
98
  // Codex config.toml constants
102
99
  const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by gsd-core installer';
103
100
  const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: ';
101
+ // Known scalar fields of Codex's `AgentsToml` struct (codex-rs/config/src/
102
+ // config_toml.rs \u2014 `[agents]` table). Codex marks the struct
103
+ // `#[schemars(deny_unknown_fields)]`, so a bare `[agents]` table is valid ONLY
104
+ // when every direct key is one of these (named agent roles live in the flattened
105
+ // `[agents.<name>]` sub-tables, a separate `AgentRoleToml`). GSD writes only
106
+ // `max_depth` (ADR-1239 upgrade 2 / #2088); the full set is enumerated so the
107
+ // schema check accepts a user's other legitimate AgentsToml scalars too.
108
+ const CODEX_AGENTS_TOML_SCALAR_KEYS = new Set([
109
+ 'max_threads',
110
+ 'max_depth',
111
+ 'job_max_runtime_seconds',
112
+ 'interrupt_message',
113
+ ]);
114
+ // GSD's managed dispatch-depth value. Codex's implicit default is also 1 (root
115
+ // sessions start at depth 0); writing it EXPLICITLY pins the negotiated
116
+ // `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default
117
+ // (ADR-1239 upgrade 2 / #2088). Per the negotiated capability, GSD-hosted Codex
118
+ // dispatch is single-level (maxDepth === 1 \u2192 `degradationFor` flattens waves).
119
+ const GSD_CODEX_AGENTS_MAX_DEPTH = 1;
120
+ // Codex hooks.json lifecycle events GSD registers beyond SessionStart (which has
121
+ // its own dedicated path). This is Codex's OWN hook-event vocabulary (per
122
+ // developers.openai.com/codex/config-reference), distinct from the cross-runtime
123
+ // settings.json `extendedHookEvents` descriptor field (a claude/gemini-family
124
+ // allowlist consumed only by hooksSurface==='settings-json' runtimes — Codex is
125
+ // codex-hooks-json). All route through gsd-context-monitor.js. #772 wired the
126
+ // first three; #2088 adds the remaining six documented events so GSD's monitor
127
+ // fires at the same lifecycle points as in Claude Code. Install and uninstall
128
+ // share this list so the registered set and the removed set never diverge.
129
+ const CODEX_EXTENDED_HOOK_EVENTS = [
130
+ 'SubagentStart',
131
+ 'Stop',
132
+ 'PostToolUse',
133
+ 'PreToolUse',
134
+ 'PermissionRequest',
135
+ 'PreCompact',
136
+ 'PostCompact',
137
+ 'SubagentStop',
138
+ 'UserPromptSubmit',
139
+ ];
104
140
  // Codex's hook-enabling feature flag (issue #3566). Codex itself marks
105
141
  // `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
106
142
  // canonical current key under [features] is `hooks`. The installer always
@@ -126,6 +162,9 @@ function isCodexHooksFeatureKey(key) {
126
162
  //
127
163
  // Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
128
164
  // GSD entries are appended only when not already present (idempotent).
165
+ // The reference/default runtime (ADR-1239 reference host). Single-sourced here
166
+ // instead of scattered literal 'claude' defaults/rosters (#2086).
167
+ const DEFAULT_RUNTIME = 'claude';
129
168
  const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
130
169
  'Bash(npx gsd-core *)',
131
170
  'Read(.planning/*)',
@@ -212,15 +251,55 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
212
251
  // Cursor reads hook configs from <project-root>/.cursor/hooks.json (local) or
213
252
  // ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { <event>: [...] } }.
214
253
  // Events use camelCase: sessionStart, postToolUse, preToolUse, etc.
215
- // A `command` hook entry runs an external script. GSD registers two managed hooks:
216
- // sessionStart → gsd-cursor-session-start.js (context injection)
217
- // postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
254
+ // A `command` hook entry runs an external script. GSD registers six managed hooks
255
+ // (AC4a upgrade, #2089 — ADR-1239):
256
+ // sessionStart → gsd-cursor-session-start.js (context injection)
257
+ // postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
258
+ // preToolUse → gsd-cursor-pre-tool.js (write-path guard)
259
+ // stop → gsd-cursor-stop.js (verify-work reminder)
260
+ // subagentStart → gsd-cursor-subagent-start.js (subagent context injection)
261
+ // subagentStop → gsd-cursor-subagent-stop.js (subagent completion reminder)
218
262
  // Cursor docs: https://cursor.com/docs/hooks
219
263
  const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
220
264
  const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
265
+ const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
266
+ const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
267
+ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
268
+ const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
269
+ // All GSD-managed Cursor hook scripts (used by uninstall cleanup).
270
+ const GSD_CURSOR_HOOK_SCRIPTS = [
271
+ GSD_CURSOR_SESSION_HOOK_SCRIPT,
272
+ GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
273
+ GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
274
+ GSD_CURSOR_STOP_HOOK_SCRIPT,
275
+ GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
276
+ GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
277
+ ];
221
278
  // Marker comment embedded in managed hook entries so GSD can find+remove them.
222
279
  const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
223
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
+
224
303
  // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
225
304
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
226
305
  const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
@@ -273,60 +352,20 @@ const {
273
352
  } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
274
353
  const {
275
354
  resolveTierEntry: gsdResolveTierEntry,
276
- EFFORT_SET: GSD_EFFORT_SET,
277
355
  } = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
278
356
 
279
- // #443 — model-catalog and config-defaults.manifest.json exports needed only
280
- // by effort-resolution code paths (resolveInstallTimeEffort /
281
- // generateCodexAgentToml / Claude .md effort injection). Loaded lazily the
282
- // first time they are needed so that requiring install.js in test contexts that
283
- // never trigger an install does NOT produce module-load-time side effects (the
284
- // manifest read + hard throw) that could alter subprocess exit codes or stderr.
285
- let _gsdEffortCatalogCache = null;
286
- function _getGsdEffortCatalog() {
287
- if (_gsdEffortCatalogCache) return _gsdEffortCatalogCache;
288
-
289
- const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
290
-
291
- const manifestPath = path.join(
292
- __dirname,
293
- '..',
294
- 'gsd-core',
295
- 'bin',
296
- 'shared',
297
- 'config-defaults.manifest.json'
298
- );
299
- let manifestData;
300
- try {
301
- manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
302
- } catch (_err) {
303
- // Fail loudly — a missing manifest is a broken install, not a soft degradation.
304
- throw new Error(
305
- `gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${_err.message}`
306
- );
307
- }
308
-
309
- const tierDefaults =
310
- (manifestData.effort &&
311
- manifestData.effort.routing_tier_defaults &&
312
- typeof manifestData.effort.routing_tier_defaults === 'object' &&
313
- !Array.isArray(manifestData.effort.routing_tier_defaults))
314
- ? manifestData.effort.routing_tier_defaults
315
- : { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid
316
-
317
- const effortDefault =
318
- (manifestData.effort && typeof manifestData.effort.default === 'string')
319
- ? manifestData.effort.default
320
- : 'high'; // guard: unreachable if manifest is valid
321
-
322
- _gsdEffortCatalogCache = {
323
- AGENT_DEFAULT_TIERS,
324
- renderEffortForRuntime,
325
- EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults,
326
- EFFORT_MANIFEST_DEFAULT: effortDefault,
327
- };
328
- return _gsdEffortCatalogCache;
329
- }
357
+ // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig /
358
+ // resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile
359
+ // helpers) was extracted into the shipped gsd-core/bin/lib/install-effort-resolver.cjs
360
+ // so `gsd-tools effort sync` can require it from the installed runtime instead of this
361
+ // package-root bin/install.js, which the installer never copies (#2071 crash). The
362
+ // installer imports it back here — single source of truth for both surfaces.
363
+ const {
364
+ readGsdEffectiveEffortConfig,
365
+ resolveInstallTimeEffort,
366
+ _getGsdEffortCatalog,
367
+ _readGsdConfigFile,
368
+ } = require(path.join(_gsdLibDir, 'install-effort-resolver.cjs'));
330
369
 
331
370
  const {
332
371
  MINIMAL_SKILL_ALLOWLIST,
@@ -350,6 +389,89 @@ try {
350
389
  } catch (_) {
351
390
  _capabilityRegistry = undefined;
352
391
  }
392
+
393
+ // Fail-safe floor for the reference host's #338-privacy-critical behaviors, used
394
+ // ONLY when the first-party capability registry cannot be loaded (a broken bundle).
395
+ // Without it, a registry-load failure would make `_hostBehaviors('claude')` return
396
+ // {} and silently route a claude LOCAL install to the repo-shared, committed
397
+ // `settings.json` instead of the gitignored `settings.local.json` (#338) — leaking
398
+ // engineer-specific absolute paths. Keyed by runtime id (a DATA lookup, not a
399
+ // hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
400
+ // The live descriptor (capabilities/claude/capability.json) remains the source of
401
+ // truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
402
+ const FALLBACK_HOST_BEHAVIORS = Object.freeze({
403
+ claude: Object.freeze({
404
+ settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
405
+ permissionsSchema: 'claude',
406
+ sourceMarkerFile: '.gsd-source',
407
+ hyphenNameAgentBody: true,
408
+ legacyCommandsGsdInstallMigration: true,
409
+ legacyCommandsGsdUninstall: 'global',
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' }),
417
+ });
418
+
419
+ /**
420
+ * Resolve a runtime's host behaviors from a capability registry, with the
421
+ * #338-privacy fail-safe floor when the registry (or the runtime's descriptor)
422
+ * is unavailable. Registry is passed in so this is unit-testable under a
423
+ * simulated registry-load failure. (ADR-1239 / #2086)
424
+ */
425
+ function _resolveHostBehaviors(runtime, registry) {
426
+ const cap = registry && registry.runtimes && registry.runtimes[runtime];
427
+ const declared = cap && cap.runtime && cap.runtime.hostBehaviors;
428
+ if (declared) return declared;
429
+ return FALLBACK_HOST_BEHAVIORS[runtime] || {};
430
+ }
431
+
432
+ /**
433
+ * Host-specific install behaviors, declared on the runtime descriptor
434
+ * (capabilities/<runtime>/capability.json -> runtime.hostBehaviors) instead of
435
+ * scattered `runtime === '<id>'` string checks (ADR-1239 / #2086). Returns {}
436
+ * for runtimes that declare none, so every behavior branch degrades to the
437
+ * generic path by default — EXCEPT the reference host's #338-critical keys, which
438
+ * fall back to FALLBACK_HOST_BEHAVIORS if the registry failed to load.
439
+ */
440
+ function _hostBehaviors(runtime) {
441
+ return _resolveHostBehaviors(runtime, _capabilityRegistry);
442
+ }
443
+
444
+ /**
445
+ * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
446
+ * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
447
+ * $HOME/.agents/skills instead of the runtime's configDir). Descriptor-driven
448
+ * (no runtime === '<id>' check) so the snapshot/rollback machinery and post-install
449
+ * verification look where the skills actually landed. Falls back to <targetDir>/skills.
450
+ */
451
+ function _resolveSkillsRootDir(runtime, targetDir, scope) {
452
+ try {
453
+ const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
454
+ const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
455
+ if (skillsKind) return path.join(skillsKind.home || targetDir, skillsKind.destSubpath);
456
+ } catch (_e) { /* fall through to the configDir default */ }
457
+ return path.join(targetDir, 'skills');
458
+ }
459
+
460
+ /**
461
+ * Construct the imperative Host-Integration adapter (ADR-1239 / #2086), FAIL-OPEN.
462
+ * `createImperativeAdapter` composes the capability registry via
463
+ * `loadRegistry({includeInstalled:true})`, which require()s several capability
464
+ * modules. If any is unavailable (e.g. a packaging regression), return null so
465
+ * the caller degrades to the engine directly rather than hard-crashing install/
466
+ * uninstall — matching the optional `capability-registry.cjs` load posture above.
467
+ */
468
+ function _runtimeAdapter(runtime) {
469
+ try {
470
+ return createImperativeAdapter({ runtime });
471
+ } catch {
472
+ return null;
473
+ }
474
+ }
353
475
  const {
354
476
  applyInstallerMigrationPlan,
355
477
  discoverInstallerMigrations,
@@ -385,6 +507,7 @@ const {
385
507
  installRuntimeArtifacts,
386
508
  uninstallRuntimeArtifacts,
387
509
  installOpencodeFamilySkills,
510
+ _installNativePluginIfDeclared,
388
511
  _copyStaged,
389
512
  hasExistingSymlinkBetween,
390
513
  preserveUserArtifacts,
@@ -434,7 +557,7 @@ if (hasMinimal && _profileArgRaw) {
434
557
 
435
558
  function selectRuntimesFromArgs(runtimeArgs) {
436
559
  if (runtimeArgs.includes('--all')) {
437
- 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'];
438
561
  }
439
562
  if (runtimeArgs.includes('--both')) {
440
563
  return ['claude', 'opencode'];
@@ -443,6 +566,7 @@ function selectRuntimesFromArgs(runtimeArgs) {
443
566
  const selected = [];
444
567
  if (runtimeArgs.includes('--claude')) selected.push('claude');
445
568
  if (runtimeArgs.includes('--opencode')) selected.push('opencode');
569
+ if (runtimeArgs.includes('--pi')) selected.push('pi');
446
570
  if (runtimeArgs.includes('--kilo')) selected.push('kilo');
447
571
  if (runtimeArgs.includes('--codex')) selected.push('codex');
448
572
  if (runtimeArgs.includes('--copilot')) selected.push('copilot');
@@ -546,7 +670,15 @@ function getConfigDirFromHome(runtime, isGlobal) {
546
670
  // multi-segment via resolveAntigravityGlobalDir + path.relative) — not a table
547
671
  // entry. (The prior inner `if (!isGlobal) return "'.agents'"` was unreachable:
548
672
  // !isGlobal returns at the top of this function.)
549
- 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') {
550
682
  const antigravityDir = resolveAntigravityGlobalDir();
551
683
  const rel = path.relative(os.homedir(), antigravityDir);
552
684
  const segments = rel.split(path.sep).filter(Boolean);
@@ -580,7 +712,7 @@ const banner = '\n' +
580
712
  ' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
581
713
  ' Git. Ship. Done.\n' +
582
714
  ' A meta-prompting, context engineering and spec-driven\n' +
583
- ' 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';
584
716
 
585
717
  // Pure seam: parse --config-dir / -c from an arbitrary args array.
586
718
  // Returns the path string, '' for an empty equals-form value, or null when the
@@ -661,6 +793,10 @@ const referencesHook = hooksSurface.referencesHook;
661
793
  // applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
662
794
  // hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
663
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;
664
800
  // processAttribution: pure Co-Authored-By content transform, relocated to the
665
801
  // conversion module (ADR-1508 / #1510 Phase 1). Bound here so install.js
666
802
  // callers continue to work and there is a single implementation. (All call
@@ -910,6 +1046,9 @@ function resolveKiloConfigPath(configDir) {
910
1046
  return path.join(configDir, 'kilo.json');
911
1047
  }
912
1048
 
1049
+ // #2087 — attribution config-path resolvers, keyed by descriptor (hostBehaviors.attributionConfigResolver)
1050
+ const ATTRIBUTION_CONFIG_RESOLVERS = { opencode: resolveOpencodeConfigPath, kilo: resolveKiloConfigPath };
1051
+
913
1052
  /**
914
1053
  * Strip JSONC comments (// and /* *​/) from a string to produce valid JSON.
915
1054
  * Handles comments inside strings correctly (does not strip them).
@@ -1071,126 +1210,6 @@ function readGsdEffectiveModelOverrides(targetDir = null) {
1071
1210
  return { ...(global || {}), ...(projectOverrides || {}) };
1072
1211
  }
1073
1212
 
1074
- /**
1075
- * #443 — Read the merged `effort` config block for install-time effort resolution.
1076
- *
1077
- * Probes the same config sources as readGsdRuntimeProfileResolver (per-project
1078
- * `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the
1079
- * `effort` object instead of the model-profile fields.
1080
- *
1081
- * Returns the merged `effort` object or null when neither source defines one.
1082
- * The caller can pass this to resolveInstallTimeEffort() which is pure and
1083
- * requires no filesystem access beyond what this helper already performs.
1084
- *
1085
- * @param {string|null} targetDir Runtime install root (walks up to find .planning/).
1086
- * @returns {object|null}
1087
- */
1088
- function readGsdEffectiveEffortConfig(targetDir = null) {
1089
- const homeDefaults = _readGsdConfigFile(
1090
- path.join(os.homedir(), '.gsd', 'defaults.json'),
1091
- '~/.gsd/defaults.json'
1092
- );
1093
-
1094
- let projectConfig = null;
1095
- if (targetDir) {
1096
- let probeDir = path.resolve(targetDir);
1097
- for (let depth = 0; depth < 8; depth += 1) {
1098
- const candidate = path.join(probeDir, '.planning', 'config.json');
1099
- if (fs.existsSync(candidate)) {
1100
- projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
1101
- break;
1102
- }
1103
- const parent = path.dirname(probeDir);
1104
- if (parent === probeDir) break;
1105
- probeDir = parent;
1106
- }
1107
- }
1108
-
1109
- const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort))
1110
- ? homeDefaults.effort
1111
- : null;
1112
- const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort))
1113
- ? projectConfig.effort
1114
- : null;
1115
-
1116
- if (!homeEffort && !projectEffort) return null;
1117
-
1118
- // Per-project wins on conflict within each sub-field. Merge field-by-field so
1119
- // a project config that only sets agent_overrides still inherits global
1120
- // routing_tier_defaults and default.
1121
- return {
1122
- ...(homeEffort || {}),
1123
- ...(projectEffort || {}),
1124
- // Deep-merge agent_overrides (project wins per-key)
1125
- agent_overrides: {
1126
- ...((homeEffort && homeEffort.agent_overrides) || {}),
1127
- ...((projectEffort && projectEffort.agent_overrides) || {}),
1128
- },
1129
- };
1130
- }
1131
-
1132
-
1133
- /**
1134
- * #443 — Resolve install-time effort for a given agent, using the same
1135
- * precedence chain as resolveEffortInternal() in core.cjs, but operating
1136
- * on a pre-loaded effortCfg object (no loadConfig side-effects at install).
1137
- *
1138
- * Precedence (mirrors resolveEffortInternal):
1139
- * 1. effortCfg.agent_overrides[agentName]
1140
- * 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present)
1141
- * — OR manifest tier defaults when effortCfg is null
1142
- * 3. effortCfg.default
1143
- * 4. 'high' (hardcoded fallback)
1144
- *
1145
- * @param {object|null} effortCfg Result of readGsdEffectiveEffortConfig().
1146
- * @param {string} agentName e.g. 'gsd-planner'
1147
- * @returns {string} Universal effort string (low/medium/high/xhigh/max/minimal)
1148
- */
1149
- function resolveInstallTimeEffort(effortCfg, agentName) {
1150
- // Validates each candidate against the canonical EFFORT_SET (sourced once
1151
- // from core.cjs) before accepting it, mirroring resolveEffortInternal exactly.
1152
- // Invalid values fall through to the next precedence layer; final fallback 'high'.
1153
-
1154
- // Step 1: agent_overrides
1155
- if (effortCfg) {
1156
- const ao = effortCfg.agent_overrides;
1157
- if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
1158
- const v = ao[agentName];
1159
- if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
1160
- }
1161
- }
1162
-
1163
- // Step 2: routing_tier_defaults keyed by the agent's catalog tier
1164
- const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog();
1165
- const agentTier = AGENT_DEFAULT_TIERS[agentName];
1166
- if (agentTier) {
1167
- if (effortCfg && effortCfg.routing_tier_defaults &&
1168
- typeof effortCfg.routing_tier_defaults === 'object' &&
1169
- !Array.isArray(effortCfg.routing_tier_defaults)) {
1170
- const v = effortCfg.routing_tier_defaults[agentTier];
1171
- if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
1172
- } else if (!effortCfg) {
1173
- // No effort config — use manifest tier defaults
1174
- const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier];
1175
- if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
1176
- }
1177
- // effortCfg exists but has no routing_tier_defaults — fall through
1178
- }
1179
-
1180
- // Step 3: effort.default
1181
- if (effortCfg) {
1182
- const d = effortCfg.default;
1183
- if (typeof d === 'string' && GSD_EFFORT_SET.has(d)) return d;
1184
- }
1185
-
1186
- // Step 4: manifest default (sourced from config-defaults.manifest.json effort.default)
1187
- // If even the manifest default is invalid, fall back to 'high'.
1188
- if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) {
1189
- return EFFORT_MANIFEST_DEFAULT;
1190
- }
1191
- return 'high';
1192
- }
1193
-
1194
1213
  /**
1195
1214
  * #443 — Inject `effort: <value>` into YAML frontmatter of a Claude .md agent
1196
1215
  * file in a newline-agnostic way (LF and CRLF source files are both handled).
@@ -1296,29 +1315,6 @@ const READONLY_AGENT_DISALLOWED_TOOLS = {
1296
1315
  'gsd-ui-auditor': 'Edit, MultiEdit',
1297
1316
  };
1298
1317
 
1299
- /**
1300
- * #2517 — Read a single GSD config file (defaults.json or per-project
1301
- * config.json) into a plain object, returning null on missing/empty files
1302
- * and warning to stderr on JSON parse failures so silent corruption can't
1303
- * mask broken configs (review finding #5).
1304
- */
1305
- function _readGsdConfigFile(absPath, label) {
1306
- if (!fs.existsSync(absPath)) return null;
1307
- let raw;
1308
- try {
1309
- raw = fs.readFileSync(absPath, 'utf-8');
1310
- } catch (err) {
1311
- process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`);
1312
- return null;
1313
- }
1314
- try {
1315
- return JSON.parse(raw);
1316
- } catch (err) {
1317
- process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`);
1318
- return null;
1319
- }
1320
- }
1321
-
1322
1318
  /**
1323
1319
  * #2517 — Build a runtime-aware tier resolver for the install path.
1324
1320
  *
@@ -1421,15 +1417,14 @@ function getCommitAttribution(runtime) {
1421
1417
 
1422
1418
  let result;
1423
1419
 
1424
- if (runtime === 'opencode' || runtime === 'kilo') {
1425
- const resolveConfigPath = runtime === 'opencode'
1426
- ? resolveOpencodeConfigPath
1427
- : resolveKiloConfigPath;
1420
+ const _attrResolverKey = _hostBehaviors(runtime).attributionConfigResolver;
1421
+ if (_attrResolverKey && ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]) {
1422
+ const resolveConfigPath = ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey];
1428
1423
  const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
1429
1424
  result = (config && config.disable_ai_attribution === true) ? null : undefined;
1430
- } else if (runtime === 'claude') {
1425
+ } else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') {
1431
1426
  // Claude Code
1432
- const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
1427
+ const settings = readSettings(path.join(getGlobalConfigDir(runtime, explicitConfigDir), 'settings.json'));
1433
1428
  if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
1434
1429
  result = undefined;
1435
1430
  } else if (settings.attribution.commit === '') {
@@ -1897,11 +1892,13 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
1897
1892
  // Hermes' SKILL.md spec lists `version` as a required frontmatter field.
1898
1893
  // Track GSD's package version so Hermes' skill_view() reports a stable
1899
1894
  // identifier per install.
1900
- if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
1901
- // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
1902
- // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
1903
- // we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
1904
- if (runtime === 'qwen') {
1895
+ if (_hostBehaviors(runtime).skillFrontmatterVersion) fm += `version: ${yamlQuote(pkg.version)}\n`;
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) {
1905
1902
  const stem = typeof skillName === 'string' && skillName.startsWith('gsd-')
1906
1903
  ? skillName.slice(4)
1907
1904
  : skillName;
@@ -1946,6 +1943,14 @@ function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) {
1946
1943
  .replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`);
1947
1944
  }
1948
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).
1949
1954
  function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cmdNames = null) {
1950
1955
  const { frontmatter, body } = extractFrontmatterAndBody(content);
1951
1956
  const kimiSkillName = normalizeKimiSkillName(skillName);
@@ -2090,6 +2095,16 @@ function buildKimiSubagentYaml({ name, description, tools }) {
2090
2095
  return `${lines.join('\n')}\n`;
2091
2096
  }
2092
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).
2093
2108
  function buildKimiAgentArtifacts({
2094
2109
  rootAgent = '',
2095
2110
  subagents = [],
@@ -2615,14 +2630,6 @@ function convertClaudeAgentToWindsurfAgent(content) {
2615
2630
  // Augment uses a tool set similar to Cursor/Windsurf.
2616
2631
  // Config lives in .augment/ (local) and ~/.augment/ (global).
2617
2632
 
2618
- const claudeToAugmentTools = {
2619
- Bash: 'launch-process',
2620
- Edit: 'str-replace-editor',
2621
- AskUserQuestion: null,
2622
- SlashCommand: null,
2623
- TodoWrite: 'add_tasks',
2624
- };
2625
-
2626
2633
  // #1675 (ADR-1508): the augment converter family below was a byte-identical
2627
2634
  // duplicate of runtime-artifact-conversion.cjs:
2628
2635
  // convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown,
@@ -2664,6 +2671,14 @@ function convertClaudeToTraeMarkdown(content) {
2664
2671
  return converted;
2665
2672
  }
2666
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).
2667
2682
  function convertClaudeCommandToTraeSkill(content, skillName) {
2668
2683
  const converted = convertClaudeToTraeMarkdown(content);
2669
2684
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
@@ -2678,7 +2693,16 @@ function convertClaudeCommandToTraeSkill(content, skillName) {
2678
2693
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2679
2694
  // #2876: quote so YAML flow indicators (`[BETA] …`) don't break Trae's
2680
2695
  // frontmatter parser.
2681
- 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}`;
2682
2706
  }
2683
2707
 
2684
2708
  function convertClaudeAgentToTraeAgent(content) {
@@ -3266,6 +3290,77 @@ function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
3266
3290
  return removed;
3267
3291
  }
3268
3292
 
3293
+ /**
3294
+ * Migrate a skills kind that moved to an alternate `home` (ADR-1239 split-home):
3295
+ * remove now-stale `<prefix>*` skill dirs left at the OLD configDir-rooted
3296
+ * location by installs from before the move. Without this, upgrading (e.g. Codex
3297
+ * relocating skills to ~/.agents/skills) orphans the pre-move dirs at
3298
+ * ~/.codex/skills. Only managed `<prefix>*` dirs are touched; user-owned content
3299
+ * (non-prefixed dirs, gsd-dev-preferences, symlinks) is preserved. Fail-open.
3300
+ * @param {string} oldSkillsDir absolute path to the pre-move skills location
3301
+ * @param {string} prefix managed skill-dir prefix (e.g. 'gsd-')
3302
+ * @returns {number} count of stale dirs removed
3303
+ */
3304
+ function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) {
3305
+ if (!fs.existsSync(oldSkillsDir)) return 0;
3306
+
3307
+ // Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
3308
+ const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
3309
+ let removed = 0;
3310
+
3311
+ for (const entry of fs.readdirSync(oldSkillsDir, { withFileTypes: true })) {
3312
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
3313
+ if (_userOwnedSkillDirs.has(entry.name)) continue;
3314
+
3315
+ const dirToRemove = path.join(oldSkillsDir, entry.name);
3316
+ try {
3317
+ // Symlink guard (mirrors cleanupWindsurfLegacyDevinSkills): never delete
3318
+ // through a symlinked gsd-* dir — it could escape the tree.
3319
+ const stat = fs.lstatSync(dirToRemove);
3320
+ if (stat.isSymbolicLink()) continue;
3321
+
3322
+ fs.rmSync(dirToRemove, { recursive: true, force: true });
3323
+ removed++;
3324
+ } catch (_err) {
3325
+ // Fail open — a single bad dir must not block install/uninstall.
3326
+ }
3327
+ }
3328
+
3329
+ // Prune the old skills dir if now empty — leaves the configHome clean.
3330
+ // Never remove a non-empty container (user may keep other content there).
3331
+ try {
3332
+ if (fs.existsSync(oldSkillsDir) && fs.readdirSync(oldSkillsDir).length === 0) {
3333
+ fs.rmdirSync(oldSkillsDir);
3334
+ }
3335
+ } catch (_err) {
3336
+ // best-effort container cleanup
3337
+ }
3338
+
3339
+ return removed;
3340
+ }
3341
+
3342
+ /**
3343
+ * When a runtime's skills kind declares an alternate `home` (split-home move),
3344
+ * return the now-stale configDir-rooted skills location that installs before the
3345
+ * move used; null when no move is in effect (no home override, or home resolves
3346
+ * to the same path). Descriptor-driven — no per-runtime hardcoding.
3347
+ * @returns {string|null}
3348
+ */
3349
+ function _resolveMovedSkillsOldDir(runtime, targetDir, scope) {
3350
+ try {
3351
+ const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
3352
+ const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
3353
+ if (skillsKind && skillsKind.home) {
3354
+ const oldDir = path.join(targetDir, skillsKind.destSubpath);
3355
+ const newDir = path.join(skillsKind.home, skillsKind.destSubpath);
3356
+ if (path.resolve(oldDir) !== path.resolve(newDir)) return oldDir;
3357
+ }
3358
+ } catch (_e) {
3359
+ // No migration when the layout can't resolve — never block on this.
3360
+ }
3361
+ return null;
3362
+ }
3363
+
3269
3364
  /**
3270
3365
  * Generate the GSD config block for Codex config.toml.
3271
3366
  * @param {Array<{name: string, description: string}>} agents
@@ -3281,6 +3376,17 @@ function generateCodexConfigBlock(agents, targetDir) {
3281
3376
  '',
3282
3377
  ];
3283
3378
 
3379
+ // ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the
3380
+ // `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit
3381
+ // default, realizing the negotiated `dispatch.maxDepth: 1` axis. This bare
3382
+ // `[agents]` scalar table coexists with the flattened `[agents.<name>]` role
3383
+ // sub-tables below (validated by validateCodexConfigSchema, which permits a
3384
+ // known-scalar-only `[agents]`). Emitted before the role tables so the parent
3385
+ // table is opened first.
3386
+ lines.push('[agents]');
3387
+ lines.push(`max_depth = ${GSD_CODEX_AGENTS_MAX_DEPTH}`);
3388
+ lines.push('');
3389
+
3284
3390
  for (const { name, description } of agents) {
3285
3391
  // #2727 — Codex 0.124.0 requires [agents.<name>] struct format, not [[agents]] sequence.
3286
3392
  // [[agents]] (introduced in #2645) is rejected by codex-cli 0.124.0 with
@@ -3294,6 +3400,52 @@ function generateCodexConfigBlock(agents, targetDir) {
3294
3400
  return lines.join('\n');
3295
3401
  }
3296
3402
 
3403
+ /**
3404
+ * Extract a user's pre-existing AgentsToml scalar assignments from a bare
3405
+ * `[agents]` table — every known scalar EXCEPT `max_depth` (which GSD manages
3406
+ * and always re-emits as 1). Returned as raw `key = value` line strings so
3407
+ * mergeCodexConfig can PRESERVE them in the managed block instead of silently
3408
+ * dropping the user's tuning when the bare `[agents]` table is purged (#2088
3409
+ * review finding: the loosened validator declares such a table legitimate, so
3410
+ * install must not destroy it). Only the first bare `[agents]` section is read;
3411
+ * `[agents.<name>]` role tables are ignored. Fail-open → [].
3412
+ * @returns {string[]}
3413
+ */
3414
+ function extractCodexUserAgentsScalars(content) {
3415
+ const preserved = [];
3416
+ let section;
3417
+ try {
3418
+ section = getTomlTableSections(content).find((s) => !s.array && s.path === 'agents');
3419
+ } catch (_e) {
3420
+ return preserved;
3421
+ }
3422
+ if (!section) return preserved;
3423
+ const body = content.slice(section.headerEnd, section.end);
3424
+ for (const record of getTomlLineRecords(body)) {
3425
+ if (record.startsInMultilineString || record.tableHeader) continue;
3426
+ const trimmed = record.text.trim();
3427
+ if (!trimmed || trimmed.startsWith('#')) continue;
3428
+ if (!record.keySegments || record.keySegments.length !== 1) continue;
3429
+ const key = record.keySegments[0];
3430
+ if (key === 'max_depth') continue; // GSD-managed — GSD's value wins.
3431
+ if (!CODEX_AGENTS_TOML_SCALAR_KEYS.has(key)) continue;
3432
+ preserved.push(trimmed);
3433
+ }
3434
+ return preserved;
3435
+ }
3436
+
3437
+ /**
3438
+ * Splice preserved user AgentsToml scalar lines into the managed GSD config
3439
+ * block, immediately after the `[agents]` header and before GSD's `max_depth`
3440
+ * line. Operates on the pre-EOL-normalization block (LF joins), matching only
3441
+ * the bare `[agents]` header (never `[agents.<name>]`). Returns the block
3442
+ * unchanged when there is nothing to preserve or the anchor is absent.
3443
+ */
3444
+ function spliceCodexAgentsScalars(block, scalarLines) {
3445
+ if (!scalarLines || scalarLines.length === 0) return block;
3446
+ return block.replace(/(\n\[agents\]\n)(max_depth = )/, `$1${scalarLines.join('\n')}\n$2`);
3447
+ }
3448
+
3297
3449
  /**
3298
3450
  * Strip any managed GSD agent sections from a TOML string.
3299
3451
  *
@@ -3316,6 +3468,16 @@ function stripCodexGsdAgentSections(content) {
3316
3468
  return true;
3317
3469
  }
3318
3470
 
3471
+ // GSD's managed `[agents]` scalar block (ADR-1239 upgrade 2 / #2088 — the
3472
+ // `max_depth` dispatch-tuning table). Install purges any pre-existing bare
3473
+ // `[agents]` and writes its own, so a known-scalar-only bare `[agents]` is
3474
+ // GSD-owned; strip it on uninstall. (The marker path already removes it via
3475
+ // the marker-to-EOF cut; this covers the no-marker fallback.)
3476
+ if (!section.array && section.path === 'agents') {
3477
+ const body = content.slice(section.headerEnd, section.end);
3478
+ return codexBareAgentsHasOnlyKnownScalars(body);
3479
+ }
3480
+
3319
3481
  // Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose
3320
3482
  // `name = "gsd-..."`, preserving user-authored [[agents]] entries.
3321
3483
  if (section.array && section.path === 'agents') {
@@ -3343,7 +3505,11 @@ function stripGsdFromCodexConfig(content) {
3343
3505
  const codexHooksOwnership = getManagedCodexHooksOwnership(content);
3344
3506
 
3345
3507
  if (markerIndex !== -1) {
3346
- // Has GSD marker — remove everything from marker to EOF
3508
+ // Has GSD marker — remove everything from marker to EOF. First recover the
3509
+ // user's own AgentsToml scalars (max_threads etc.) that install folded into
3510
+ // the managed [agents] block (#2088), so a full install→uninstall cycle
3511
+ // round-trips the user's tuning. GSD-managed max_depth is dropped.
3512
+ const preservedScalars = extractCodexUserAgentsScalars(content.slice(markerIndex));
3347
3513
  let before = content.substring(0, markerIndex);
3348
3514
  before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership);
3349
3515
  // Also strip GSD-injected feature keys above the marker (Case 3 inject)
@@ -3352,6 +3518,9 @@ function stripGsdFromCodexConfig(content) {
3352
3518
  before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
3353
3519
  before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
3354
3520
  before = before.replace(/^(?:\r?\n)+/, '').trimEnd();
3521
+ if (preservedScalars.length > 0) {
3522
+ before = (before ? before + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
3523
+ }
3355
3524
  if (!before) return null;
3356
3525
  return before + eol;
3357
3526
  }
@@ -3362,7 +3531,11 @@ function stripGsdFromCodexConfig(content) {
3362
3531
  cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
3363
3532
  cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
3364
3533
 
3365
- // Remove [agents.gsd-*] sections (from header to next section or EOF)
3534
+ // #2088: recover the user's own AgentsToml scalars before the [agents] table is
3535
+ // stripped, so they survive uninstall even in the no-marker fallback path.
3536
+ const preservedScalars = extractCodexUserAgentsScalars(cleaned);
3537
+
3538
+ // Remove [agents.gsd-*] sections + the managed known-scalar [agents] table.
3366
3539
  cleaned = stripCodexGsdAgentSections(cleaned);
3367
3540
 
3368
3541
  // Remove [features] section if now empty (only header, no keys before next section)
@@ -3373,6 +3546,10 @@ function stripGsdFromCodexConfig(content) {
3373
3546
 
3374
3547
  cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd();
3375
3548
 
3549
+ if (preservedScalars.length > 0) {
3550
+ cleaned = (cleaned ? cleaned + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
3551
+ }
3552
+
3376
3553
  if (!cleaned) return null;
3377
3554
  return cleaned + eol;
3378
3555
  }
@@ -4844,6 +5021,33 @@ function parseTomlToObject(content) {
4844
5021
  * - `hooks.<Event>` MUST be an array of tables when present (Codex ≥0.124
4845
5022
  * rejects bare `[hooks.<Event>]` single-bracket maps).
4846
5023
  */
5024
+ /**
5025
+ * True when a bare `[agents]` table body contains ONLY known AgentsToml scalar
5026
+ * keys (CODEX_AGENTS_TOML_SCALAR_KEYS) — i.e. it is a valid AgentsToml struct
5027
+ * that Codex's `deny_unknown_fields` will accept, not the break-causing form
5028
+ * (#2760) that carries an unknown key. Comments and blank lines are ignored; an
5029
+ * empty body is trivially valid. Mirrors isLegacyGsdAgentsSection's line scan.
5030
+ */
5031
+ function codexBareAgentsHasOnlyKnownScalars(body) {
5032
+ const lineRecords = getTomlLineRecords(body);
5033
+ for (const record of lineRecords) {
5034
+ // Conservative reject of anything not positively a single known-scalar
5035
+ // assignment. A multiline-string value cannot be a valid AgentsToml scalar
5036
+ // (max_threads/max_depth/job_max_runtime_seconds are integers,
5037
+ // interrupt_message is a bool — none are strings), so codex would reject it
5038
+ // too; rejecting here is correct, not a false negative.
5039
+ if (record.startsInMultilineString) return false;
5040
+ if (record.tableHeader) return false;
5041
+ const trimmed = record.text.trim();
5042
+ if (!trimmed || trimmed.startsWith('#')) continue;
5043
+ if (!record.keySegments || record.keySegments.length !== 1 ||
5044
+ !CODEX_AGENTS_TOML_SCALAR_KEYS.has(record.keySegments[0])) {
5045
+ return false;
5046
+ }
5047
+ }
5048
+ return true;
5049
+ }
5050
+
4847
5051
  function validateCodexConfigSchema(content) {
4848
5052
  let parsed;
4849
5053
  try {
@@ -4872,10 +5076,21 @@ function validateCodexConfigSchema(content) {
4872
5076
  }
4873
5077
 
4874
5078
  if (!section.array && section.path === 'agents') {
4875
- return {
4876
- ok: false,
4877
- reason: 'bare [agents] table is invalid in current Codex schema (expected [agents.<name>] struct form)',
4878
- };
5079
+ // #2760 rejected ALL bare `[agents]` tables because a bare table holding a
5080
+ // non-AgentsToml key (`default = "x"`, a role name, etc.) triggers Codex's
5081
+ // "invalid type: ..., expected struct AgentsToml" and breaks every CLI
5082
+ // invocation. But a bare `[agents]` whose keys are all valid AgentsToml
5083
+ // scalars (max_depth/max_threads/...) IS a valid struct — that is exactly
5084
+ // GSD's managed `max_depth` dispatch-tuning block (ADR-1239 upgrade 2 /
5085
+ // #2088), and a user's own scalar tuning. Permit known-scalar-only; still
5086
+ // reject any bare `[agents]` carrying an unknown key.
5087
+ const body = content.slice(section.headerEnd, section.end);
5088
+ if (!codexBareAgentsHasOnlyKnownScalars(body)) {
5089
+ return {
5090
+ ok: false,
5091
+ reason: 'bare [agents] table with a non-AgentsToml key is invalid in current Codex schema (expected [agents.<name>] struct form, or only AgentsToml scalars like max_depth/max_threads)',
5092
+ };
5093
+ }
4879
5094
  }
4880
5095
 
4881
5096
  // hooks.state.* is Codex's persistent hook-trust namespace (added in
@@ -5151,7 +5366,13 @@ function mergeCodexConfig(configPath, gsdBlock) {
5151
5366
 
5152
5367
  const existing = fs.readFileSync(configPath, 'utf8');
5153
5368
  const eol = detectLineEnding(existing);
5154
- const normalizedGsdBlock = gsdBlock.replace(/\r?\n/g, eol);
5369
+ // #2088 review: the bare `[agents]` table is purged below (Case 2/3 via
5370
+ // stripLeakedGsdCodexSections) to keep a single managed `[agents]`. Preserve
5371
+ // the user's own AgentsToml scalar tuning (max_threads, job_max_runtime_seconds,
5372
+ // interrupt_message — everything except GSD-managed max_depth) by re-emitting
5373
+ // it inside the managed block, so install never silently drops it.
5374
+ const mergedGsdBlock = spliceCodexAgentsScalars(gsdBlock, extractCodexUserAgentsScalars(existing));
5375
+ const normalizedGsdBlock = mergedGsdBlock.replace(/\r?\n/g, eol);
5155
5376
  const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
5156
5377
 
5157
5378
  // Case 2: Has GSD marker — truncate and re-append
@@ -5622,6 +5843,37 @@ function removeCursorHooksJson(targetDir) {
5622
5843
  return hooksSurface.removeCursorHooksJson(targetDir);
5623
5844
  }
5624
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
+
5625
5877
  /**
5626
5878
  * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
5627
5879
  *
@@ -5926,7 +6178,12 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
5926
6178
  }
5927
6179
 
5928
6180
  // Kilo CLI — same conversion logic as OpenCode, different config paths.
5929
- 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 } = {}) {
5930
6187
  // Replace tool name references in content (applies to all files)
5931
6188
  let convertedContent = content;
5932
6189
  convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question');
@@ -6085,6 +6342,13 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6085
6342
  // For agents: add required Kilo agent fields
6086
6343
  if (isAgent) {
6087
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
+ }
6088
6352
  newLines.push(...buildKiloAgentPermissionBlock(agentTools));
6089
6353
  }
6090
6354
 
@@ -6105,62 +6369,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6105
6369
  // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
6106
6370
  // Imported from installEngine above.
6107
6371
 
6108
- /**
6109
- * Copy commands to a flat structure for OpenCode
6110
- * OpenCode expects: command/gsd-help.md (invoked as /gsd-help)
6111
- * Source structure: commands/gsd/help.md
6112
- *
6113
- * @param {string} srcDir - Source directory (e.g., commands/gsd/)
6114
- * @param {string} destDir - Destination directory (e.g., command/)
6115
- * @param {string} prefix - Prefix for filenames (e.g., 'gsd')
6116
- * @param {string} pathPrefix - Path prefix for file references
6117
- * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
6118
- */
6119
6372
  // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
6120
6373
  // Imported from installEngine above.
6121
-
6122
- function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
6123
- if (!fs.existsSync(srcDir)) {
6124
- return;
6125
- }
6126
-
6127
- // Remove old gsd-*.md files before copying new ones
6128
- if (fs.existsSync(destDir)) {
6129
- for (const file of fs.readdirSync(destDir)) {
6130
- if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
6131
- fs.unlinkSync(path.join(destDir, file));
6132
- }
6133
- }
6134
- } else {
6135
- fs.mkdirSync(destDir, { recursive: true });
6136
- }
6137
-
6138
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
6139
-
6140
- for (const entry of entries) {
6141
- const srcPath = path.join(srcDir, entry.name);
6142
-
6143
- if (entry.isDirectory()) {
6144
- // Recurse into subdirectories, adding to prefix
6145
- // e.g., commands/gsd/debug/start.md -> command/gsd-debug-start.md
6146
- copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
6147
- } else if (entry.name.endsWith('.md')) {
6148
- // Flatten: help.md -> gsd-help.md
6149
- const baseName = entry.name.replace('.md', '');
6150
- const destName = `${prefix}-${baseName}.md`;
6151
- const destPath = path.join(destDir, destName);
6152
-
6153
- let content = fs.readFileSync(srcPath, 'utf8');
6154
- content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
6155
- content = processAttribution(content, getCommitAttribution(runtime));
6156
- content = runtime === 'kilo'
6157
- ? convertClaudeToKiloFrontmatter(content)
6158
- : convertClaudeToOpencodeFrontmatter(content);
6159
-
6160
- fs.writeFileSync(destPath, content);
6161
- }
6162
- }
6163
- }
6374
+ //
6375
+ // copyFlattenedCommands (OpenCode/Kilo flattened command/ writer): moved to
6376
+ // src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
6377
+ // OpenCode/Kilo installs now route through installRuntimeArtifacts's
6378
+ // combinedFamilyInstall path (installOpencodeFamilyArtifacts) instead of the
6379
+ // bespoke inline block that used to call this function.
6164
6380
 
6165
6381
  function listCodexSkillNames(skillsDir, prefix = 'gsd-') {
6166
6382
  if (!fs.existsSync(skillsDir)) return [];
@@ -6353,33 +6569,53 @@ const RUNTIME_CONTENT_DISPATCH = {
6353
6569
  return content;
6354
6570
  },
6355
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/`).
6356
6577
  qwen: {
6357
- md: (content) => {
6358
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6359
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
6360
- 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
+ }
6361
6587
  return content;
6362
6588
  },
6363
- js: (content) => {
6364
- content = content.replace(/\.claude\/skills\//g, '.qwen/skills/');
6365
- content = content.replace(/\.claude\//g, '.qwen/');
6366
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
6367
- 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
+ }
6368
6597
  return content;
6369
6598
  },
6370
6599
  },
6371
6600
  hermes: {
6372
- md: (content) => {
6373
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6374
- content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
6375
- 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
+ }
6376
6609
  return content;
6377
6610
  },
6378
- js: (content) => {
6379
- content = content.replace(/\.claude\/skills\//g, '.hermes/skills/');
6380
- content = content.replace(/\.claude\//g, '.hermes/');
6381
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
6382
- 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
+ }
6383
6619
  return content;
6384
6620
  },
6385
6621
  },
@@ -6462,7 +6698,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
6462
6698
  // copyWithPathReplacement is the emit path for gsd-core/workflows/*.md;
6463
6699
  // _applyRuntimeRewrites is NOT invoked here, so this is what makes the fix
6464
6700
  // live in real installs (it is a no-op for files without those lines).
6465
- if (runtime !== 'claude') {
6701
+ if (!_hostBehaviors(runtime).authorsCanonicalWorkflow) {
6466
6702
  content = _stampNonClaudeRuntimeDefaults(content, runtime);
6467
6703
  }
6468
6704
 
@@ -6638,25 +6874,21 @@ function validateHookFields(settings) {
6638
6874
  * GSD hook filenames removed during uninstall.
6639
6875
  * Module-level so tests can assert structurally instead of regex-parsing source
6640
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).
6641
6890
  */
6642
- const GSD_UNINSTALL_HOOKS = [
6643
- 'gsd-statusline.js',
6644
- 'gsd-check-update.js',
6645
- 'gsd-check-update.cmd',
6646
- 'gsd-config-reload.js',
6647
- 'gsd-context-monitor.js',
6648
- 'gsd-cursor-session-start.js',
6649
- 'gsd-cursor-post-tool.js',
6650
- 'gsd-prompt-guard.js',
6651
- 'gsd-read-guard.js',
6652
- 'gsd-read-injection-scanner.js',
6653
- 'gsd-update-banner.js',
6654
- 'gsd-workflow-guard.js',
6655
- 'gsd-session-state.sh',
6656
- 'gsd-validate-commit.sh',
6657
- 'gsd-phase-boundary.sh',
6658
- 'gsd-graphify-update.sh',
6659
- ];
6891
+ const GSD_UNINSTALL_HOOKS = [..._HOOKS_TO_COPY, 'gsd-check-update.cmd'];
6660
6892
 
6661
6893
  /**
6662
6894
  * Uninstall GSD from the specified directory for a specific runtime
@@ -6664,16 +6896,32 @@ const GSD_UNINSTALL_HOOKS = [
6664
6896
  * @param {boolean} isGlobal - Whether to uninstall from global or local
6665
6897
  * @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', 'copilot')
6666
6898
  */
6667
- function uninstall(isGlobal, runtime = 'claude') {
6668
- const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
6899
+ function uninstall(isGlobal, runtime = DEFAULT_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);
6669
6913
  const dirName = getDirName(runtime);
6670
6914
 
6671
6915
  // Get the target directory based on runtime and install type. Cline local
6672
6916
  // installs write to the project root (.clinerules/ lives at the root, not in
6673
6917
  // a .cline/ subdir), mirroring the install() path resolution (#787).
6918
+ // Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
6919
+ // project root (.clinerules/ lives at the root, not in a .cline/ subdir),
6920
+ // mirroring the install() path resolution (#787). Folded from a hardcoded
6921
+ // `runtime === 'cline'` branch into hostBehaviors.localTargetIsProjectRoot.
6674
6922
  const targetDir = isGlobal
6675
6923
  ? getGlobalConfigDir(runtime, explicitConfigDir)
6676
- : runtime === 'cline'
6924
+ : _hostBehaviors(runtime).localTargetIsProjectRoot
6677
6925
  ? process.cwd()
6678
6926
  : path.join(process.cwd(), dirName);
6679
6927
 
@@ -6690,7 +6938,13 @@ function uninstall(isGlobal, runtime = 'claude') {
6690
6938
  // #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot
6691
6939
  // installs, so its cleanup must run even when .github (targetDir) was already
6692
6940
  // removed — i.e. BEFORE the "target directory missing" early-return below.
6693
- 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) {
6694
6948
  const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
6695
6949
  if (fs.existsSync(agentsMdPath)) {
6696
6950
  const content = fs.readFileSync(agentsMdPath, 'utf8');
@@ -6722,11 +6976,34 @@ function uninstall(isGlobal, runtime = 'claude') {
6722
6976
 
6723
6977
  // 1. Remove GSD commands/skills (layout-driven)
6724
6978
  const scope = isGlobal ? 'global' : 'local';
6725
- uninstallRuntimeArtifacts(runtime, targetDir, scope);
6979
+ // ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
6980
+ // Fail-open to the engine directly if the composed-registry adapter can't load.
6981
+ const _uninstallAdapter = _runtimeAdapter(runtime);
6982
+ if (_uninstallAdapter) {
6983
+ _uninstallAdapter.uninstall({ configDir: targetDir, scope });
6984
+ } else {
6985
+ uninstallRuntimeArtifacts(runtime, targetDir, scope);
6986
+ }
6726
6987
  removedCount++;
6727
6988
 
6989
+ // ADR-1239 split-home migration: the adapter/plan uninstall targets the new
6990
+ // `home` location (e.g. Codex → ~/.agents/skills). A user who installed
6991
+ // BEFORE the move and never reinstalled still has managed gsd-* skill dirs at
6992
+ // the old configDir-rooted location (~/.codex/skills) — remove those too so
6993
+ // uninstall leaves nothing behind. User-owned content is preserved.
6994
+ {
6995
+ const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
6996
+ if (_movedOldSkillsDir) {
6997
+ const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-');
6998
+ if (migrated > 0) {
6999
+ removedCount++;
7000
+ console.log(` ${green}✓${reset} Removed ${migrated} legacy skill dir(s) from ${_movedOldSkillsDir}`);
7001
+ }
7002
+ }
7003
+ }
7004
+
6728
7005
  // 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json
6729
- if (isCodex) {
7006
+ if (_hostBehaviors(runtime).tomlConfigInstall) {
6730
7007
  const codexAgentsDir = path.join(targetDir, 'agents');
6731
7008
  if (fs.existsSync(codexAgentsDir)) {
6732
7009
  const tomlFiles = fs.readdirSync(codexAgentsDir);
@@ -6765,8 +7042,10 @@ function uninstall(isGlobal, runtime = 'claude') {
6765
7042
  console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
6766
7043
  }
6767
7044
 
6768
- // #772: remove new Codex hook event registrations added by this enhancement.
6769
- for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
7045
+ // #772/#2088: remove every managed Codex extended hook-event registration.
7046
+ // Shares CODEX_EXTENDED_HOOK_EVENTS with the install loop — removal set ==
7047
+ // registration set, so no managed event is ever orphaned.
7048
+ for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) {
6770
7049
  const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
6771
7050
  if (eventCleanup.changed) {
6772
7051
  removedCount++;
@@ -6775,8 +7054,84 @@ function uninstall(isGlobal, runtime = 'claude') {
6775
7054
  }
6776
7055
  }
6777
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
+
6778
7130
  // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
6779
- 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') {
6780
7135
  const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
6781
7136
  if (fs.existsSync(instructionsPath)) {
6782
7137
  const content = fs.readFileSync(instructionsPath, 'utf8');
@@ -6813,7 +7168,9 @@ function uninstall(isGlobal, runtime = 'claude') {
6813
7168
  // 1b-cline. Non-layout Cline side-effects (issue #787): remove the
6814
7169
  // directory-form rules + PreToolUse hook, and strip the GSD block from the
6815
7170
  // global cross-tool ~/.agents/AGENTS.md target.
6816
- if (runtime === 'cline') {
7171
+ // Descriptor-driven (ADR-1239 / #2090): folded from `runtime === 'cline'`
7172
+ // into hostBehaviors.clineRulesSurface.
7173
+ if (_hostBehaviors(runtime).clineRulesSurface) {
6817
7174
  const clinerulesDir = path.join(targetDir, '.clinerules');
6818
7175
  for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
6819
7176
  const p = path.join(clinerulesDir, rel);
@@ -6859,17 +7216,20 @@ function uninstall(isGlobal, runtime = 'claude') {
6859
7216
  }
6860
7217
  }
6861
7218
 
6862
- // 1b-cursor. Non-layout Cursor side-effects (issue #777): remove GSD-managed
6863
- // hook entries from hooks.json and clean up the managed hook scripts.
6864
- if (isCursor) {
7219
+ // 1b-cursor. Descriptor-driven hook-bus cleanup (ADR-1239 / #2089): remove
7220
+ // GSD-managed hook entries from hooks.json and clean up the managed hook
7221
+ // scripts. Gated by the hostBehaviors.hooksJsonSurface descriptor axis, not a
7222
+ // hardcoded `isCursor` branch.
7223
+ if (_hostBehaviors(runtime).hooksJsonSurface) {
6865
7224
  const hooksJsonCleanup = removeCursorHooksJson(targetDir);
6866
7225
  if (hooksJsonCleanup.changed) {
6867
7226
  removedCount++;
6868
7227
  console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`);
6869
7228
  }
6870
- // Remove the managed hook scripts (session-start + post-tool).
7229
+ // Remove all GSD-managed hook scripts (sessionStart, postToolUse, preToolUse,
7230
+ // stop, subagentStart, subagentStop — AC4a, #2089).
6871
7231
  const hooksDir = path.join(targetDir, 'hooks');
6872
- for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) {
7232
+ for (const script of GSD_CURSOR_HOOK_SCRIPTS) {
6873
7233
  const p = path.join(hooksDir, script);
6874
7234
  try {
6875
7235
  if (fs.existsSync(p)) {
@@ -6886,9 +7246,41 @@ function uninstall(isGlobal, runtime = 'claude') {
6886
7246
  } catch { /* best-effort */ }
6887
7247
  }
6888
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
+
6889
7281
  // 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout,
6890
7282
  // #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs.
6891
- if (!isGlobal && runtime === 'claude') {
7283
+ if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') {
6892
7284
  const commandsDir = path.join(targetDir, 'commands');
6893
7285
  // Remove flat gsd-*.md files (current layout after #1367 fix)
6894
7286
  if (fs.existsSync(commandsDir)) {
@@ -6930,7 +7322,7 @@ function uninstall(isGlobal, runtime = 'claude') {
6930
7322
  // removes the directory; we must preserve/restore user artifacts before that path.
6931
7323
  // This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory
6932
7324
  // was already removed and skip if so (idempotent).
6933
- if (isQwen || isHermes) {
7325
+ if (_hostBehaviors(runtime).legacyCommandsGsdCleanup === true) {
6934
7326
  // dev-preferences may have survived in skills/ as SKILL.md — nothing to do for
6935
7327
  // that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed),
6936
7328
  // attempt migration. In practice _runLegacyUninstallCleanup removes it first,
@@ -7040,17 +7432,20 @@ function uninstall(isGlobal, runtime = 'claude') {
7040
7432
  }
7041
7433
  }
7042
7434
 
7043
- // 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
7044
- // plugin file is removed; the plugins/ dir is pruned only if it becomes
7045
- // empty, preserving any user-authored OpenCode plugins.
7046
- if (isOpencode) {
7047
- const pluginsDir = path.join(targetDir, 'plugins');
7048
- const pluginPath = path.join(pluginsDir, 'gsd-core.js');
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.
7440
+ const _np = _hostBehaviors(runtime).nativePlugin;
7441
+ if (_np) {
7442
+ const pluginsDir = path.join(targetDir, _np.dir);
7443
+ const pluginPath = path.join(pluginsDir, _np.file);
7049
7444
  if (fs.existsSync(pluginPath)) {
7050
7445
  try {
7051
7446
  fs.unlinkSync(pluginPath);
7052
7447
  removedCount++;
7053
- console.log(` ${green}✓${reset} Removed OpenCode plugin`);
7448
+ console.log(` ${green}✓${reset} Removed native plugin adapter (${runtime})`);
7054
7449
  } catch (_) { /* best-effort */ }
7055
7450
  try { fs.rmdirSync(pluginsDir); } catch (_) { /* not empty — user plugins present */ }
7056
7451
  }
@@ -7191,7 +7586,7 @@ function uninstall(isGlobal, runtime = 'claude') {
7191
7586
  // to preserve any user-added allow/deny entries.
7192
7587
  // Uses a local flag to avoid the shared `settingsModified` producing a false
7193
7588
  // "Removed GSD permissions" message when only hooks/statusline changed.
7194
- if (runtime === 'claude' && settings.permissions) {
7589
+ if (_hostBehaviors(runtime).permissionsSchema === 'claude' && settings.permissions) {
7195
7590
  let permissionsModified = false;
7196
7591
  if (Array.isArray(settings.permissions.allow)) {
7197
7592
  const before = settings.permissions.allow.length;
@@ -7217,6 +7612,47 @@ function uninstall(isGlobal, runtime = 'claude') {
7217
7612
  }
7218
7613
  }
7219
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
+
7220
7656
  if (settingsModified) {
7221
7657
  writeSettings(settingsPath, settings);
7222
7658
  removedCount++;
@@ -7224,7 +7660,7 @@ function uninstall(isGlobal, runtime = 'claude') {
7224
7660
  }
7225
7661
 
7226
7662
  // 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
7227
- if (isOpencode) {
7663
+ if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
7228
7664
  const configPath = resolveOpencodeConfigPath(targetDir);
7229
7665
  if (fs.existsSync(configPath)) {
7230
7666
  try {
@@ -7265,7 +7701,9 @@ function uninstall(isGlobal, runtime = 'claude') {
7265
7701
  }
7266
7702
 
7267
7703
  // 7. For Kilo, clean up permissions from kilo.json or kilo.jsonc
7268
- 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') {
7269
7707
  const configPath = resolveKiloConfigPath(targetDir);
7270
7708
  if (fs.existsSync(configPath)) {
7271
7709
  try {
@@ -7305,6 +7743,29 @@ function uninstall(isGlobal, runtime = 'claude') {
7305
7743
  }
7306
7744
  }
7307
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
+
7308
7769
  // Remove the file manifest that the installer wrote at install time.
7309
7770
  // Without this step the metadata file persists after uninstall (#1908).
7310
7771
  const manifestPath = path.join(targetDir, MANIFEST_NAME);
@@ -7558,6 +8019,193 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
7558
8019
  console.log(` ${green}✓${reset} Configured read permission for GSD docs`);
7559
8020
  }
7560
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
+
7561
8209
  /**
7562
8210
  * Verify a directory exists and contains files
7563
8211
  */
@@ -7666,19 +8314,35 @@ function resolveInstallRelativePath(baseDir, relPath) {
7666
8314
  /**
7667
8315
  * Write file manifest after installation for future modification detection
7668
8316
  */
7669
- function writeManifest(configDir, runtime = 'claude', options = {}) {
7670
- const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
8317
+ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
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);
7671
8330
  const gsdDir = path.join(configDir, 'gsd-core');
7672
8331
  // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
7673
8332
  // Claude local uses flatCommandsDir instead for manifest recording.
7674
8333
  const flatCommandsDir = path.join(configDir, 'commands');
7675
- const opencodeCommandDir = path.join(configDir, 'command');
7676
- // Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
8334
+ const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command');
8335
+ // Hermes nests GSD skills under skills/gsd/ as a single category (#2841) —
8336
+ // already encoded in its layout descriptor's destSubpath ('skills/gsd').
7677
8337
  // All other runtimes that use the Codex-style skills layout use a flat skills/ root.
7678
- const codexSkillsDir = isHermes
7679
- ? path.join(configDir, 'skills', 'gsd')
7680
- : path.join(configDir, 'skills');
7681
- const codexSkillsManifestPrefix = isHermes ? 'skills/gsd/' : 'skills/';
8338
+ // ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
8339
+ // skills -> $HOME/.agents/skills instead of configDir/skills) via the same
8340
+ // descriptor-driven helper used by the snapshot/rollback/verification paths,
8341
+ // so the manifest records what's actually on disk. _resolveSkillsRootDir already
8342
+ // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not
8343
+ // re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd.
8344
+ const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global');
8345
+ const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/';
7682
8346
  const agentsDir = path.join(configDir, 'agents');
7683
8347
  const manifest = {
7684
8348
  version: pkg.version,
@@ -7704,21 +8368,21 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7704
8368
  // Claude local (#1367): flat gsd-*.md files at commands/ level.
7705
8369
  // Only claude local writes gsd-*.md here; global installs don't emit commands,
7706
8370
  // so this branch is a no-op for global (no matching files to find).
7707
- if (runtime === 'claude' && fs.existsSync(flatCommandsDir)) {
8371
+ if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && fs.existsSync(flatCommandsDir)) {
7708
8372
  for (const file of fs.readdirSync(flatCommandsDir)) {
7709
8373
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
7710
8374
  manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file));
7711
8375
  }
7712
8376
  }
7713
8377
  }
7714
- if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) {
8378
+ if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
7715
8379
  for (const file of fs.readdirSync(opencodeCommandDir)) {
7716
8380
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
7717
8381
  manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
7718
8382
  }
7719
8383
  }
7720
8384
  }
7721
- if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || !isOpencode) && fs.existsSync(codexSkillsDir)) {
8385
+ if (!_hostBehaviors(runtime).skipCodexSkillsManifest && fs.existsSync(codexSkillsDir)) {
7722
8386
  // All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
7723
8387
  const skillListPrefix = 'gsd-';
7724
8388
  for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
@@ -7728,15 +8392,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7728
8392
  manifest.files[`${codexSkillsManifestPrefix}${skillName}/${rel}`] = hash;
7729
8393
  }
7730
8394
  }
7731
- // For Hermes, also hash the category DESCRIPTION.md so reinstall detects drift.
7732
- if (isHermes) {
8395
+ // Descriptor-driven (#2090): hash the category DESCRIPTION.md so reinstall detects drift.
8396
+ if (_hostBehaviors(runtime).trackCategoryDescription) {
7733
8397
  const descPath = path.join(codexSkillsDir, 'DESCRIPTION.md');
7734
8398
  if (fs.existsSync(descPath)) {
7735
8399
  manifest.files['skills/gsd/DESCRIPTION.md'] = fileHash(descPath);
7736
8400
  }
7737
8401
  }
7738
8402
  }
7739
- if (isKimi && fs.existsSync(agentsDir)) {
8403
+ if (_hostBehaviors(runtime).agentManifestStyle === 'kimi-nested' && fs.existsSync(agentsDir)) {
7740
8404
  const agentHashes = generateManifest(agentsDir);
7741
8405
  for (const [rel, hash] of Object.entries(agentHashes)) {
7742
8406
  const isRootAgent = rel === 'gsd.yaml' || rel === 'gsd.md';
@@ -7755,7 +8419,9 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7755
8419
  // Track Cline directory-form artifacts in the manifest (issue #787): the
7756
8420
  // rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
7757
8421
  // marker block, not the per-configDir manifest, since it lives outside it.)
7758
- if (isCline) {
8422
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
8423
+ // hostBehaviors.clineRulesSurface.
8424
+ if (_hostBehaviors(runtime).clineRulesSurface) {
7759
8425
  for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
7760
8426
  const dest = path.join(configDir, rel);
7761
8427
  if (fs.existsSync(dest)) {
@@ -7766,7 +8432,17 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7766
8432
 
7767
8433
  // Track hook files so saveLocalPatches() can detect user modifications
7768
8434
  // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
7769
- if (!isCodex && !isCopilot && !isCline && !isKimi) {
8435
+ // Descriptor-driven (ADR-1239 / #2089+#2090): cline's exclusion is via
8436
+ // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline).
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) {
7770
8446
  const hooksDir = path.join(configDir, 'hooks');
7771
8447
  if (fs.existsSync(hooksDir)) {
7772
8448
  // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
@@ -7830,10 +8506,11 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7830
8506
 
7831
8507
  // Track the OpenCode native plugin adapter (#1914) so update/drift detection
7832
8508
  // and uninstall can account for it.
7833
- if (isOpencode) {
7834
- const pluginInstallPath = path.join(configDir, 'plugins', 'gsd-core.js');
8509
+ const _npM = _hostBehaviors(runtime).nativePlugin;
8510
+ if (_npM) {
8511
+ const pluginInstallPath = path.join(configDir, _npM.dir, _npM.file);
7835
8512
  if (fs.existsSync(pluginInstallPath)) {
7836
- manifest.files['plugins/gsd-core.js'] = fileHash(pluginInstallPath);
8513
+ manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath);
7837
8514
  }
7838
8515
  }
7839
8516
 
@@ -8117,7 +8794,7 @@ function saveLocalPatches(configDir, pristineCtx) {
8117
8794
  /**
8118
8795
  * After install, report backed-up patches for user to reapply.
8119
8796
  */
8120
- function reportLocalPatches(configDir, runtime = 'claude') {
8797
+ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
8121
8798
  const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
8122
8799
  const metaPath = path.join(patchesDir, 'backup-meta.json');
8123
8800
  if (!fs.existsSync(metaPath)) return [];
@@ -8126,15 +8803,7 @@ function reportLocalPatches(configDir, runtime = 'claude') {
8126
8803
  try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
8127
8804
 
8128
8805
  if (meta.files && meta.files.length > 0) {
8129
- const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot')
8130
- ? '/gsd-update --reapply'
8131
- : runtime === 'codex'
8132
- ? '$gsd-update --reapply'
8133
- : runtime === 'cursor'
8134
- ? 'gsd-update --reapply (mention the skill name)'
8135
- : runtime === 'kimi'
8136
- ? '/skill:gsd-update --reapply'
8137
- : '/gsd-update --reapply';
8806
+ const reapplyCommand = _hostBehaviors(runtime).reapplyCommand || '/gsd-update --reapply';
8138
8807
  console.log('');
8139
8808
  console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
8140
8809
  for (const f of meta.files) {
@@ -8160,13 +8829,45 @@ function reportInstallerMigrationResult(result) {
8160
8829
  }
8161
8830
  }
8162
8831
 
8163
- function install(isGlobal, runtime = 'claude', options = {}) {
8164
- const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
8832
+ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
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);
8165
8866
  const plan = resolveInstallPlan(runtime);
8166
8867
  const dirName = getDirName(runtime);
8167
8868
  const src = path.join(__dirname, '..');
8168
8869
 
8169
- if (isKimi && !isGlobal) {
8870
+ if (_hostBehaviors(runtime).localInstallDeferred && !isGlobal) {
8170
8871
  console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
8171
8872
  console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
8172
8873
  console.log(` Project-level Kimi install semantics remain deferred.`);
@@ -8214,15 +8915,17 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8214
8915
  };
8215
8916
 
8216
8917
  // Get the target directory based on runtime and install type.
8217
- // Cline local installs write to the project root (like Claude Code) — .clinerules
8218
- // lives at the root, not inside a .cline/ subdirectory.
8918
+ // Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
8919
+ // project root (like Claude Code) — .clinerules lives at the root, not inside
8920
+ // a .cline/ subdirectory. Folded from `isCline` into
8921
+ // hostBehaviors.localTargetIsProjectRoot.
8219
8922
  // #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
8220
8923
  // directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
8221
8924
  // but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
8222
8925
  // auto-removed on reinstall (dual-read fallback per issue #791 spec).
8223
8926
  const targetDir = isGlobal
8224
8927
  ? getGlobalConfigDir(runtime, explicitConfigDir)
8225
- : isCline
8928
+ : _hostBehaviors(runtime).localTargetIsProjectRoot
8226
8929
  ? process.cwd()
8227
8930
  : path.join(process.cwd(), dirName);
8228
8931
 
@@ -8301,7 +9004,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8301
9004
  const isWindowsHost = process.platform === 'win32';
8302
9005
  const pathPrefix = computePathPrefix({
8303
9006
  isGlobal,
8304
- isOpencode,
9007
+ isOpencode: _hostBehaviors(runtime).skipHomePrefixSubstitution === true,
8305
9008
  isWindowsHost,
8306
9009
  resolvedTarget,
8307
9010
  homeDir,
@@ -8367,8 +9070,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8367
9070
  // Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
8368
9071
  const codexPreInstallAgentContents = new Map();
8369
9072
  let codexPreInstallVersionBytes = null;
8370
- if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
8371
- const _preSkillsDir = path.join(targetDir, 'skills');
9073
+ if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
9074
+ const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
8372
9075
  if (fs.existsSync(_preSkillsDir)) {
8373
9076
  for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
8374
9077
  if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -8421,10 +9124,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8421
9124
  // atomic-write temp files. It is safe to call before any writes have happened.
8422
9125
  // The full restoreCodexSnapshot() (defined inside the config block) additionally
8423
9126
  // handles config.toml, which is not yet touched at this point in the pipeline.
8424
- const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => {
9127
+ const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
8425
9128
  rollbackInstallerMigrations();
8426
9129
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
8427
- const _earlySkillsDir = path.join(targetDir, 'skills');
9130
+ const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
8428
9131
  for (const skillName of codexPreInstallSkillNames) {
8429
9132
  const skillDirPath = path.join(_earlySkillsDir, skillName);
8430
9133
  const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -8587,7 +9290,6 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8587
9290
  // Hermes: writeHermesCategoryDescription (not a layout kind)
8588
9291
  // Cline global: skills emitted via layout; .clinerules still written below (#782)
8589
9292
  // Cline local: no skills (only .clinerules) — falls through to cline-rules surface
8590
- // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
8591
9293
  // Claude local: copyWithPathReplacement + stale-skills cleanup
8592
9294
 
8593
9295
  // Layout-driven path for all skills-based runtimes (full and minimal modes).
@@ -8599,13 +9301,15 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8599
9301
  // (it declares any skills/commands/agents/kimi-agents kind for this scope).
8600
9302
  // This replaces the prior hardcoded `isCodex || isCopilot || ...` roster so a
8601
9303
  // newly-added runtime with an artifact layout installs without a per-runtime
8602
- // branch — the add-a-host tax ADR-1239 Phase B retires. Three legacy
8603
- // special-cased paths are preserved: opencode/kilo (combined commands+skills
8604
- // via copyFlattenedCommands + installOpencodeFamilySkills) and claude-local
9304
+ // branch — the add-a-host tax ADR-1239 Phase B retires. OpenCode/Kilo now
9305
+ // route through this SAME path too: their hostBehaviors.combinedFamilyInstall
9306
+ // flag makes installRuntimeArtifacts (in src/install-engine.cts) delegate to
9307
+ // installOpencodeFamilyArtifacts for the combined commands+skills+native-plugin
9308
+ // install (ADR-1239 / #2087), replacing the bespoke inline block this comment
9309
+ // used to describe. Claude-local remains the one special-cased path
8605
9310
  // (copyWithPathReplacement + stale-skills cleanup).
8606
9311
  const _isSkillsRuntime = (() => {
8607
- if (isOpencode || isKilo) return false; // specialized combined path
8608
- if (runtime === 'claude' && !isGlobal) return false; // claude-local legacy path
9312
+ if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
8609
9313
  const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
8610
9314
  const layout = cap && cap.runtime && cap.runtime.artifactLayout;
8611
9315
  if (!layout) return false;
@@ -8616,7 +9320,28 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8616
9320
  if (_isSkillsRuntime) {
8617
9321
  // Layout-driven install for skills-based runtimes (full and minimal modes)
8618
9322
  const scope = isGlobal ? 'global' : 'local';
8619
- installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
9323
+ // ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
9324
+ // (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
9325
+ // configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
9326
+ // (no isCodex check), so downstream sidecar-cleanup and post-install
9327
+ // verification look in the right place regardless of which runtime declares
9328
+ // an alternate home for its skills kind.
9329
+ const _skillsRootDir = _resolveSkillsRootDir(runtime, targetDir, scope);
9330
+ // ADR-1239 / #2086: drive install through the public Host-Integration Interface
9331
+ // (imperative adapter). The adapter delegates to the SAME installRuntimeArtifacts
9332
+ // engine call -> byte-identical output (gated by golden-install-parity). Fail-open
9333
+ // to the engine directly if the composed-registry adapter can't load.
9334
+ const _adapter = _runtimeAdapter(runtime);
9335
+ if (_adapter) {
9336
+ _adapter.install({
9337
+ configDir: targetDir,
9338
+ scope,
9339
+ resolvedProfile: _resolvedProfile,
9340
+ resolveAttribution: getCommitAttribution,
9341
+ });
9342
+ } else {
9343
+ installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
9344
+ }
8620
9345
 
8621
9346
  // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
8622
9347
  // gsd-* skill dirs. Prior installs wrote these files so Codex would show a
@@ -8624,28 +9349,46 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8624
9349
  // index BOTH SKILL.md and the sidecar, causing each GSD skill to appear twice
8625
9350
  // in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
8626
9351
  // sufficient for Codex discovery. User-owned dirs are never touched.
8627
- if (isCodex) {
8628
- cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills'));
9352
+ if (_hostBehaviors(runtime).cleanupSkillSidecars) {
9353
+ cleanupCodexSkillMetadataSidecars(_skillsRootDir);
9354
+ }
9355
+
9356
+ // ADR-1239 split-home migration: when a runtime's skills kind moved to an
9357
+ // alternate `home` (e.g. Codex → ~/.agents/skills), pre-move installs left
9358
+ // managed gsd-* skill dirs at the old configDir-rooted location
9359
+ // (~/.codex/skills). Reinstalling here writes the new location but would
9360
+ // otherwise orphan the old one — clean up the stale gsd-* dirs.
9361
+ {
9362
+ const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
9363
+ if (_movedOldSkillsDir) {
9364
+ const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-');
9365
+ if (migrated > 0) {
9366
+ console.log(` ${green}✓${reset} Migrated ${migrated} skill dir(s) off the legacy ${_movedOldSkillsDir} location`);
9367
+ }
9368
+ }
8629
9369
  }
8630
9370
 
8631
9371
  // #1629 Finding B: Windsurf local only — remove legacy .devin/skills/gsd-*
8632
9372
  // dirs from pre-#1615 installs. #1615 moved Windsurf to .windsurf/workflows/
8633
9373
  // but never cleaned up the old .devin/skills/ layout (#1085). User-owned
8634
9374
  // content is preserved (non-gsd- dirs, gsd-dev-preferences, symlinks).
8635
- 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) {
8636
9379
  const removedCount = cleanupWindsurfLegacyDevinSkills(process.cwd());
8637
9380
  if (removedCount > 0) {
8638
9381
  console.log(` ${green}✓${reset} Removed ${removedCount} legacy .devin/skills/gsd-* dir(s) (pre-#1615 Windsurf layout)`);
8639
9382
  }
8640
9383
  }
8641
9384
 
8642
- // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
8643
- if (isHermes) {
9385
+ // Descriptor-driven (#2090): write DESCRIPTION.md for the gsd/ category after layout install
9386
+ if (_hostBehaviors(runtime).writeCategoryDescription) {
8644
9387
  writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
8645
9388
  }
8646
9389
 
8647
9390
  // Verify installed artifacts and report
8648
- if (isHermes) {
9391
+ if (_hostBehaviors(runtime).reportSkillsCount) {
8649
9392
  const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
8650
9393
  if (fs.existsSync(hermesSkillsDir)) {
8651
9394
  // Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
@@ -8659,7 +9402,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8659
9402
  } else {
8660
9403
  failures.push('skills/gsd/*');
8661
9404
  }
8662
- } else if (isKimi) {
9405
+ } else if (_hostBehaviors(runtime).verificationStyle === 'kimi') {
8663
9406
  const skillsDir = path.join(targetDir, 'skills');
8664
9407
  const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml');
8665
9408
  if (fs.existsSync(skillsDir)) {
@@ -8679,7 +9422,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8679
9422
  } else {
8680
9423
  failures.push('agents/gsd.yaml');
8681
9424
  }
8682
- } 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') {
8683
9430
  if (isGlobal) {
8684
9431
  console.log(` ${green}✓${reset} Windsurf global install skipped workflow artifacts (workspace-only)`);
8685
9432
  } else {
@@ -8697,7 +9444,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8697
9444
  }
8698
9445
  }
8699
9446
  } else {
8700
- const skillsDir = path.join(targetDir, 'skills');
9447
+ const skillsDir = _skillsRootDir;
8701
9448
  if (fs.existsSync(skillsDir)) {
8702
9449
  const count = fs.readdirSync(skillsDir, { withFileTypes: true })
8703
9450
  .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
@@ -8725,24 +9472,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8725
9472
  }
8726
9473
  }
8727
9474
 
8728
- // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands)
8729
- if (isCursor) {
8730
- const commandsDir = path.join(targetDir, 'commands');
8731
- if (fs.existsSync(commandsDir)) {
8732
- const cmdCount = fs.readdirSync(commandsDir)
8733
- .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
8734
- if (cmdCount > 0) {
8735
- console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
8736
- } else {
8737
- failures.push('commands/gsd-*');
8738
- }
8739
- } else {
8740
- failures.push('commands/gsd-*');
8741
- }
8742
- }
8743
-
8744
- // CodeBuddy only: also report the commands/ output (#789 — slash commands)
8745
- if (isCodebuddy) {
9475
+ // Descriptor-driven commands/ output report (#785 — Cursor 1.6 slash commands).
9476
+ // Gated by hostBehaviors.reportCommandsDir, not a hardcoded `isCursor` branch (#2089).
9477
+ if (_hostBehaviors(runtime).reportCommandsDir) {
8746
9478
  const commandsDir = path.join(targetDir, 'commands');
8747
9479
  if (fs.existsSync(commandsDir)) {
8748
9480
  const cmdCount = fs.readdirSync(commandsDir)
@@ -8757,69 +9489,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8757
9489
  }
8758
9490
  }
8759
9491
  }
8760
- } else if (isOpencode || isKilo) {
8761
- // OpenCode/Kilo: flat structure in command/ directory
8762
- const commandDir = path.join(targetDir, 'command');
8763
- fs.mkdirSync(commandDir, { recursive: true });
8764
-
8765
- // Copy commands/gsd/*.md as command/gsd-*.md (flatten structure)
8766
- const gsdSrc = _stageSkills(_commandsDir);
8767
- copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime);
8768
- if (verifyInstalled(commandDir, 'command/gsd-*')) {
8769
- const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length;
8770
- console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
8771
- } else {
8772
- failures.push('command/gsd-*');
8773
- }
8774
-
8775
- // Also emit OpenCode-family skills (skills/<name>/SKILL.md). OpenCode and
8776
- // Kilo support native, on-demand skills in addition to flat commands — see
8777
- // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
8778
- // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
8779
- const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution);
8780
- if (_skillCount > 0) {
8781
- console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
8782
- } else {
8783
- failures.push('skills/gsd-*');
8784
- }
8785
-
8786
- // OpenCode-only: install the native plugin adapter (#1914). OpenCode
8787
- // declares hooksSurface: 'none', so GSD's lifecycle hooks are never
8788
- // registered as settings.json hooks the way Claude Code does — the hook
8789
- // *scripts* ship to <configDir>/hooks/ but nothing invokes them. This
8790
- // plugin bridges OpenCode's event bus onto those existing hook scripts
8791
- // (prompt guard, read guard, injection scanner, context monitor, ...),
8792
- // spawning them as subprocesses. OpenCode auto-discovers plugin files under
8793
- // <configDir>/plugins/ at startup — no opencode.json registration needed
8794
- // (its `plugin` array is for npm packages, not local file paths).
8795
- //
8796
- // The file MUST land as `.js`: OpenCode's loader globs
8797
- // `{plugin,plugins}/*.{ts,js}` (verified against its source) — a `.cjs`
8798
- // extension would never be discovered. The config dir carries a
8799
- // `{"type":"commonjs"}` package.json (written above), so the `.js` file is
8800
- // interpreted as CommonJS, matching the adapter's module.exports/require.
8801
- // Kilo has no plugin surface, so this is gated to OpenCode only.
8802
- if (isOpencode) {
8803
- const pluginSrc = path.join(src, '.opencode', 'plugins', 'gsd-core.js');
8804
- const pluginDestDir = path.join(targetDir, 'plugins');
8805
- const pluginDest = path.join(pluginDestDir, 'gsd-core.js');
8806
- if (fs.existsSync(pluginSrc)) {
8807
- fs.mkdirSync(pluginDestDir, { recursive: true });
8808
- fs.copyFileSync(pluginSrc, pluginDest);
8809
- if (fs.existsSync(pluginDest)) {
8810
- console.log(` ${green}✓${reset} Installed OpenCode plugin (bridges GSD hooks)`);
8811
- } else {
8812
- failures.push('plugins/gsd-core.js');
8813
- }
8814
- } else {
8815
- failures.push('plugins/gsd-core.js');
8816
- }
8817
- }
8818
- } else if (isCline) {
9492
+ } else if (_hostBehaviors(runtime).localCommandsViaRules) {
8819
9493
  // Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
8820
9494
  // No skills/commands directory needed for local installs.
8821
9495
  // Global installs are handled above by _isSkillsRuntime (#782).
9496
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
9497
+ // hostBehaviors.localCommandsViaRules.
8822
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)`);
8823
9509
  } else {
8824
9510
  // Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
8825
9511
  // commands from .claude/commands/ using the filename stem as the command
@@ -8890,6 +9576,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8890
9576
  }
8891
9577
  }
8892
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
+
8893
9591
  // Copy gsd-core skill with path replacement
8894
9592
  // Preserve user-generated files before the wipe-and-copy so they survive re-install
8895
9593
  const skillSrc = path.join(src, 'gsd-core');
@@ -8916,11 +9614,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8916
9614
  // other runtime/scope deploys commands/gsd, so its walk-up already resolves
8917
9615
  // and needs no marker. Guarded on source presence so a half-published
8918
9616
  // package never writes a dangling marker.
8919
- if (runtime === 'claude' && isGlobal) {
9617
+ if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
8920
9618
  const gsdSourceCommands = path.join(src, 'commands', 'gsd');
8921
9619
  if (fs.existsSync(gsdSourceCommands)) {
8922
9620
  try {
8923
- fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8');
9621
+ // ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
9622
+ // must resolve under targetDir (parity with the other descriptor-driven writes).
9623
+ const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
9624
+ fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8');
8924
9625
  } catch (err) {
8925
9626
  // Non-fatal: install proceeds. But on the Claude-global layout walk-up
8926
9627
  // also fails (no commands/gsd source tree), so a silent write failure
@@ -8938,7 +9639,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8938
9639
  // this copy, every /gsd-* workflow in Cascade references a missing file and the LLM
8939
9640
  // cannot execute the command body. Surfaced by the #1629 regression test after the
8940
9641
  // original adversarial review of #1622 missed it.
8941
- 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) {
8942
9647
  const commandsSrc = path.join(src, 'commands', 'gsd');
8943
9648
  const commandsDest = path.join(skillDest, 'commands', 'gsd');
8944
9649
  if (fs.existsSync(commandsSrc)) {
@@ -8994,28 +9699,39 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8994
9699
  // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
8995
9700
  // #1575: copilot and antigravity cut over — copilot gets .agent.md filename
8996
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).
8997
9708
  // cline remains excluded: rules-only local branch + local/global complication
8998
9709
  // that the descriptor-driven path does not handle correctly.
8999
- 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']);
9000
9711
 
9001
9712
  // Always remove stale gsd-* agents first so re-installing with
9002
9713
  // `--minimal` actually shrinks a previously-full install.
9003
9714
  // For Codex this also covers per-agent `.toml` files alongside the `.md`
9004
9715
  // sources so a full → minimal switch doesn't leave stale registrations.
9005
- // Skipped for descriptor-agent runtimes (installRuntimeArtifacts prunes).
9006
- 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)) {
9007
9720
  for (const file of fs.readdirSync(agentsDest)) {
9008
9721
  if (
9009
9722
  file.startsWith('gsd-') &&
9010
- (file.endsWith('.md') || (isCodex && file.endsWith('.toml')))
9723
+ (file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml')))
9011
9724
  ) {
9012
9725
  fs.unlinkSync(path.join(agentsDest, file));
9013
9726
  }
9014
9727
  }
9015
9728
  }
9016
9729
 
9017
- if (isKimi) {
9018
- 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)`);
9019
9735
  } else if (_DESCRIPTOR_AGENTS_RUNTIMES.has(runtime)) {
9020
9736
  // installRuntimeArtifacts already wrote agents + handles stale-file cleanup
9021
9737
  // via its own prune pass. No further action needed.
@@ -9025,7 +9741,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9025
9741
  // Without stripping them here, a full → minimal reinstall would leave the
9026
9742
  // runtime advertising the old full agent surface even though the agent
9027
9743
  // files are gone. Reuse the same helper that powers `--uninstall`.
9028
- if (isCodex) {
9744
+ if (_hostBehaviors(runtime).tomlConfigInstall) {
9029
9745
  const codexConfigPath = path.join(targetDir, 'config.toml');
9030
9746
  if (fs.existsSync(codexConfigPath)) {
9031
9747
  const existing = fs.readFileSync(codexConfigPath, 'utf8');
@@ -9052,15 +9768,21 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9052
9768
  const bareDirRegex = /~\/\.claude\b/g;
9053
9769
  const bareHomeDirRegex = /\$HOME\/\.claude\b/g;
9054
9770
  const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
9055
- if (!isCopilot && !isAntigravity) {
9056
- content = content.replace(dirRegex, pathPrefix);
9057
- content = content.replace(homeDirRegex, pathPrefix);
9058
- content = content.replace(bareDirRegex, normalizedPathPrefix);
9059
- content = content.replace(bareHomeDirRegex, normalizedPathPrefix);
9060
- }
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);
9061
9783
  content = processAttribution(content, getCommitAttribution(runtime));
9062
9784
  // Convert frontmatter for runtime compatibility (agents need different handling)
9063
- if (isOpencode) {
9785
+ if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
9064
9786
  // Resolve per-agent model for OpenCode agents.
9065
9787
  // Precedence: model_overrides[agent] > model_profile_overrides.opencode.<tier> > omit.
9066
9788
  // model_overrides (#2256): explicit per-agent override, highest precedence.
@@ -9079,34 +9801,53 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9079
9801
  }
9080
9802
  }
9081
9803
  content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
9082
- } else if (isKilo) {
9083
- content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
9084
- } else if (isCodex) {
9804
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
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 });
9824
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
9085
9825
  content = convertClaudeAgentToCodexAgent(content);
9086
- } else if (isCopilot) {
9087
- content = convertClaudeAgentToCopilotAgent(content, isGlobal);
9088
- } else if (isAntigravity) {
9089
- content = convertClaudeAgentToAntigravityAgent(content, isGlobal);
9090
- } else if (isCursor) {
9091
- content = convertClaudeAgentToCursorAgent(content);
9092
- } else if (isWindsurf) {
9093
- content = convertClaudeAgentToWindsurfAgent(content);
9094
- } else if (isAugment) {
9095
- content = convertClaudeAgentToAugmentAgent(content);
9096
- } else if (isTrae) {
9097
- content = convertClaudeAgentToTraeAgent(content);
9098
- } else if (isCodebuddy) {
9099
- content = convertClaudeAgentToCodebuddyAgent(content);
9100
- } else if (isCline) {
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.
9838
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
9839
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
9840
+ // hostBehaviors.frontmatterDialect === 'cline'.
9101
9841
  content = convertClaudeAgentToClineAgent(content);
9102
- } else if (isQwen) {
9103
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9104
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9105
- content = content.replace(/\.claude\//g, '.qwen/');
9106
- } else if (isHermes) {
9107
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9108
- content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
9109
- content = content.replace(/\.claude\//g, '.hermes/');
9842
+ } else if (_hostBehaviors(runtime).brandingRewrites) {
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/']);
9110
9851
  }
9111
9852
  // #443 — Inject `effort:` into the Claude .md frontmatter ONLY.
9112
9853
  // OpenCode/Qwen/Hermes also produce .md files but break on
@@ -9115,11 +9856,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9115
9856
  // Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536).
9116
9857
  // Injection is per-runtime at install time because the canonical source
9117
9858
  // agents/*.md must stay runtime-safe (no effort: key in source).
9118
- if (runtime === 'claude') {
9859
+ if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort')) {
9119
9860
  const _effortCfg = readGsdEffectiveEffortConfig(targetDir);
9120
9861
  const _agentName = entry.name.replace(/\.md$/, '');
9121
9862
  const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
9122
- const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
9863
+ const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, _universalEffort).value;
9123
9864
  content = injectEffortFrontmatter(content, _renderedEffort);
9124
9865
  const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
9125
9866
  if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
@@ -9131,7 +9872,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9131
9872
  // shouldNormalizeHyphenNamespaceInAgentBody above. Mirrors the
9132
9873
  // SKILL.md-body fix shipped via #3629.
9133
9874
  content = normalizeAgentBodyForRuntime(content, runtime, readGsdCommandNames());
9134
- 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;
9135
9881
  fs.writeFileSync(path.join(agentsDest, destName), content);
9136
9882
  }
9137
9883
  }
@@ -9163,19 +9909,41 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9163
9909
  failures.push('VERSION');
9164
9910
  }
9165
9911
 
9166
- if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) {
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
+
9167
9933
  // Write package.json to force CommonJS mode for GSD scripts
9168
9934
  // Prevents "require is not defined" errors when project has "type": "module"
9169
9935
  // Node.js walks up looking for package.json - this stops inheritance from project
9170
- const pkgJsonDest = path.join(targetDir, 'package.json');
9936
+ const pkgJsonDest = path.join(destRootDir, 'package.json');
9171
9937
  fs.writeFileSync(pkgJsonDest, '{"type":"commonjs"}\n');
9172
9938
  console.log(` ${green}✓${reset} Wrote package.json (CommonJS mode)`);
9173
9939
 
9940
+ let hooksOk = true;
9941
+
9174
9942
  // Copy hooks from dist/ (bundled with dependencies)
9175
9943
  // Template paths for the target runtime (replaces '.claude' with correct config dir)
9176
9944
  const hooksSrc = path.join(src, 'hooks', 'dist');
9177
9945
  if (fs.existsSync(hooksSrc)) {
9178
- const hooksDest = path.join(targetDir, 'hooks');
9946
+ const hooksDest = path.join(destRootDir, 'hooks');
9179
9947
  fs.mkdirSync(hooksDest, { recursive: true });
9180
9948
  const hookEntries = fs.readdirSync(hooksSrc);
9181
9949
  const configDirReplacement = getConfigDirFromHome(runtime, isGlobal);
@@ -9188,13 +9956,15 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9188
9956
  content = content.replace(/'\.claude'/g, configDirReplacement);
9189
9957
  content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`);
9190
9958
  content = content.replace(/\.claude\//g, `${getDirName(runtime)}/`);
9191
- if (isQwen) {
9192
- content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9193
- content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9194
- }
9195
- if (isHermes) {
9196
- content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9197
- 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']);
9198
9968
  }
9199
9969
  // #376: rewrite gsd: → gsd- for hyphen-namespace runtimes
9200
9970
  if (shouldNormalizeHyphenNamespaceInAgentBody(runtime)) {
@@ -9247,25 +10017,65 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9247
10017
  }
9248
10018
  }
9249
10019
  } else {
9250
- failures.push('hooks');
9251
- }
10020
+ hooksOk = false;
10021
+ }
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;
10045
+ }
10046
+
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');
9252
10076
  }
9253
10077
  }
9254
10078
 
9255
- // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
9256
- // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
9257
- // (Cursor uses standalone .js hook scripts registered via hooks.json; Codex uses
9258
- // hooks.json directly; the others skip hooks entirely), so they must not receive
9259
- // the hooks/lib/ helpers — otherwise the Codex comment downstream
9260
- // ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
9261
- const hooksLibSrc = path.join(src, 'hooks', 'lib');
9262
- if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && fs.existsSync(hooksLibSrc)) {
9263
- const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
9264
- fs.mkdirSync(hooksLibDest, { recursive: true });
9265
- copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
9266
- console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
9267
- }
9268
-
9269
10079
  // Install scripts/changeset/ and scripts/lib/ into <configDir>/scripts/
9270
10080
  // so that `node "$GSD_DIR/scripts/changeset/cli.cjs"` resolves at runtime.
9271
10081
  //
@@ -9386,14 +10196,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9386
10196
  }
9387
10197
 
9388
10198
  // Write file manifest for future modification detection
9389
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
10199
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9390
10200
  console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
9391
10201
 
9392
10202
  // Report any backed-up local patches
9393
10203
  reportLocalPatches(targetDir, runtime);
9394
10204
 
9395
10205
  // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
9396
- if (runtime !== 'claude') {
10206
+ if (!_hostBehaviors(runtime).ownsClaudePaths) {
9397
10207
  const leakedPaths = [];
9398
10208
  // Only scan files that were written by this install (manifest-tracked).
9399
10209
  // Scanning the entire targetDir can match user-authored content that
@@ -9529,7 +10339,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9529
10339
  // (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
9530
10340
  // are restored even when they are absent from disk at rollback time (#3245 CR).
9531
10341
  // • Dirs that did not pre-exist: remove entirely.
9532
- const _rollbackSkillsDir = path.join(targetDir, 'skills');
10342
+ const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
9533
10343
  // Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
9534
10344
  for (const skillName of codexPreInstallSkillNames) {
9535
10345
  const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -9640,7 +10450,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9640
10450
  // Re-write the manifest now that .toml agent files exist on disk.
9641
10451
  // The initial writeManifest call (before Codex config generation) could
9642
10452
  // not include agents/gsd-*.toml because those files did not yet exist.
9643
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
10453
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9644
10454
  } else {
9645
10455
  console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
9646
10456
  }
@@ -9780,24 +10590,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9780
10590
  }
9781
10591
  }
9782
10592
 
9783
- // ── Codex extended hook events (#772) ────────────────────────────────
9784
- // Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register
9785
- // three new high-value lifecycle events — all routed through
9786
- // gsd-context-monitor.js so context-headroom warnings surface at:
9787
- // SubagentStart — subagent session open (environment / agent-name aware)
9788
- // Stop — model stop / session final-response moment
9789
- // PostToolUse — after each tool invocation (mirrors Claude baseline)
9790
- //
9791
- // Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
9792
- // tool_name is Write|Edit (PreToolUse payload shape), so it would be a
9793
- // silent no-op for the UserPromptSubmit payload. Registration deferred
9794
- // to a follow-on issue.
10593
+ // ── Codex extended hook events (#772, #2088) ─────────────────────────
10594
+ // Codex CLI stabilised a full hook-event set in rust-v0.137.0. GSD
10595
+ // registers CODEX_EXTENDED_HOOK_EVENTS (#2088 adds the 6 documented
10596
+ // events beyond the original #772 three) — all routed through
10597
+ // gsd-context-monitor.js so context-headroom warnings surface at each
10598
+ // lifecycle point: SubagentStart/SubagentStop (subagent open/close),
10599
+ // Stop (final-response), PreToolUse/PostToolUse (tool boundaries),
10600
+ // PermissionRequest (approval prompts), Pre/PostCompact (context
10601
+ // compaction), and UserPromptSubmit (per-turn context injection). The
10602
+ // context-monitor script decides per-payload what to do; unregistered
10603
+ // events simply never fire.
9795
10604
  //
9796
10605
  // Guard: only register when the context-monitor file exists and the node
9797
10606
  // runner is available — same guards as the SessionStart path above.
9798
10607
  const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
9799
10608
  if (codexNodeRunner && fs.existsSync(contextMonitorFile)) {
9800
- for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) {
10609
+ for (const codexEvent of CODEX_EXTENDED_HOOK_EVENTS) {
9801
10610
  const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, {
9802
10611
  absoluteRunner: codexNodeRunner,
9803
10612
  platform: process.platform,
@@ -9809,7 +10618,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9809
10618
  }
9810
10619
  }
9811
10620
  } else if (!codexNodeRunner) {
9812
- console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`);
10621
+ console.warn(` ${yellow}⚠${reset} Skipped Codex extended hook-event registration — Node runner unavailable.`);
9813
10622
  }
9814
10623
  // ── end Codex extended hook events ────────────────────────────────────
9815
10624
  }
@@ -9874,22 +10683,98 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9874
10683
  }
9875
10684
 
9876
10685
  if (plan.installSurface === 'cursor-hooks-json') {
9877
- // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
9878
- // Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
9879
- const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
10686
+ // ADR-1239 / #2089: Cursor hooks.json driven by the descriptor-managed hook-bus
10687
+ // adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse,
10688
+ // stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads
10689
+ // the event list from the descriptor-driven adapter module.
10690
+ const cursorHookResult = writeCursorHooksJson(targetDir, src, {
10691
+ managedHookEvents: _hostBehaviors(runtime).managedHookEvents,
10692
+ });
9880
10693
  if (cursorHookResult.changed) {
9881
- console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`);
10694
+ console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`);
9882
10695
  } else {
9883
10696
  console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
9884
10697
  }
9885
- // Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
9886
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
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.
10703
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9887
10704
  persistActiveProfileMarker();
9888
10705
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9889
10706
  }
9890
10707
 
9891
10708
  if (plan.installSurface === 'profile-marker-only') {
9892
- // 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
+
9893
10778
  persistActiveProfileMarker();
9894
10779
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9895
10780
  }
@@ -9901,7 +10786,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9901
10786
  writeClineArtifacts(targetDir, isGlobal);
9902
10787
  // Re-run the manifest pass: these artifacts are written *after* the earlier
9903
10788
  // writeManifest() call, so a second pass is needed to hash-track them.
9904
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
10789
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9905
10790
  persistActiveProfileMarker();
9906
10791
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9907
10792
  }
@@ -9916,9 +10801,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9916
10801
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
9917
10802
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
9918
10803
  // settings.json. Global installs and all other runtimes continue to use settings.json.
9919
- const isLocalClaude = (runtime === 'claude' && !isGlobal);
9920
- const settingsFileName = isLocalClaude ? 'settings.local.json' : 'settings.json';
9921
- const settingsPath = path.join(targetDir, settingsFileName);
10804
+ const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
10805
+ const isLocalClaude = (!isGlobal && !!(_scopedSettings && _scopedSettings.local));
10806
+ const settingsFileName = isLocalClaude
10807
+ ? _scopedSettings.local
10808
+ : ((_scopedSettings && _scopedSettings.global) || 'settings.json');
10809
+ // ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
10810
+ // must resolve under targetDir (this path also drives a recursive mkdirSync).
10811
+ const settingsPath = assertDestWithinConfigHome(targetDir, settingsFileName);
9922
10812
 
9923
10813
  // #338 migration: if a prior local Claude install wrote GSD-shaped entries to settings.json,
9924
10814
  // relocate them to settings.local.json and clear them from the shared file in the same run.
@@ -10012,7 +10902,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10012
10902
  // Claude Code sets $CLAUDE_PROJECT_DIR; Antigravity does not — and on
10013
10903
  // Windows its own substitution logic doubles the path (#2557). It runs
10014
10904
  // project hooks with the project dir as cwd, so bare relative paths work.
10015
- 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 });
10016
10909
  const hookOpts = { portableHooks: hasPortableHooks, runtime };
10017
10910
  // #2979: local-install hook commands also use the absolute node path so
10018
10911
  // GUI/minimal-PATH runtimes can resolve them. Bare `node` fails when the
@@ -10101,7 +10994,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10101
10994
  // installAllRuntimes can register it at finalize time when the user opts
10102
10995
  // in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
10103
10996
  // / localCmd resolution logic is shared with the other JS hooks.
10104
- const updateBannerCommand = isOpencode || isKilo
10997
+ const updateBannerCommand = _hostBehaviors(runtime).skipUpdateBannerCommand
10105
10998
  ? null
10106
10999
  : (isGlobal
10107
11000
  ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
@@ -10184,11 +11077,20 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10184
11077
  /**
10185
11078
  * Apply statusline config, then print completion message
10186
11079
  */
10187
- function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
10188
- const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
11080
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) {
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);
10189
11091
  const plan = resolveInstallPlan(runtime);
10190
11092
 
10191
- if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
11093
+ if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
10192
11094
  if (!isGlobal && !forceStatusline) {
10193
11095
  // Local installs skip statusLine by default: repo settings.json takes precedence over
10194
11096
  // profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -10214,7 +11116,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10214
11116
  // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
10215
11117
  // cline either lack the surface or use a different config schema.
10216
11118
  const { shouldInstallBanner, bannerCommand } = bannerOpts;
10217
- if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
11119
+ if (shouldInstallBanner && settings && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
10218
11120
  if (!bannerCommand) {
10219
11121
  console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
10220
11122
  } else {
@@ -10243,10 +11145,16 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10243
11145
  // Merges GSD-owned entries non-destructively (preserves existing user permissions).
10244
11146
  // Scoped to Claude only: antigravity/qwen/hermes/codebuddy also write
10245
11147
  // settings.json but use different runtimes and do not use these permission strings.
10246
- if (runtime === 'claude') {
11148
+ if (_hostBehaviors(runtime).permissionsSchema === 'claude') {
10247
11149
  mergeClaudePermissions(settings);
10248
11150
  }
10249
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
+
10250
11158
  // Write settings when runtime supports settings.json.
10251
11159
  // #3002 CR: defense-in-depth — re-run validateHookFields right before
10252
11160
  // serialization. The push-site guards above already skip null-command
@@ -10268,6 +11176,15 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10268
11176
  configureKiloPermissions(isGlobal, configDir);
10269
11177
  }
10270
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
+
10271
11188
  // For non-Claude runtimes, DEFAULT resolve_model_ids to "omit" in ~/.gsd/defaults.json
10272
11189
  // when it is absent or falsy, so resolveModelInternal() returns '' instead of Claude
10273
11190
  // aliases (opus/sonnet/haiku) the runtime can't resolve. An explicit `true` opt-in
@@ -10276,7 +11193,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10276
11193
  // chat model instead of pinning the resolved model. See #1156 (default-to-omit
10277
11194
  // intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern
10278
11195
  // on configureOpencodePermissions above.
10279
- if (runtime !== 'claude' && !process.env.GSD_TEST_MODE) {
11196
+ if (!_hostBehaviors(runtime).nativeModelAliases && !process.env.GSD_TEST_MODE) {
10280
11197
  const gsdDir = path.join(os.homedir(), '.gsd');
10281
11198
  const defaultsPath = path.join(gsdDir, 'defaults.json');
10282
11199
  try {
@@ -10318,7 +11235,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10318
11235
  // Restart is required for CC to pick up newly-installed skills, and the
10319
11236
  // slash-menu surface depends on CC version — so the instruction needs to
10320
11237
  // cover both invocation paths to avoid #2957-style "no commands appear".
10321
- if (runtime === 'claude' && isGlobal) {
11238
+ if (_hostBehaviors(runtime).skillsGlobalOnboarding && isGlobal) {
10322
11239
  console.log(`
10323
11240
  ${green}Done!${reset} Restart ${program}, then in any directory either type ${cyan}${command}${reset} or ask Claude to run the ${cyan}gsd-new-project${reset} skill.
10324
11241
 
@@ -10327,7 +11244,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10327
11244
  return;
10328
11245
  }
10329
11246
 
10330
- if (runtime === 'kimi') {
11247
+ if (_hostBehaviors(runtime).doneBannerStyle === 'kimi-agent-file') {
10331
11248
  const agentPath = configDir ? path.join(configDir, 'agents', 'gsd.yaml') : 'agents/gsd.yaml';
10332
11249
  console.log(`
10333
11250
  ${green}Done!${reset} Start ${program} with ${cyan}kimi --agent-file ${agentPath}${reset}, then run ${cyan}${command}${reset}.
@@ -10415,13 +11332,14 @@ const runtimeMap = {
10415
11332
  '10': 'kimi',
10416
11333
  '11': 'kilo',
10417
11334
  '12': 'opencode',
10418
- '13': 'qwen',
10419
- '14': 'trae',
10420
- '15': 'windsurf',
10421
- '16': 'zcode'
11335
+ '13': 'pi',
11336
+ '14': 'qwen',
11337
+ '15': 'trae',
11338
+ '16': 'windsurf',
11339
+ '17': 'zcode'
10422
11340
  };
10423
- const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf', 'zcode'];
10424
- 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';
10425
11343
 
10426
11344
  /**
10427
11345
  * Build the runtime-selection prompt text shown by the interactive installer.
@@ -10441,11 +11359,12 @@ function buildRuntimePromptText() {
10441
11359
  ${cyan}10${reset}) Kimi ${dim}(~/.config/agents, then ~/.agents if existing)${reset}
10442
11360
  ${cyan}11${reset}) Kilo ${dim}(~/.config/kilo)${reset}
10443
11361
  ${cyan}12${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
10444
- ${cyan}13${reset}) Qwen Code ${dim}(~/.qwen)${reset}
10445
- ${cyan}14${reset}) Trae ${dim}(~/.trae)${reset}
10446
- ${cyan}15${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
10447
- ${cyan}16${reset}) ZCode ${dim}(~/.zcode)${reset}
10448
- ${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
10449
11368
 
10450
11369
  ${dim}Select multiple: 1,2,6 or 1 2 6${reset}
10451
11370
  `;
@@ -10477,7 +11396,7 @@ function parseRuntimeInput(answer) {
10477
11396
  }
10478
11397
  }
10479
11398
 
10480
- return selected.length > 0 ? selected : ['claude'];
11399
+ return selected.length > 0 ? selected : [DEFAULT_RUNTIME];
10481
11400
  }
10482
11401
 
10483
11402
  function promptRuntime(callback) {
@@ -10992,8 +11911,8 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
10992
11911
  '.agents', // antigravity local form (canonical, #791)
10993
11912
  '.agent', // antigravity local form (legacy, backward-compat)
10994
11913
  '.cursor',
10995
- '.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
10996
- '.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)
10997
11916
  '.codeium/windsurf',
10998
11917
  '.augment',
10999
11918
  '.trae',
@@ -11096,7 +12015,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
11096
12015
  throw error;
11097
12016
  }
11098
12017
 
11099
- const statuslineRuntimes = ['claude'];
12018
+ const statuslineRuntimes = [DEFAULT_RUNTIME];
11100
12019
  const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
11101
12020
 
11102
12021
  const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
@@ -11193,6 +12112,13 @@ module.exports = {
11193
12112
  generateCodexAgentToml,
11194
12113
  cleanupCodexSkillMetadataSidecars,
11195
12114
  cleanupWindsurfLegacyDevinSkills,
12115
+ cleanupMovedSkillsOldLocation,
12116
+ _resolveMovedSkillsOldDir,
12117
+ _resolveSkillsRootDir,
12118
+ codexBareAgentsHasOnlyKnownScalars,
12119
+ extractCodexUserAgentsScalars,
12120
+ spliceCodexAgentsScalars,
12121
+ CODEX_EXTENDED_HOOK_EVENTS,
11196
12122
  generateCodexConfigBlock,
11197
12123
  stripGsdFromCodexConfig,
11198
12124
  migrateCodexHooksMapFormat,
@@ -11212,6 +12138,9 @@ module.exports = {
11212
12138
  install,
11213
12139
  installAllRuntimes,
11214
12140
  uninstall,
12141
+ // #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
12142
+ _resolveHostBehaviors,
12143
+ FALLBACK_HOST_BEHAVIORS,
11215
12144
  convertSlashCommandsToCodexSkillMentions,
11216
12145
  convertClaudeCommandToCodexSkill,
11217
12146
  convertClaudeCommandToKimiSkill,
@@ -11235,6 +12164,13 @@ module.exports = {
11235
12164
  getConfigDirFromHome,
11236
12165
  resolveKiloConfigPath,
11237
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,
11238
12174
  claudeToCopilotTools,
11239
12175
  convertCopilotToolName,
11240
12176
  convertClaudeToCopilotContent,
@@ -11276,12 +12212,22 @@ module.exports = {
11276
12212
  mergeGsdAgentsMd,
11277
12213
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
11278
12214
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
12215
+ GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
12216
+ GSD_CURSOR_STOP_HOOK_SCRIPT,
12217
+ GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
12218
+ GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
12219
+ GSD_CURSOR_HOOK_SCRIPTS,
11279
12220
  GSD_CURSOR_HOOK_MARKER,
11280
12221
  buildCursorHookEntry,
11281
12222
  isManagedCursorHookEntry,
11282
12223
  reconcileCursorHooksJson,
11283
12224
  writeCursorHooksJson,
11284
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,
11285
12231
  stripGsdFromAgentsMd,
11286
12232
  GSD_AGENTS_MD_MARKER,
11287
12233
  GSD_AGENTS_MD_CLOSE_MARKER,
@@ -11380,7 +12326,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11380
12326
  console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
11381
12327
  process.exit(1);
11382
12328
  }
11383
- const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
12329
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : [DEFAULT_RUNTIME];
11384
12330
  for (const runtime of runtimes) {
11385
12331
  uninstall(hasGlobal, runtime);
11386
12332
  }
@@ -11392,12 +12338,12 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11392
12338
  }
11393
12339
  } else if (hasGlobal || hasLocal) {
11394
12340
  // Default to Claude if no runtime specified but location is
11395
- installAllRuntimes(['claude'], hasGlobal, false);
12341
+ installAllRuntimes([DEFAULT_RUNTIME], hasGlobal, false);
11396
12342
  } else {
11397
12343
  // Interactive
11398
12344
  if (!process.stdin.isTTY) {
11399
12345
  console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
11400
- installAllRuntimes(['claude'], true, false);
12346
+ installAllRuntimes([DEFAULT_RUNTIME], true, false);
11401
12347
  } else {
11402
12348
  promptRuntime((runtimes) => {
11403
12349
  promptLocation(runtimes);