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

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 (84) 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 +28 -0
  7. package/bin/install.js +624 -456
  8. package/gsd-core/bin/gsd-tools.cjs +40 -1
  9. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  10. package/gsd-core/bin/lib/audit.cjs +6 -3
  11. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  12. package/gsd-core/bin/lib/capability-registry.cjs +325 -64
  13. package/gsd-core/bin/lib/capability-validator.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  15. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  16. package/gsd-core/bin/lib/commands.cjs +7 -5
  17. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  18. package/gsd-core/bin/lib/config.cjs +96 -0
  19. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  20. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  21. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  22. package/gsd-core/bin/lib/host-integration.cjs +16 -0
  23. package/gsd-core/bin/lib/init.cjs +74 -30
  24. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  25. package/gsd-core/bin/lib/install-engine.cjs +151 -11
  26. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  27. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  28. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  29. package/gsd-core/bin/lib/milestone.cjs +3 -3
  30. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  31. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  32. package/gsd-core/bin/lib/phase.cjs +78 -16
  33. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  34. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  35. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  36. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  37. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  38. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
  39. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  40. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
  41. package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
  42. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
  43. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  44. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  45. package/gsd-core/bin/lib/state.cjs +24 -24
  46. package/gsd-core/bin/lib/surface.cjs +30 -0
  47. package/gsd-core/bin/lib/uat.cjs +4 -1
  48. package/gsd-core/bin/lib/validate.cjs +15 -6
  49. package/gsd-core/bin/lib/verify.cjs +33 -37
  50. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  51. package/gsd-core/bin/shared/model-catalog.json +6 -6
  52. package/gsd-core/references/api-coverage.md +104 -0
  53. package/gsd-core/references/model-profiles.md +2 -2
  54. package/gsd-core/references/planning-config.md +2 -0
  55. package/gsd-core/references/specless-probe-fallback.md +172 -0
  56. package/gsd-core/templates/config.json +2 -1
  57. package/gsd-core/workflows/audit-fix.md +9 -1
  58. package/gsd-core/workflows/code-review-fix.md +7 -3
  59. package/gsd-core/workflows/code-review.md +4 -1
  60. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  61. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  62. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  63. package/gsd-core/workflows/execute-phase.md +1 -25
  64. package/gsd-core/workflows/plan-phase.md +31 -2
  65. package/gsd-core/workflows/quick.md +2 -2
  66. package/gsd-core/workflows/review.md +59 -13
  67. package/gsd-core/workflows/settings-advanced.md +5 -5
  68. package/gsd-core/workflows/settings.md +2 -2
  69. package/gsd-core/workflows/verify-phase.md +3 -2
  70. package/gsd-core/workflows/verify-work.md +38 -0
  71. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  72. package/hooks/dist/gsd-cursor-stop.js +48 -0
  73. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  74. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  75. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  76. package/hooks/gsd-cursor-pre-tool.js +76 -0
  77. package/hooks/gsd-cursor-stop.js +48 -0
  78. package/hooks/gsd-cursor-subagent-start.js +50 -0
  79. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  80. package/hooks/managed-hooks-registry.cjs +4 -0
  81. package/package.json +2 -1
  82. package/scripts/build-hooks.js +5 -1
  83. package/scripts/lint-phase-id-drift.cjs +150 -0
  84. package/scripts/run-tests.cjs +21 -1
package/bin/install.js CHANGED
@@ -43,6 +43,7 @@ const {
43
43
  readBaseRefFromSettings,
44
44
  } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
45
45
  const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
46
+ const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
46
47
  const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
47
48
  // Canonical set of hook files shipped to users. Imported here so writeManifest()
48
49
  // records exactly the same set that build-hooks.js copies to hooks/dist/, making
@@ -101,6 +102,45 @@ const reset = '\x1b[0m';
101
102
  // Codex config.toml constants
102
103
  const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by gsd-core installer';
103
104
  const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: ';
105
+ // Known scalar fields of Codex's `AgentsToml` struct (codex-rs/config/src/
106
+ // config_toml.rs \u2014 `[agents]` table). Codex marks the struct
107
+ // `#[schemars(deny_unknown_fields)]`, so a bare `[agents]` table is valid ONLY
108
+ // when every direct key is one of these (named agent roles live in the flattened
109
+ // `[agents.<name>]` sub-tables, a separate `AgentRoleToml`). GSD writes only
110
+ // `max_depth` (ADR-1239 upgrade 2 / #2088); the full set is enumerated so the
111
+ // schema check accepts a user's other legitimate AgentsToml scalars too.
112
+ const CODEX_AGENTS_TOML_SCALAR_KEYS = new Set([
113
+ 'max_threads',
114
+ 'max_depth',
115
+ 'job_max_runtime_seconds',
116
+ 'interrupt_message',
117
+ ]);
118
+ // GSD's managed dispatch-depth value. Codex's implicit default is also 1 (root
119
+ // sessions start at depth 0); writing it EXPLICITLY pins the negotiated
120
+ // `dispatch.maxDepth: 1` axis instead of relying on codex-cli's implicit default
121
+ // (ADR-1239 upgrade 2 / #2088). Per the negotiated capability, GSD-hosted Codex
122
+ // dispatch is single-level (maxDepth === 1 \u2192 `degradationFor` flattens waves).
123
+ const GSD_CODEX_AGENTS_MAX_DEPTH = 1;
124
+ // Codex hooks.json lifecycle events GSD registers beyond SessionStart (which has
125
+ // its own dedicated path). This is Codex's OWN hook-event vocabulary (per
126
+ // developers.openai.com/codex/config-reference), distinct from the cross-runtime
127
+ // settings.json `extendedHookEvents` descriptor field (a claude/gemini-family
128
+ // allowlist consumed only by hooksSurface==='settings-json' runtimes — Codex is
129
+ // codex-hooks-json). All route through gsd-context-monitor.js. #772 wired the
130
+ // first three; #2088 adds the remaining six documented events so GSD's monitor
131
+ // fires at the same lifecycle points as in Claude Code. Install and uninstall
132
+ // share this list so the registered set and the removed set never diverge.
133
+ const CODEX_EXTENDED_HOOK_EVENTS = [
134
+ 'SubagentStart',
135
+ 'Stop',
136
+ 'PostToolUse',
137
+ 'PreToolUse',
138
+ 'PermissionRequest',
139
+ 'PreCompact',
140
+ 'PostCompact',
141
+ 'SubagentStop',
142
+ 'UserPromptSubmit',
143
+ ];
104
144
  // Codex's hook-enabling feature flag (issue #3566). Codex itself marks
105
145
  // `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the
106
146
  // canonical current key under [features] is `hooks`. The installer always
@@ -126,6 +166,9 @@ function isCodexHooksFeatureKey(key) {
126
166
  //
127
167
  // Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
128
168
  // GSD entries are appended only when not already present (idempotent).
169
+ // The reference/default runtime (ADR-1239 reference host). Single-sourced here
170
+ // instead of scattered literal 'claude' defaults/rosters (#2086).
171
+ const DEFAULT_RUNTIME = 'claude';
129
172
  const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
130
173
  'Bash(npx gsd-core *)',
131
174
  'Read(.planning/*)',
@@ -212,12 +255,30 @@ const GSD_COPILOT_SESSION_HOOK_PWSH =
212
255
  // Cursor reads hook configs from <project-root>/.cursor/hooks.json (local) or
213
256
  // ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { <event>: [...] } }.
214
257
  // 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)
258
+ // A `command` hook entry runs an external script. GSD registers six managed hooks
259
+ // (AC4a upgrade, #2089 — ADR-1239):
260
+ // sessionStart → gsd-cursor-session-start.js (context injection)
261
+ // postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
262
+ // preToolUse → gsd-cursor-pre-tool.js (write-path guard)
263
+ // stop → gsd-cursor-stop.js (verify-work reminder)
264
+ // subagentStart → gsd-cursor-subagent-start.js (subagent context injection)
265
+ // subagentStop → gsd-cursor-subagent-stop.js (subagent completion reminder)
218
266
  // Cursor docs: https://cursor.com/docs/hooks
219
267
  const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
220
268
  const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
269
+ const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js';
270
+ const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js';
271
+ const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js';
272
+ const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js';
273
+ // All GSD-managed Cursor hook scripts (used by uninstall cleanup).
274
+ const GSD_CURSOR_HOOK_SCRIPTS = [
275
+ GSD_CURSOR_SESSION_HOOK_SCRIPT,
276
+ GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
277
+ GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
278
+ GSD_CURSOR_STOP_HOOK_SCRIPT,
279
+ GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
280
+ GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
281
+ ];
221
282
  // Marker comment embedded in managed hook entries so GSD can find+remove them.
222
283
  const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
223
284
 
@@ -273,60 +334,20 @@ const {
273
334
  } = require(path.join(_gsdLibDir, 'model-catalog.cjs'));
274
335
  const {
275
336
  resolveTierEntry: gsdResolveTierEntry,
276
- EFFORT_SET: GSD_EFFORT_SET,
277
337
  } = require(path.join(_gsdLibDir, 'model-resolver.cjs'));
278
338
 
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
- }
339
+ // #2071 — install-time effort resolution (readGsdEffectiveEffortConfig /
340
+ // resolveInstallTimeEffort, plus their _getGsdEffortCatalog + _readGsdConfigFile
341
+ // helpers) was extracted into the shipped gsd-core/bin/lib/install-effort-resolver.cjs
342
+ // so `gsd-tools effort sync` can require it from the installed runtime instead of this
343
+ // package-root bin/install.js, which the installer never copies (#2071 crash). The
344
+ // installer imports it back here — single source of truth for both surfaces.
345
+ const {
346
+ readGsdEffectiveEffortConfig,
347
+ resolveInstallTimeEffort,
348
+ _getGsdEffortCatalog,
349
+ _readGsdConfigFile,
350
+ } = require(path.join(_gsdLibDir, 'install-effort-resolver.cjs'));
330
351
 
331
352
  const {
332
353
  MINIMAL_SKILL_ALLOWLIST,
@@ -350,6 +371,80 @@ try {
350
371
  } catch (_) {
351
372
  _capabilityRegistry = undefined;
352
373
  }
374
+
375
+ // Fail-safe floor for the reference host's #338-privacy-critical behaviors, used
376
+ // ONLY when the first-party capability registry cannot be loaded (a broken bundle).
377
+ // Without it, a registry-load failure would make `_hostBehaviors('claude')` return
378
+ // {} and silently route a claude LOCAL install to the repo-shared, committed
379
+ // `settings.json` instead of the gitignored `settings.local.json` (#338) — leaking
380
+ // engineer-specific absolute paths. Keyed by runtime id (a DATA lookup, not a
381
+ // hardcoded string-equality branch) so behavior degrades CLOSED (safe), never open.
382
+ // The live descriptor (capabilities/claude/capability.json) remains the source of
383
+ // truth; this mirrors only the privacy-load-bearing subset. (ADR-1239 / #2086)
384
+ const FALLBACK_HOST_BEHAVIORS = Object.freeze({
385
+ claude: Object.freeze({
386
+ settingsFileByScope: Object.freeze({ local: 'settings.local.json', global: 'settings.json' }),
387
+ permissionsSchema: 'claude',
388
+ sourceMarkerFile: '.gsd-source',
389
+ }),
390
+ });
391
+
392
+ /**
393
+ * Resolve a runtime's host behaviors from a capability registry, with the
394
+ * #338-privacy fail-safe floor when the registry (or the runtime's descriptor)
395
+ * is unavailable. Registry is passed in so this is unit-testable under a
396
+ * simulated registry-load failure. (ADR-1239 / #2086)
397
+ */
398
+ function _resolveHostBehaviors(runtime, registry) {
399
+ const cap = registry && registry.runtimes && registry.runtimes[runtime];
400
+ const declared = cap && cap.runtime && cap.runtime.hostBehaviors;
401
+ if (declared) return declared;
402
+ return FALLBACK_HOST_BEHAVIORS[runtime] || {};
403
+ }
404
+
405
+ /**
406
+ * Host-specific install behaviors, declared on the runtime descriptor
407
+ * (capabilities/<runtime>/capability.json -> runtime.hostBehaviors) instead of
408
+ * scattered `runtime === '<id>'` string checks (ADR-1239 / #2086). Returns {}
409
+ * for runtimes that declare none, so every behavior branch degrades to the
410
+ * generic path by default — EXCEPT the reference host's #338-critical keys, which
411
+ * fall back to FALLBACK_HOST_BEHAVIORS if the registry failed to load.
412
+ */
413
+ function _hostBehaviors(runtime) {
414
+ return _resolveHostBehaviors(runtime, _capabilityRegistry);
415
+ }
416
+
417
+ /**
418
+ * Resolve the ACTUAL on-disk skills-install directory for a runtime, honoring a
419
+ * skills-kind `home` override (ADR-1239 upgrade 3 / #2088: e.g. Codex skills ->
420
+ * $HOME/.agents/skills instead of the runtime's configDir). Descriptor-driven
421
+ * (no runtime === '<id>' check) so the snapshot/rollback machinery and post-install
422
+ * verification look where the skills actually landed. Falls back to <targetDir>/skills.
423
+ */
424
+ function _resolveSkillsRootDir(runtime, targetDir, scope) {
425
+ try {
426
+ const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
427
+ const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
428
+ if (skillsKind) return path.join(skillsKind.home || targetDir, skillsKind.destSubpath);
429
+ } catch (_e) { /* fall through to the configDir default */ }
430
+ return path.join(targetDir, 'skills');
431
+ }
432
+
433
+ /**
434
+ * Construct the imperative Host-Integration adapter (ADR-1239 / #2086), FAIL-OPEN.
435
+ * `createImperativeAdapter` composes the capability registry via
436
+ * `loadRegistry({includeInstalled:true})`, which require()s several capability
437
+ * modules. If any is unavailable (e.g. a packaging regression), return null so
438
+ * the caller degrades to the engine directly rather than hard-crashing install/
439
+ * uninstall — matching the optional `capability-registry.cjs` load posture above.
440
+ */
441
+ function _runtimeAdapter(runtime) {
442
+ try {
443
+ return createImperativeAdapter({ runtime });
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
353
448
  const {
354
449
  applyInstallerMigrationPlan,
355
450
  discoverInstallerMigrations,
@@ -910,6 +1005,9 @@ function resolveKiloConfigPath(configDir) {
910
1005
  return path.join(configDir, 'kilo.json');
911
1006
  }
912
1007
 
1008
+ // #2087 — attribution config-path resolvers, keyed by descriptor (hostBehaviors.attributionConfigResolver)
1009
+ const ATTRIBUTION_CONFIG_RESOLVERS = { opencode: resolveOpencodeConfigPath, kilo: resolveKiloConfigPath };
1010
+
913
1011
  /**
914
1012
  * Strip JSONC comments (// and /* *​/) from a string to produce valid JSON.
915
1013
  * Handles comments inside strings correctly (does not strip them).
@@ -1071,126 +1169,6 @@ function readGsdEffectiveModelOverrides(targetDir = null) {
1071
1169
  return { ...(global || {}), ...(projectOverrides || {}) };
1072
1170
  }
1073
1171
 
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
1172
  /**
1195
1173
  * #443 — Inject `effort: <value>` into YAML frontmatter of a Claude .md agent
1196
1174
  * file in a newline-agnostic way (LF and CRLF source files are both handled).
@@ -1296,29 +1274,6 @@ const READONLY_AGENT_DISALLOWED_TOOLS = {
1296
1274
  'gsd-ui-auditor': 'Edit, MultiEdit',
1297
1275
  };
1298
1276
 
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
1277
  /**
1323
1278
  * #2517 — Build a runtime-aware tier resolver for the install path.
1324
1279
  *
@@ -1421,15 +1376,14 @@ function getCommitAttribution(runtime) {
1421
1376
 
1422
1377
  let result;
1423
1378
 
1424
- if (runtime === 'opencode' || runtime === 'kilo') {
1425
- const resolveConfigPath = runtime === 'opencode'
1426
- ? resolveOpencodeConfigPath
1427
- : resolveKiloConfigPath;
1379
+ const _attrResolverKey = _hostBehaviors(runtime).attributionConfigResolver;
1380
+ if (_attrResolverKey && ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]) {
1381
+ const resolveConfigPath = ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey];
1428
1382
  const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
1429
1383
  result = (config && config.disable_ai_attribution === true) ? null : undefined;
1430
- } else if (runtime === 'claude') {
1384
+ } else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') {
1431
1385
  // Claude Code
1432
- const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
1386
+ const settings = readSettings(path.join(getGlobalConfigDir(runtime, explicitConfigDir), 'settings.json'));
1433
1387
  if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
1434
1388
  result = undefined;
1435
1389
  } else if (settings.attribution.commit === '') {
@@ -1897,7 +1851,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
1897
1851
  // Hermes' SKILL.md spec lists `version` as a required frontmatter field.
1898
1852
  // Track GSD's package version so Hermes' skill_view() reports a stable
1899
1853
  // identifier per install.
1900
- if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
1854
+ if (_hostBehaviors(runtime).skillFrontmatterVersion) fm += `version: ${yamlQuote(pkg.version)}\n`;
1901
1855
  // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
1902
1856
  // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
1903
1857
  // we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
@@ -3266,6 +3220,77 @@ function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
3266
3220
  return removed;
3267
3221
  }
3268
3222
 
3223
+ /**
3224
+ * Migrate a skills kind that moved to an alternate `home` (ADR-1239 split-home):
3225
+ * remove now-stale `<prefix>*` skill dirs left at the OLD configDir-rooted
3226
+ * location by installs from before the move. Without this, upgrading (e.g. Codex
3227
+ * relocating skills to ~/.agents/skills) orphans the pre-move dirs at
3228
+ * ~/.codex/skills. Only managed `<prefix>*` dirs are touched; user-owned content
3229
+ * (non-prefixed dirs, gsd-dev-preferences, symlinks) is preserved. Fail-open.
3230
+ * @param {string} oldSkillsDir absolute path to the pre-move skills location
3231
+ * @param {string} prefix managed skill-dir prefix (e.g. 'gsd-')
3232
+ * @returns {number} count of stale dirs removed
3233
+ */
3234
+ function cleanupMovedSkillsOldLocation(oldSkillsDir, prefix) {
3235
+ if (!fs.existsSync(oldSkillsDir)) return 0;
3236
+
3237
+ // Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
3238
+ const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
3239
+ let removed = 0;
3240
+
3241
+ for (const entry of fs.readdirSync(oldSkillsDir, { withFileTypes: true })) {
3242
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
3243
+ if (_userOwnedSkillDirs.has(entry.name)) continue;
3244
+
3245
+ const dirToRemove = path.join(oldSkillsDir, entry.name);
3246
+ try {
3247
+ // Symlink guard (mirrors cleanupWindsurfLegacyDevinSkills): never delete
3248
+ // through a symlinked gsd-* dir — it could escape the tree.
3249
+ const stat = fs.lstatSync(dirToRemove);
3250
+ if (stat.isSymbolicLink()) continue;
3251
+
3252
+ fs.rmSync(dirToRemove, { recursive: true, force: true });
3253
+ removed++;
3254
+ } catch (_err) {
3255
+ // Fail open — a single bad dir must not block install/uninstall.
3256
+ }
3257
+ }
3258
+
3259
+ // Prune the old skills dir if now empty — leaves the configHome clean.
3260
+ // Never remove a non-empty container (user may keep other content there).
3261
+ try {
3262
+ if (fs.existsSync(oldSkillsDir) && fs.readdirSync(oldSkillsDir).length === 0) {
3263
+ fs.rmdirSync(oldSkillsDir);
3264
+ }
3265
+ } catch (_err) {
3266
+ // best-effort container cleanup
3267
+ }
3268
+
3269
+ return removed;
3270
+ }
3271
+
3272
+ /**
3273
+ * When a runtime's skills kind declares an alternate `home` (split-home move),
3274
+ * return the now-stale configDir-rooted skills location that installs before the
3275
+ * move used; null when no move is in effect (no home override, or home resolves
3276
+ * to the same path). Descriptor-driven — no per-runtime hardcoding.
3277
+ * @returns {string|null}
3278
+ */
3279
+ function _resolveMovedSkillsOldDir(runtime, targetDir, scope) {
3280
+ try {
3281
+ const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
3282
+ const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
3283
+ if (skillsKind && skillsKind.home) {
3284
+ const oldDir = path.join(targetDir, skillsKind.destSubpath);
3285
+ const newDir = path.join(skillsKind.home, skillsKind.destSubpath);
3286
+ if (path.resolve(oldDir) !== path.resolve(newDir)) return oldDir;
3287
+ }
3288
+ } catch (_e) {
3289
+ // No migration when the layout can't resolve — never block on this.
3290
+ }
3291
+ return null;
3292
+ }
3293
+
3269
3294
  /**
3270
3295
  * Generate the GSD config block for Codex config.toml.
3271
3296
  * @param {Array<{name: string, description: string}>} agents
@@ -3281,6 +3306,17 @@ function generateCodexConfigBlock(agents, targetDir) {
3281
3306
  '',
3282
3307
  ];
3283
3308
 
3309
+ // ADR-1239 upgrade 2 / #2088 — explicit dispatch tuning. Pin `max_depth` on the
3310
+ // `[agents]` (AgentsToml) table rather than relying on codex-cli's implicit
3311
+ // default, realizing the negotiated `dispatch.maxDepth: 1` axis. This bare
3312
+ // `[agents]` scalar table coexists with the flattened `[agents.<name>]` role
3313
+ // sub-tables below (validated by validateCodexConfigSchema, which permits a
3314
+ // known-scalar-only `[agents]`). Emitted before the role tables so the parent
3315
+ // table is opened first.
3316
+ lines.push('[agents]');
3317
+ lines.push(`max_depth = ${GSD_CODEX_AGENTS_MAX_DEPTH}`);
3318
+ lines.push('');
3319
+
3284
3320
  for (const { name, description } of agents) {
3285
3321
  // #2727 — Codex 0.124.0 requires [agents.<name>] struct format, not [[agents]] sequence.
3286
3322
  // [[agents]] (introduced in #2645) is rejected by codex-cli 0.124.0 with
@@ -3294,6 +3330,52 @@ function generateCodexConfigBlock(agents, targetDir) {
3294
3330
  return lines.join('\n');
3295
3331
  }
3296
3332
 
3333
+ /**
3334
+ * Extract a user's pre-existing AgentsToml scalar assignments from a bare
3335
+ * `[agents]` table — every known scalar EXCEPT `max_depth` (which GSD manages
3336
+ * and always re-emits as 1). Returned as raw `key = value` line strings so
3337
+ * mergeCodexConfig can PRESERVE them in the managed block instead of silently
3338
+ * dropping the user's tuning when the bare `[agents]` table is purged (#2088
3339
+ * review finding: the loosened validator declares such a table legitimate, so
3340
+ * install must not destroy it). Only the first bare `[agents]` section is read;
3341
+ * `[agents.<name>]` role tables are ignored. Fail-open → [].
3342
+ * @returns {string[]}
3343
+ */
3344
+ function extractCodexUserAgentsScalars(content) {
3345
+ const preserved = [];
3346
+ let section;
3347
+ try {
3348
+ section = getTomlTableSections(content).find((s) => !s.array && s.path === 'agents');
3349
+ } catch (_e) {
3350
+ return preserved;
3351
+ }
3352
+ if (!section) return preserved;
3353
+ const body = content.slice(section.headerEnd, section.end);
3354
+ for (const record of getTomlLineRecords(body)) {
3355
+ if (record.startsInMultilineString || record.tableHeader) continue;
3356
+ const trimmed = record.text.trim();
3357
+ if (!trimmed || trimmed.startsWith('#')) continue;
3358
+ if (!record.keySegments || record.keySegments.length !== 1) continue;
3359
+ const key = record.keySegments[0];
3360
+ if (key === 'max_depth') continue; // GSD-managed — GSD's value wins.
3361
+ if (!CODEX_AGENTS_TOML_SCALAR_KEYS.has(key)) continue;
3362
+ preserved.push(trimmed);
3363
+ }
3364
+ return preserved;
3365
+ }
3366
+
3367
+ /**
3368
+ * Splice preserved user AgentsToml scalar lines into the managed GSD config
3369
+ * block, immediately after the `[agents]` header and before GSD's `max_depth`
3370
+ * line. Operates on the pre-EOL-normalization block (LF joins), matching only
3371
+ * the bare `[agents]` header (never `[agents.<name>]`). Returns the block
3372
+ * unchanged when there is nothing to preserve or the anchor is absent.
3373
+ */
3374
+ function spliceCodexAgentsScalars(block, scalarLines) {
3375
+ if (!scalarLines || scalarLines.length === 0) return block;
3376
+ return block.replace(/(\n\[agents\]\n)(max_depth = )/, `$1${scalarLines.join('\n')}\n$2`);
3377
+ }
3378
+
3297
3379
  /**
3298
3380
  * Strip any managed GSD agent sections from a TOML string.
3299
3381
  *
@@ -3316,6 +3398,16 @@ function stripCodexGsdAgentSections(content) {
3316
3398
  return true;
3317
3399
  }
3318
3400
 
3401
+ // GSD's managed `[agents]` scalar block (ADR-1239 upgrade 2 / #2088 — the
3402
+ // `max_depth` dispatch-tuning table). Install purges any pre-existing bare
3403
+ // `[agents]` and writes its own, so a known-scalar-only bare `[agents]` is
3404
+ // GSD-owned; strip it on uninstall. (The marker path already removes it via
3405
+ // the marker-to-EOF cut; this covers the no-marker fallback.)
3406
+ if (!section.array && section.path === 'agents') {
3407
+ const body = content.slice(section.headerEnd, section.end);
3408
+ return codexBareAgentsHasOnlyKnownScalars(body);
3409
+ }
3410
+
3319
3411
  // Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose
3320
3412
  // `name = "gsd-..."`, preserving user-authored [[agents]] entries.
3321
3413
  if (section.array && section.path === 'agents') {
@@ -3343,7 +3435,11 @@ function stripGsdFromCodexConfig(content) {
3343
3435
  const codexHooksOwnership = getManagedCodexHooksOwnership(content);
3344
3436
 
3345
3437
  if (markerIndex !== -1) {
3346
- // Has GSD marker — remove everything from marker to EOF
3438
+ // Has GSD marker — remove everything from marker to EOF. First recover the
3439
+ // user's own AgentsToml scalars (max_threads etc.) that install folded into
3440
+ // the managed [agents] block (#2088), so a full install→uninstall cycle
3441
+ // round-trips the user's tuning. GSD-managed max_depth is dropped.
3442
+ const preservedScalars = extractCodexUserAgentsScalars(content.slice(markerIndex));
3347
3443
  let before = content.substring(0, markerIndex);
3348
3444
  before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership);
3349
3445
  // Also strip GSD-injected feature keys above the marker (Case 3 inject)
@@ -3352,6 +3448,9 @@ function stripGsdFromCodexConfig(content) {
3352
3448
  before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
3353
3449
  before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
3354
3450
  before = before.replace(/^(?:\r?\n)+/, '').trimEnd();
3451
+ if (preservedScalars.length > 0) {
3452
+ before = (before ? before + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
3453
+ }
3355
3454
  if (!before) return null;
3356
3455
  return before + eol;
3357
3456
  }
@@ -3362,7 +3461,11 @@ function stripGsdFromCodexConfig(content) {
3362
3461
  cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
3363
3462
  cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
3364
3463
 
3365
- // Remove [agents.gsd-*] sections (from header to next section or EOF)
3464
+ // #2088: recover the user's own AgentsToml scalars before the [agents] table is
3465
+ // stripped, so they survive uninstall even in the no-marker fallback path.
3466
+ const preservedScalars = extractCodexUserAgentsScalars(cleaned);
3467
+
3468
+ // Remove [agents.gsd-*] sections + the managed known-scalar [agents] table.
3366
3469
  cleaned = stripCodexGsdAgentSections(cleaned);
3367
3470
 
3368
3471
  // Remove [features] section if now empty (only header, no keys before next section)
@@ -3373,6 +3476,10 @@ function stripGsdFromCodexConfig(content) {
3373
3476
 
3374
3477
  cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd();
3375
3478
 
3479
+ if (preservedScalars.length > 0) {
3480
+ cleaned = (cleaned ? cleaned + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
3481
+ }
3482
+
3376
3483
  if (!cleaned) return null;
3377
3484
  return cleaned + eol;
3378
3485
  }
@@ -4844,6 +4951,33 @@ function parseTomlToObject(content) {
4844
4951
  * - `hooks.<Event>` MUST be an array of tables when present (Codex ≥0.124
4845
4952
  * rejects bare `[hooks.<Event>]` single-bracket maps).
4846
4953
  */
4954
+ /**
4955
+ * True when a bare `[agents]` table body contains ONLY known AgentsToml scalar
4956
+ * keys (CODEX_AGENTS_TOML_SCALAR_KEYS) — i.e. it is a valid AgentsToml struct
4957
+ * that Codex's `deny_unknown_fields` will accept, not the break-causing form
4958
+ * (#2760) that carries an unknown key. Comments and blank lines are ignored; an
4959
+ * empty body is trivially valid. Mirrors isLegacyGsdAgentsSection's line scan.
4960
+ */
4961
+ function codexBareAgentsHasOnlyKnownScalars(body) {
4962
+ const lineRecords = getTomlLineRecords(body);
4963
+ for (const record of lineRecords) {
4964
+ // Conservative reject of anything not positively a single known-scalar
4965
+ // assignment. A multiline-string value cannot be a valid AgentsToml scalar
4966
+ // (max_threads/max_depth/job_max_runtime_seconds are integers,
4967
+ // interrupt_message is a bool — none are strings), so codex would reject it
4968
+ // too; rejecting here is correct, not a false negative.
4969
+ if (record.startsInMultilineString) return false;
4970
+ if (record.tableHeader) return false;
4971
+ const trimmed = record.text.trim();
4972
+ if (!trimmed || trimmed.startsWith('#')) continue;
4973
+ if (!record.keySegments || record.keySegments.length !== 1 ||
4974
+ !CODEX_AGENTS_TOML_SCALAR_KEYS.has(record.keySegments[0])) {
4975
+ return false;
4976
+ }
4977
+ }
4978
+ return true;
4979
+ }
4980
+
4847
4981
  function validateCodexConfigSchema(content) {
4848
4982
  let parsed;
4849
4983
  try {
@@ -4872,10 +5006,21 @@ function validateCodexConfigSchema(content) {
4872
5006
  }
4873
5007
 
4874
5008
  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
- };
5009
+ // #2760 rejected ALL bare `[agents]` tables because a bare table holding a
5010
+ // non-AgentsToml key (`default = "x"`, a role name, etc.) triggers Codex's
5011
+ // "invalid type: ..., expected struct AgentsToml" and breaks every CLI
5012
+ // invocation. But a bare `[agents]` whose keys are all valid AgentsToml
5013
+ // scalars (max_depth/max_threads/...) IS a valid struct — that is exactly
5014
+ // GSD's managed `max_depth` dispatch-tuning block (ADR-1239 upgrade 2 /
5015
+ // #2088), and a user's own scalar tuning. Permit known-scalar-only; still
5016
+ // reject any bare `[agents]` carrying an unknown key.
5017
+ const body = content.slice(section.headerEnd, section.end);
5018
+ if (!codexBareAgentsHasOnlyKnownScalars(body)) {
5019
+ return {
5020
+ ok: false,
5021
+ 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)',
5022
+ };
5023
+ }
4879
5024
  }
4880
5025
 
4881
5026
  // hooks.state.* is Codex's persistent hook-trust namespace (added in
@@ -5151,7 +5296,13 @@ function mergeCodexConfig(configPath, gsdBlock) {
5151
5296
 
5152
5297
  const existing = fs.readFileSync(configPath, 'utf8');
5153
5298
  const eol = detectLineEnding(existing);
5154
- const normalizedGsdBlock = gsdBlock.replace(/\r?\n/g, eol);
5299
+ // #2088 review: the bare `[agents]` table is purged below (Case 2/3 via
5300
+ // stripLeakedGsdCodexSections) to keep a single managed `[agents]`. Preserve
5301
+ // the user's own AgentsToml scalar tuning (max_threads, job_max_runtime_seconds,
5302
+ // interrupt_message — everything except GSD-managed max_depth) by re-emitting
5303
+ // it inside the managed block, so install never silently drops it.
5304
+ const mergedGsdBlock = spliceCodexAgentsScalars(gsdBlock, extractCodexUserAgentsScalars(existing));
5305
+ const normalizedGsdBlock = mergedGsdBlock.replace(/\r?\n/g, eol);
5155
5306
  const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
5156
5307
 
5157
5308
  // Case 2: Has GSD marker — truncate and re-append
@@ -6105,62 +6256,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6105
6256
  // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
6106
6257
  // Imported from installEngine above.
6107
6258
 
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
6259
  // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
6120
6260
  // 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
- }
6261
+ //
6262
+ // copyFlattenedCommands (OpenCode/Kilo flattened command/ writer): moved to
6263
+ // src/install-engine.cts as installOpencodeFamilyCommands (ADR-1239 / #2087).
6264
+ // OpenCode/Kilo installs now route through installRuntimeArtifacts's
6265
+ // combinedFamilyInstall path (installOpencodeFamilyArtifacts) instead of the
6266
+ // bespoke inline block that used to call this function.
6164
6267
 
6165
6268
  function listCodexSkillNames(skillsDir, prefix = 'gsd-') {
6166
6269
  if (!fs.existsSync(skillsDir)) return [];
@@ -6462,7 +6565,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
6462
6565
  // copyWithPathReplacement is the emit path for gsd-core/workflows/*.md;
6463
6566
  // _applyRuntimeRewrites is NOT invoked here, so this is what makes the fix
6464
6567
  // live in real installs (it is a no-op for files without those lines).
6465
- if (runtime !== 'claude') {
6568
+ if (!_hostBehaviors(runtime).authorsCanonicalWorkflow) {
6466
6569
  content = _stampNonClaudeRuntimeDefaults(content, runtime);
6467
6570
  }
6468
6571
 
@@ -6664,16 +6767,20 @@ const GSD_UNINSTALL_HOOKS = [
6664
6767
  * @param {boolean} isGlobal - Whether to uninstall from global or local
6665
6768
  * @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', 'copilot')
6666
6769
  */
6667
- function uninstall(isGlobal, runtime = 'claude') {
6770
+ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
6668
6771
  const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
6669
6772
  const dirName = getDirName(runtime);
6670
6773
 
6671
6774
  // Get the target directory based on runtime and install type. Cline local
6672
6775
  // installs write to the project root (.clinerules/ lives at the root, not in
6673
6776
  // a .cline/ subdir), mirroring the install() path resolution (#787).
6777
+ // Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
6778
+ // project root (.clinerules/ lives at the root, not in a .cline/ subdir),
6779
+ // mirroring the install() path resolution (#787). Folded from a hardcoded
6780
+ // `runtime === 'cline'` branch into hostBehaviors.localTargetIsProjectRoot.
6674
6781
  const targetDir = isGlobal
6675
6782
  ? getGlobalConfigDir(runtime, explicitConfigDir)
6676
- : runtime === 'cline'
6783
+ : _hostBehaviors(runtime).localTargetIsProjectRoot
6677
6784
  ? process.cwd()
6678
6785
  : path.join(process.cwd(), dirName);
6679
6786
 
@@ -6722,11 +6829,34 @@ function uninstall(isGlobal, runtime = 'claude') {
6722
6829
 
6723
6830
  // 1. Remove GSD commands/skills (layout-driven)
6724
6831
  const scope = isGlobal ? 'global' : 'local';
6725
- uninstallRuntimeArtifacts(runtime, targetDir, scope);
6832
+ // ADR-1239 / #2086: drive uninstall through the public Host-Integration Interface.
6833
+ // Fail-open to the engine directly if the composed-registry adapter can't load.
6834
+ const _uninstallAdapter = _runtimeAdapter(runtime);
6835
+ if (_uninstallAdapter) {
6836
+ _uninstallAdapter.uninstall({ configDir: targetDir, scope });
6837
+ } else {
6838
+ uninstallRuntimeArtifacts(runtime, targetDir, scope);
6839
+ }
6726
6840
  removedCount++;
6727
6841
 
6842
+ // ADR-1239 split-home migration: the adapter/plan uninstall targets the new
6843
+ // `home` location (e.g. Codex → ~/.agents/skills). A user who installed
6844
+ // BEFORE the move and never reinstalled still has managed gsd-* skill dirs at
6845
+ // the old configDir-rooted location (~/.codex/skills) — remove those too so
6846
+ // uninstall leaves nothing behind. User-owned content is preserved.
6847
+ {
6848
+ const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
6849
+ if (_movedOldSkillsDir) {
6850
+ const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-');
6851
+ if (migrated > 0) {
6852
+ removedCount++;
6853
+ console.log(` ${green}✓${reset} Removed ${migrated} legacy skill dir(s) from ${_movedOldSkillsDir}`);
6854
+ }
6855
+ }
6856
+ }
6857
+
6728
6858
  // 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json
6729
- if (isCodex) {
6859
+ if (_hostBehaviors(runtime).tomlConfigInstall) {
6730
6860
  const codexAgentsDir = path.join(targetDir, 'agents');
6731
6861
  if (fs.existsSync(codexAgentsDir)) {
6732
6862
  const tomlFiles = fs.readdirSync(codexAgentsDir);
@@ -6765,8 +6895,10 @@ function uninstall(isGlobal, runtime = 'claude') {
6765
6895
  console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
6766
6896
  }
6767
6897
 
6768
- // #772: remove new Codex hook event registrations added by this enhancement.
6769
- for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
6898
+ // #772/#2088: remove every managed Codex extended hook-event registration.
6899
+ // Shares CODEX_EXTENDED_HOOK_EVENTS with the install loop — removal set ==
6900
+ // registration set, so no managed event is ever orphaned.
6901
+ for (const eventName of CODEX_EXTENDED_HOOK_EVENTS) {
6770
6902
  const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
6771
6903
  if (eventCleanup.changed) {
6772
6904
  removedCount++;
@@ -6813,7 +6945,9 @@ function uninstall(isGlobal, runtime = 'claude') {
6813
6945
  // 1b-cline. Non-layout Cline side-effects (issue #787): remove the
6814
6946
  // directory-form rules + PreToolUse hook, and strip the GSD block from the
6815
6947
  // global cross-tool ~/.agents/AGENTS.md target.
6816
- if (runtime === 'cline') {
6948
+ // Descriptor-driven (ADR-1239 / #2090): folded from `runtime === 'cline'`
6949
+ // into hostBehaviors.clineRulesSurface.
6950
+ if (_hostBehaviors(runtime).clineRulesSurface) {
6817
6951
  const clinerulesDir = path.join(targetDir, '.clinerules');
6818
6952
  for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
6819
6953
  const p = path.join(clinerulesDir, rel);
@@ -6859,17 +6993,20 @@ function uninstall(isGlobal, runtime = 'claude') {
6859
6993
  }
6860
6994
  }
6861
6995
 
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) {
6996
+ // 1b-cursor. Descriptor-driven hook-bus cleanup (ADR-1239 / #2089): remove
6997
+ // GSD-managed hook entries from hooks.json and clean up the managed hook
6998
+ // scripts. Gated by the hostBehaviors.hooksJsonSurface descriptor axis, not a
6999
+ // hardcoded `isCursor` branch.
7000
+ if (_hostBehaviors(runtime).hooksJsonSurface) {
6865
7001
  const hooksJsonCleanup = removeCursorHooksJson(targetDir);
6866
7002
  if (hooksJsonCleanup.changed) {
6867
7003
  removedCount++;
6868
7004
  console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`);
6869
7005
  }
6870
- // Remove the managed hook scripts (session-start + post-tool).
7006
+ // Remove all GSD-managed hook scripts (sessionStart, postToolUse, preToolUse,
7007
+ // stop, subagentStart, subagentStop — AC4a, #2089).
6871
7008
  const hooksDir = path.join(targetDir, 'hooks');
6872
- for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) {
7009
+ for (const script of GSD_CURSOR_HOOK_SCRIPTS) {
6873
7010
  const p = path.join(hooksDir, script);
6874
7011
  try {
6875
7012
  if (fs.existsSync(p)) {
@@ -6888,7 +7025,7 @@ function uninstall(isGlobal, runtime = 'claude') {
6888
7025
 
6889
7026
  // 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout,
6890
7027
  // #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs.
6891
- if (!isGlobal && runtime === 'claude') {
7028
+ if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') {
6892
7029
  const commandsDir = path.join(targetDir, 'commands');
6893
7030
  // Remove flat gsd-*.md files (current layout after #1367 fix)
6894
7031
  if (fs.existsSync(commandsDir)) {
@@ -6930,7 +7067,7 @@ function uninstall(isGlobal, runtime = 'claude') {
6930
7067
  // removes the directory; we must preserve/restore user artifacts before that path.
6931
7068
  // This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory
6932
7069
  // was already removed and skip if so (idempotent).
6933
- if (isQwen || isHermes) {
7070
+ if (isQwen || _hostBehaviors(runtime).legacyCommandsGsdCleanup === true) {
6934
7071
  // dev-preferences may have survived in skills/ as SKILL.md — nothing to do for
6935
7072
  // that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed),
6936
7073
  // attempt migration. In practice _runLegacyUninstallCleanup removes it first,
@@ -7043,9 +7180,10 @@ function uninstall(isGlobal, runtime = 'claude') {
7043
7180
  // 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
7044
7181
  // plugin file is removed; the plugins/ dir is pruned only if it becomes
7045
7182
  // 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');
7183
+ const _np = _hostBehaviors(runtime).nativePlugin;
7184
+ if (_np) {
7185
+ const pluginsDir = path.join(targetDir, _np.dir);
7186
+ const pluginPath = path.join(pluginsDir, _np.file);
7049
7187
  if (fs.existsSync(pluginPath)) {
7050
7188
  try {
7051
7189
  fs.unlinkSync(pluginPath);
@@ -7191,7 +7329,7 @@ function uninstall(isGlobal, runtime = 'claude') {
7191
7329
  // to preserve any user-added allow/deny entries.
7192
7330
  // Uses a local flag to avoid the shared `settingsModified` producing a false
7193
7331
  // "Removed GSD permissions" message when only hooks/statusline changed.
7194
- if (runtime === 'claude' && settings.permissions) {
7332
+ if (_hostBehaviors(runtime).permissionsSchema === 'claude' && settings.permissions) {
7195
7333
  let permissionsModified = false;
7196
7334
  if (Array.isArray(settings.permissions.allow)) {
7197
7335
  const before = settings.permissions.allow.length;
@@ -7224,7 +7362,7 @@ function uninstall(isGlobal, runtime = 'claude') {
7224
7362
  }
7225
7363
 
7226
7364
  // 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
7227
- if (isOpencode) {
7365
+ if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
7228
7366
  const configPath = resolveOpencodeConfigPath(targetDir);
7229
7367
  if (fs.existsSync(configPath)) {
7230
7368
  try {
@@ -7666,19 +7804,24 @@ function resolveInstallRelativePath(baseDir, relPath) {
7666
7804
  /**
7667
7805
  * Write file manifest after installation for future modification detection
7668
7806
  */
7669
- function writeManifest(configDir, runtime = 'claude', options = {}) {
7807
+ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
7670
7808
  const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
7671
7809
  const gsdDir = path.join(configDir, 'gsd-core');
7672
7810
  // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
7673
7811
  // Claude local uses flatCommandsDir instead for manifest recording.
7674
7812
  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).
7813
+ const opencodeCommandDir = path.join(configDir, _hostBehaviors(runtime).flatCommandDir || 'command');
7814
+ // Hermes nests GSD skills under skills/gsd/ as a single category (#2841) —
7815
+ // already encoded in its layout descriptor's destSubpath ('skills/gsd').
7677
7816
  // 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/';
7817
+ // ADR-1239 upgrade 3 (#2088): honor a skills-kind `home` override (e.g. Codex
7818
+ // skills -> $HOME/.agents/skills instead of configDir/skills) via the same
7819
+ // descriptor-driven helper used by the snapshot/rollback/verification paths,
7820
+ // so the manifest records what's actually on disk. _resolveSkillsRootDir already
7821
+ // resolves destSubpath (which includes hermes's 'skills/gsd' nesting) — do not
7822
+ // re-append 'gsd' or the hermes dir gets double-nested to skills/gsd/gsd.
7823
+ const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, options.scope === 'local' ? 'local' : 'global');
7824
+ const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/';
7682
7825
  const agentsDir = path.join(configDir, 'agents');
7683
7826
  const manifest = {
7684
7827
  version: pkg.version,
@@ -7704,21 +7847,21 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7704
7847
  // Claude local (#1367): flat gsd-*.md files at commands/ level.
7705
7848
  // Only claude local writes gsd-*.md here; global installs don't emit commands,
7706
7849
  // so this branch is a no-op for global (no matching files to find).
7707
- if (runtime === 'claude' && fs.existsSync(flatCommandsDir)) {
7850
+ if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && fs.existsSync(flatCommandsDir)) {
7708
7851
  for (const file of fs.readdirSync(flatCommandsDir)) {
7709
7852
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
7710
7853
  manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file));
7711
7854
  }
7712
7855
  }
7713
7856
  }
7714
- if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) {
7857
+ if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
7715
7858
  for (const file of fs.readdirSync(opencodeCommandDir)) {
7716
7859
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
7717
7860
  manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
7718
7861
  }
7719
7862
  }
7720
7863
  }
7721
- if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || !isOpencode) && fs.existsSync(codexSkillsDir)) {
7864
+ if (!_hostBehaviors(runtime).skipCodexSkillsManifest && fs.existsSync(codexSkillsDir)) {
7722
7865
  // All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
7723
7866
  const skillListPrefix = 'gsd-';
7724
7867
  for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
@@ -7728,8 +7871,8 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7728
7871
  manifest.files[`${codexSkillsManifestPrefix}${skillName}/${rel}`] = hash;
7729
7872
  }
7730
7873
  }
7731
- // For Hermes, also hash the category DESCRIPTION.md so reinstall detects drift.
7732
- if (isHermes) {
7874
+ // Descriptor-driven (#2090): hash the category DESCRIPTION.md so reinstall detects drift.
7875
+ if (_hostBehaviors(runtime).trackCategoryDescription) {
7733
7876
  const descPath = path.join(codexSkillsDir, 'DESCRIPTION.md');
7734
7877
  if (fs.existsSync(descPath)) {
7735
7878
  manifest.files['skills/gsd/DESCRIPTION.md'] = fileHash(descPath);
@@ -7755,7 +7898,9 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7755
7898
  // Track Cline directory-form artifacts in the manifest (issue #787): the
7756
7899
  // rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
7757
7900
  // marker block, not the per-configDir manifest, since it lives outside it.)
7758
- if (isCline) {
7901
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
7902
+ // hostBehaviors.clineRulesSurface.
7903
+ if (_hostBehaviors(runtime).clineRulesSurface) {
7759
7904
  for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
7760
7905
  const dest = path.join(configDir, rel);
7761
7906
  if (fs.existsSync(dest)) {
@@ -7766,7 +7911,9 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7766
7911
 
7767
7912
  // Track hook files so saveLocalPatches() can detect user modifications
7768
7913
  // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
7769
- if (!isCodex && !isCopilot && !isCline && !isKimi) {
7914
+ // Descriptor-driven (ADR-1239 / #2089+#2090): cline's exclusion is via
7915
+ // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCline).
7916
+ if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi) {
7770
7917
  const hooksDir = path.join(configDir, 'hooks');
7771
7918
  if (fs.existsSync(hooksDir)) {
7772
7919
  // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
@@ -7830,10 +7977,11 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7830
7977
 
7831
7978
  // Track the OpenCode native plugin adapter (#1914) so update/drift detection
7832
7979
  // and uninstall can account for it.
7833
- if (isOpencode) {
7834
- const pluginInstallPath = path.join(configDir, 'plugins', 'gsd-core.js');
7980
+ const _npM = _hostBehaviors(runtime).nativePlugin;
7981
+ if (_npM) {
7982
+ const pluginInstallPath = path.join(configDir, _npM.dir, _npM.file);
7835
7983
  if (fs.existsSync(pluginInstallPath)) {
7836
- manifest.files['plugins/gsd-core.js'] = fileHash(pluginInstallPath);
7984
+ manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath);
7837
7985
  }
7838
7986
  }
7839
7987
 
@@ -8117,7 +8265,7 @@ function saveLocalPatches(configDir, pristineCtx) {
8117
8265
  /**
8118
8266
  * After install, report backed-up patches for user to reapply.
8119
8267
  */
8120
- function reportLocalPatches(configDir, runtime = 'claude') {
8268
+ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
8121
8269
  const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
8122
8270
  const metaPath = path.join(patchesDir, 'backup-meta.json');
8123
8271
  if (!fs.existsSync(metaPath)) return [];
@@ -8126,15 +8274,11 @@ function reportLocalPatches(configDir, runtime = 'claude') {
8126
8274
  try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
8127
8275
 
8128
8276
  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';
8277
+ const reapplyCommand = _hostBehaviors(runtime).reapplyCommand
8278
+ ? _hostBehaviors(runtime).reapplyCommand
8279
+ : runtime === 'kimi'
8280
+ ? '/skill:gsd-update --reapply'
8281
+ : '/gsd-update --reapply';
8138
8282
  console.log('');
8139
8283
  console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
8140
8284
  for (const f of meta.files) {
@@ -8160,8 +8304,8 @@ function reportInstallerMigrationResult(result) {
8160
8304
  }
8161
8305
  }
8162
8306
 
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);
8307
+ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
8308
+ const { isOpencode, isKilo, isZcode, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
8165
8309
  const plan = resolveInstallPlan(runtime);
8166
8310
  const dirName = getDirName(runtime);
8167
8311
  const src = path.join(__dirname, '..');
@@ -8214,15 +8358,17 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8214
8358
  };
8215
8359
 
8216
8360
  // 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.
8361
+ // Descriptor-driven (ADR-1239 / #2090): cline local installs write to the
8362
+ // project root (like Claude Code) — .clinerules lives at the root, not inside
8363
+ // a .cline/ subdirectory. Folded from `isCline` into
8364
+ // hostBehaviors.localTargetIsProjectRoot.
8219
8365
  // #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
8220
8366
  // directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
8221
8367
  // but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
8222
8368
  // auto-removed on reinstall (dual-read fallback per issue #791 spec).
8223
8369
  const targetDir = isGlobal
8224
8370
  ? getGlobalConfigDir(runtime, explicitConfigDir)
8225
- : isCline
8371
+ : _hostBehaviors(runtime).localTargetIsProjectRoot
8226
8372
  ? process.cwd()
8227
8373
  : path.join(process.cwd(), dirName);
8228
8374
 
@@ -8301,7 +8447,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8301
8447
  const isWindowsHost = process.platform === 'win32';
8302
8448
  const pathPrefix = computePathPrefix({
8303
8449
  isGlobal,
8304
- isOpencode,
8450
+ isOpencode: _hostBehaviors(runtime).skipHomePrefixSubstitution === true,
8305
8451
  isWindowsHost,
8306
8452
  resolvedTarget,
8307
8453
  homeDir,
@@ -8367,8 +8513,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8367
8513
  // Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
8368
8514
  const codexPreInstallAgentContents = new Map();
8369
8515
  let codexPreInstallVersionBytes = null;
8370
- if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
8371
- const _preSkillsDir = path.join(targetDir, 'skills');
8516
+ if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
8517
+ const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
8372
8518
  if (fs.existsSync(_preSkillsDir)) {
8373
8519
  for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
8374
8520
  if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -8421,10 +8567,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8421
8567
  // atomic-write temp files. It is safe to call before any writes have happened.
8422
8568
  // The full restoreCodexSnapshot() (defined inside the config block) additionally
8423
8569
  // handles config.toml, which is not yet touched at this point in the pipeline.
8424
- const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => {
8570
+ const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
8425
8571
  rollbackInstallerMigrations();
8426
8572
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
8427
- const _earlySkillsDir = path.join(targetDir, 'skills');
8573
+ const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
8428
8574
  for (const skillName of codexPreInstallSkillNames) {
8429
8575
  const skillDirPath = path.join(_earlySkillsDir, skillName);
8430
8576
  const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -8587,7 +8733,6 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8587
8733
  // Hermes: writeHermesCategoryDescription (not a layout kind)
8588
8734
  // Cline global: skills emitted via layout; .clinerules still written below (#782)
8589
8735
  // Cline local: no skills (only .clinerules) — falls through to cline-rules surface
8590
- // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
8591
8736
  // Claude local: copyWithPathReplacement + stale-skills cleanup
8592
8737
 
8593
8738
  // Layout-driven path for all skills-based runtimes (full and minimal modes).
@@ -8599,13 +8744,15 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8599
8744
  // (it declares any skills/commands/agents/kimi-agents kind for this scope).
8600
8745
  // This replaces the prior hardcoded `isCodex || isCopilot || ...` roster so a
8601
8746
  // 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
8747
+ // branch — the add-a-host tax ADR-1239 Phase B retires. OpenCode/Kilo now
8748
+ // route through this SAME path too: their hostBehaviors.combinedFamilyInstall
8749
+ // flag makes installRuntimeArtifacts (in src/install-engine.cts) delegate to
8750
+ // installOpencodeFamilyArtifacts for the combined commands+skills+native-plugin
8751
+ // install (ADR-1239 / #2087), replacing the bespoke inline block this comment
8752
+ // used to describe. Claude-local remains the one special-cased path
8605
8753
  // (copyWithPathReplacement + stale-skills cleanup).
8606
8754
  const _isSkillsRuntime = (() => {
8607
- if (isOpencode || isKilo) return false; // specialized combined path
8608
- if (runtime === 'claude' && !isGlobal) return false; // claude-local legacy path
8755
+ if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
8609
8756
  const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
8610
8757
  const layout = cap && cap.runtime && cap.runtime.artifactLayout;
8611
8758
  if (!layout) return false;
@@ -8616,7 +8763,28 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8616
8763
  if (_isSkillsRuntime) {
8617
8764
  // Layout-driven install for skills-based runtimes (full and minimal modes)
8618
8765
  const scope = isGlobal ? 'global' : 'local';
8619
- installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
8766
+ // ADR-1239 upgrade 3 / #2088: a kind may declare an alternate install `home`
8767
+ // (e.g. Codex skills -> $HOME/.agents/skills) instead of the runtime's normal
8768
+ // configDir. Resolve the ACTUAL on-disk skills root here, descriptor-driven
8769
+ // (no isCodex check), so downstream sidecar-cleanup and post-install
8770
+ // verification look in the right place regardless of which runtime declares
8771
+ // an alternate home for its skills kind.
8772
+ const _skillsRootDir = _resolveSkillsRootDir(runtime, targetDir, scope);
8773
+ // ADR-1239 / #2086: drive install through the public Host-Integration Interface
8774
+ // (imperative adapter). The adapter delegates to the SAME installRuntimeArtifacts
8775
+ // engine call -> byte-identical output (gated by golden-install-parity). Fail-open
8776
+ // to the engine directly if the composed-registry adapter can't load.
8777
+ const _adapter = _runtimeAdapter(runtime);
8778
+ if (_adapter) {
8779
+ _adapter.install({
8780
+ configDir: targetDir,
8781
+ scope,
8782
+ resolvedProfile: _resolvedProfile,
8783
+ resolveAttribution: getCommitAttribution,
8784
+ });
8785
+ } else {
8786
+ installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
8787
+ }
8620
8788
 
8621
8789
  // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
8622
8790
  // gsd-* skill dirs. Prior installs wrote these files so Codex would show a
@@ -8624,8 +8792,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8624
8792
  // index BOTH SKILL.md and the sidecar, causing each GSD skill to appear twice
8625
8793
  // in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
8626
8794
  // sufficient for Codex discovery. User-owned dirs are never touched.
8627
- if (isCodex) {
8628
- cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills'));
8795
+ if (_hostBehaviors(runtime).cleanupSkillSidecars) {
8796
+ cleanupCodexSkillMetadataSidecars(_skillsRootDir);
8797
+ }
8798
+
8799
+ // ADR-1239 split-home migration: when a runtime's skills kind moved to an
8800
+ // alternate `home` (e.g. Codex → ~/.agents/skills), pre-move installs left
8801
+ // managed gsd-* skill dirs at the old configDir-rooted location
8802
+ // (~/.codex/skills). Reinstalling here writes the new location but would
8803
+ // otherwise orphan the old one — clean up the stale gsd-* dirs.
8804
+ {
8805
+ const _movedOldSkillsDir = _resolveMovedSkillsOldDir(runtime, targetDir, scope);
8806
+ if (_movedOldSkillsDir) {
8807
+ const migrated = cleanupMovedSkillsOldLocation(_movedOldSkillsDir, 'gsd-');
8808
+ if (migrated > 0) {
8809
+ console.log(` ${green}✓${reset} Migrated ${migrated} skill dir(s) off the legacy ${_movedOldSkillsDir} location`);
8810
+ }
8811
+ }
8629
8812
  }
8630
8813
 
8631
8814
  // #1629 Finding B: Windsurf local only — remove legacy .devin/skills/gsd-*
@@ -8639,13 +8822,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8639
8822
  }
8640
8823
  }
8641
8824
 
8642
- // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
8643
- if (isHermes) {
8825
+ // Descriptor-driven (#2090): write DESCRIPTION.md for the gsd/ category after layout install
8826
+ if (_hostBehaviors(runtime).writeCategoryDescription) {
8644
8827
  writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
8645
8828
  }
8646
8829
 
8647
8830
  // Verify installed artifacts and report
8648
- if (isHermes) {
8831
+ if (_hostBehaviors(runtime).reportSkillsCount) {
8649
8832
  const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
8650
8833
  if (fs.existsSync(hermesSkillsDir)) {
8651
8834
  // Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
@@ -8697,7 +8880,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8697
8880
  }
8698
8881
  }
8699
8882
  } else {
8700
- const skillsDir = path.join(targetDir, 'skills');
8883
+ const skillsDir = _skillsRootDir;
8701
8884
  if (fs.existsSync(skillsDir)) {
8702
8885
  const count = fs.readdirSync(skillsDir, { withFileTypes: true })
8703
8886
  .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
@@ -8725,8 +8908,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8725
8908
  }
8726
8909
  }
8727
8910
 
8728
- // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands)
8729
- if (isCursor) {
8911
+ // Descriptor-driven commands/ output report (#785 — Cursor 1.6 slash commands).
8912
+ // Gated by hostBehaviors.reportCommandsDir, not a hardcoded `isCursor` branch (#2089).
8913
+ if (_hostBehaviors(runtime).reportCommandsDir) {
8730
8914
  const commandsDir = path.join(targetDir, 'commands');
8731
8915
  if (fs.existsSync(commandsDir)) {
8732
8916
  const cmdCount = fs.readdirSync(commandsDir)
@@ -8757,68 +8941,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8757
8941
  }
8758
8942
  }
8759
8943
  }
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) {
8944
+ } else if (_hostBehaviors(runtime).localCommandsViaRules) {
8819
8945
  // Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
8820
8946
  // No skills/commands directory needed for local installs.
8821
8947
  // Global installs are handled above by _isSkillsRuntime (#782).
8948
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
8949
+ // hostBehaviors.localCommandsViaRules.
8822
8950
  console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
8823
8951
  } else {
8824
8952
  // Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
@@ -8916,11 +9044,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8916
9044
  // other runtime/scope deploys commands/gsd, so its walk-up already resolves
8917
9045
  // and needs no marker. Guarded on source presence so a half-published
8918
9046
  // package never writes a dangling marker.
8919
- if (runtime === 'claude' && isGlobal) {
9047
+ if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
8920
9048
  const gsdSourceCommands = path.join(src, 'commands', 'gsd');
8921
9049
  if (fs.existsSync(gsdSourceCommands)) {
8922
9050
  try {
8923
- fs.writeFileSync(path.join(targetDir, '.gsd-source'), gsdSourceCommands + '\n', 'utf8');
9051
+ // ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
9052
+ // must resolve under targetDir (parity with the other descriptor-driven writes).
9053
+ const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
9054
+ fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8');
8924
9055
  } catch (err) {
8925
9056
  // Non-fatal: install proceeds. But on the Claude-global layout walk-up
8926
9057
  // also fails (no commands/gsd source tree), so a silent write failure
@@ -9007,7 +9138,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9007
9138
  for (const file of fs.readdirSync(agentsDest)) {
9008
9139
  if (
9009
9140
  file.startsWith('gsd-') &&
9010
- (file.endsWith('.md') || (isCodex && file.endsWith('.toml')))
9141
+ (file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml')))
9011
9142
  ) {
9012
9143
  fs.unlinkSync(path.join(agentsDest, file));
9013
9144
  }
@@ -9025,7 +9156,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9025
9156
  // Without stripping them here, a full → minimal reinstall would leave the
9026
9157
  // runtime advertising the old full agent surface even though the agent
9027
9158
  // files are gone. Reuse the same helper that powers `--uninstall`.
9028
- if (isCodex) {
9159
+ if (_hostBehaviors(runtime).tomlConfigInstall) {
9029
9160
  const codexConfigPath = path.join(targetDir, 'config.toml');
9030
9161
  if (fs.existsSync(codexConfigPath)) {
9031
9162
  const existing = fs.readFileSync(codexConfigPath, 'utf8');
@@ -9060,7 +9191,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9060
9191
  }
9061
9192
  content = processAttribution(content, getCommitAttribution(runtime));
9062
9193
  // Convert frontmatter for runtime compatibility (agents need different handling)
9063
- if (isOpencode) {
9194
+ if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
9064
9195
  // Resolve per-agent model for OpenCode agents.
9065
9196
  // Precedence: model_overrides[agent] > model_profile_overrides.opencode.<tier> > omit.
9066
9197
  // model_overrides (#2256): explicit per-agent override, highest precedence.
@@ -9079,16 +9210,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9079
9210
  }
9080
9211
  }
9081
9212
  content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
9082
- } else if (isKilo) {
9213
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
9083
9214
  content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
9084
- } else if (isCodex) {
9215
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
9085
9216
  content = convertClaudeAgentToCodexAgent(content);
9086
9217
  } else if (isCopilot) {
9087
9218
  content = convertClaudeAgentToCopilotAgent(content, isGlobal);
9088
9219
  } else if (isAntigravity) {
9089
9220
  content = convertClaudeAgentToAntigravityAgent(content, isGlobal);
9090
- } else if (isCursor) {
9091
- content = convertClaudeAgentToCursorAgent(content);
9092
9221
  } else if (isWindsurf) {
9093
9222
  content = convertClaudeAgentToWindsurfAgent(content);
9094
9223
  } else if (isAugment) {
@@ -9097,13 +9226,15 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9097
9226
  content = convertClaudeAgentToTraeAgent(content);
9098
9227
  } else if (isCodebuddy) {
9099
9228
  content = convertClaudeAgentToCodebuddyAgent(content);
9100
- } else if (isCline) {
9229
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
9230
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
9231
+ // hostBehaviors.frontmatterDialect === 'cline'.
9101
9232
  content = convertClaudeAgentToClineAgent(content);
9102
9233
  } else if (isQwen) {
9103
9234
  content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9104
9235
  content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9105
9236
  content = content.replace(/\.claude\//g, '.qwen/');
9106
- } else if (isHermes) {
9237
+ } else if (_hostBehaviors(runtime).brandingRewrites) {
9107
9238
  content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9108
9239
  content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
9109
9240
  content = content.replace(/\.claude\//g, '.hermes/');
@@ -9115,11 +9246,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9115
9246
  // Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536).
9116
9247
  // Injection is per-runtime at install time because the canonical source
9117
9248
  // agents/*.md must stay runtime-safe (no effort: key in source).
9118
- if (runtime === 'claude') {
9249
+ if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort')) {
9119
9250
  const _effortCfg = readGsdEffectiveEffortConfig(targetDir);
9120
9251
  const _agentName = entry.name.replace(/\.md$/, '');
9121
9252
  const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
9122
- const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
9253
+ const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, _universalEffort).value;
9123
9254
  content = injectEffortFrontmatter(content, _renderedEffort);
9124
9255
  const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
9125
9256
  if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
@@ -9163,7 +9294,17 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9163
9294
  failures.push('VERSION');
9164
9295
  }
9165
9296
 
9166
- if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) {
9297
+ // #1821: Kilo and ZCode declare hooksSurface:'none' AND have no plugin surface,
9298
+ // so the staged hook scripts are dead weight for them — exclude both here.
9299
+ // OpenCode also declares hooksSurface:'none' but is deliberately NOT excluded:
9300
+ // its native plugin adapter (#1914, installed above under plugins/gsd-core.js)
9301
+ // spawns the staged hooks/*.js scripts via OpenCode's event bus and needs both
9302
+ // them and the CommonJS package.json marker written below.
9303
+ // #2089: Cursor's exclusion is now descriptor-driven via
9304
+ // hostBehaviors.skipSharedHooksInstall (was hardcoded !isCursor).
9305
+ // #2090: Cline's exclusion is likewise descriptor-driven (cline declares
9306
+ // skipSharedHooksInstall:true) — the redundant `&& !isCline` was removed.
9307
+ if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode) {
9167
9308
  // Write package.json to force CommonJS mode for GSD scripts
9168
9309
  // Prevents "require is not defined" errors when project has "type": "module"
9169
9310
  // Node.js walks up looking for package.json - this stops inheritance from project
@@ -9192,7 +9333,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9192
9333
  content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9193
9334
  content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9194
9335
  }
9195
- if (isHermes) {
9336
+ if (_hostBehaviors(runtime).brandingRewrites) {
9196
9337
  content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9197
9338
  content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
9198
9339
  }
@@ -9254,12 +9395,16 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9254
9395
 
9255
9396
  // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
9256
9397
  // 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.
9398
+ // (Cursor uses standalone .js hook scripts registered via hooks.json — gated
9399
+ // descriptor-driven via hostBehaviors.skipSharedHooksInstall, #2089; Cline likewise
9400
+ // #2090; Codex uses hooks.json directly; the others skip hooks entirely); Kilo and
9401
+ // ZCode also skip hooks entirely (hooksSurface:'none' with no plugin surface — #1821).
9402
+ // OpenCode is NOT excluded: its #1914 plugin adapter spawns the staged hooks and
9403
+ // requires hooks/lib/ helpers. None of the excluded runtimes must receive the
9404
+ // hooks/lib/ helpers — otherwise the Codex comment downstream ("we deliberately do
9405
+ // *not* copy hooks/lib/ for Codex") is contradicted in practice.
9261
9406
  const hooksLibSrc = path.join(src, 'hooks', 'lib');
9262
- if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && fs.existsSync(hooksLibSrc)) {
9407
+ if (!isCodex && !isCopilot && _hostBehaviors(runtime).skipSharedHooksInstall !== true && !isWindsurf && !isTrae && !isKimi && !isKilo && !isZcode && fs.existsSync(hooksLibSrc)) {
9263
9408
  const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
9264
9409
  fs.mkdirSync(hooksLibDest, { recursive: true });
9265
9410
  copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
@@ -9386,14 +9531,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9386
9531
  }
9387
9532
 
9388
9533
  // Write file manifest for future modification detection
9389
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
9534
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9390
9535
  console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
9391
9536
 
9392
9537
  // Report any backed-up local patches
9393
9538
  reportLocalPatches(targetDir, runtime);
9394
9539
 
9395
9540
  // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
9396
- if (runtime !== 'claude') {
9541
+ if (!_hostBehaviors(runtime).ownsClaudePaths) {
9397
9542
  const leakedPaths = [];
9398
9543
  // Only scan files that were written by this install (manifest-tracked).
9399
9544
  // Scanning the entire targetDir can match user-authored content that
@@ -9529,7 +9674,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9529
9674
  // (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
9530
9675
  // are restored even when they are absent from disk at rollback time (#3245 CR).
9531
9676
  // • Dirs that did not pre-exist: remove entirely.
9532
- const _rollbackSkillsDir = path.join(targetDir, 'skills');
9677
+ const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
9533
9678
  // Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
9534
9679
  for (const skillName of codexPreInstallSkillNames) {
9535
9680
  const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -9640,7 +9785,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9640
9785
  // Re-write the manifest now that .toml agent files exist on disk.
9641
9786
  // The initial writeManifest call (before Codex config generation) could
9642
9787
  // not include agents/gsd-*.toml because those files did not yet exist.
9643
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
9788
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9644
9789
  } else {
9645
9790
  console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
9646
9791
  }
@@ -9780,24 +9925,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9780
9925
  }
9781
9926
  }
9782
9927
 
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.
9928
+ // ── Codex extended hook events (#772, #2088) ─────────────────────────
9929
+ // Codex CLI stabilised a full hook-event set in rust-v0.137.0. GSD
9930
+ // registers CODEX_EXTENDED_HOOK_EVENTS (#2088 adds the 6 documented
9931
+ // events beyond the original #772 three) — all routed through
9932
+ // gsd-context-monitor.js so context-headroom warnings surface at each
9933
+ // lifecycle point: SubagentStart/SubagentStop (subagent open/close),
9934
+ // Stop (final-response), PreToolUse/PostToolUse (tool boundaries),
9935
+ // PermissionRequest (approval prompts), Pre/PostCompact (context
9936
+ // compaction), and UserPromptSubmit (per-turn context injection). The
9937
+ // context-monitor script decides per-payload what to do; unregistered
9938
+ // events simply never fire.
9795
9939
  //
9796
9940
  // Guard: only register when the context-monitor file exists and the node
9797
9941
  // runner is available — same guards as the SessionStart path above.
9798
9942
  const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
9799
9943
  if (codexNodeRunner && fs.existsSync(contextMonitorFile)) {
9800
- for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) {
9944
+ for (const codexEvent of CODEX_EXTENDED_HOOK_EVENTS) {
9801
9945
  const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, {
9802
9946
  absoluteRunner: codexNodeRunner,
9803
9947
  platform: process.platform,
@@ -9809,7 +9953,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9809
9953
  }
9810
9954
  }
9811
9955
  } else if (!codexNodeRunner) {
9812
- console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`);
9956
+ console.warn(` ${yellow}⚠${reset} Skipped Codex extended hook-event registration — Node runner unavailable.`);
9813
9957
  }
9814
9958
  // ── end Codex extended hook events ────────────────────────────────────
9815
9959
  }
@@ -9874,16 +10018,20 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9874
10018
  }
9875
10019
 
9876
10020
  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, {});
10021
+ // ADR-1239 / #2089: Cursor hooks.json driven by the descriptor-managed hook-bus
10022
+ // adapter. Registers all 6 managed events (sessionStart, postToolUse, preToolUse,
10023
+ // stop, subagentStart, subagentStop) via runtime-hooks-surface.cts, which reads
10024
+ // the event list from the descriptor-driven adapter module.
10025
+ const cursorHookResult = writeCursorHooksJson(targetDir, src, {
10026
+ managedHookEvents: _hostBehaviors(runtime).managedHookEvents,
10027
+ });
9880
10028
  if (cursorHookResult.changed) {
9881
- console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`);
10029
+ console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop)`);
9882
10030
  } else {
9883
10031
  console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
9884
10032
  }
9885
10033
  // Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
9886
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
10034
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9887
10035
  persistActiveProfileMarker();
9888
10036
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9889
10037
  }
@@ -9901,7 +10049,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9901
10049
  writeClineArtifacts(targetDir, isGlobal);
9902
10050
  // Re-run the manifest pass: these artifacts are written *after* the earlier
9903
10051
  // writeManifest() call, so a second pass is needed to hash-track them.
9904
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
10052
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9905
10053
  persistActiveProfileMarker();
9906
10054
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9907
10055
  }
@@ -9916,9 +10064,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9916
10064
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
9917
10065
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
9918
10066
  // 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);
10067
+ const _scopedSettings = _hostBehaviors(runtime).settingsFileByScope || null;
10068
+ const isLocalClaude = (!isGlobal && !!(_scopedSettings && _scopedSettings.local));
10069
+ const settingsFileName = isLocalClaude
10070
+ ? _scopedSettings.local
10071
+ : ((_scopedSettings && _scopedSettings.global) || 'settings.json');
10072
+ // ADR-1239 Phase B write-confinement: the descriptor-sourced settings filename
10073
+ // must resolve under targetDir (this path also drives a recursive mkdirSync).
10074
+ const settingsPath = assertDestWithinConfigHome(targetDir, settingsFileName);
9922
10075
 
9923
10076
  // #338 migration: if a prior local Claude install wrote GSD-shaped entries to settings.json,
9924
10077
  // relocate them to settings.local.json and clear them from the shared file in the same run.
@@ -10101,7 +10254,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10101
10254
  // installAllRuntimes can register it at finalize time when the user opts
10102
10255
  // in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
10103
10256
  // / localCmd resolution logic is shared with the other JS hooks.
10104
- const updateBannerCommand = isOpencode || isKilo
10257
+ const updateBannerCommand = _hostBehaviors(runtime).skipUpdateBannerCommand
10105
10258
  ? null
10106
10259
  : (isGlobal
10107
10260
  ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
@@ -10184,11 +10337,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10184
10337
  /**
10185
10338
  * Apply statusline config, then print completion message
10186
10339
  */
10187
- function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = 'claude', isGlobal = true, configDir = null, bannerOpts = {}) {
10340
+ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallStatusline, runtime = DEFAULT_RUNTIME, isGlobal = true, configDir = null, bannerOpts = {}) {
10188
10341
  const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
10189
10342
  const plan = resolveInstallPlan(runtime);
10190
10343
 
10191
- if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
10344
+ if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
10192
10345
  if (!isGlobal && !forceStatusline) {
10193
10346
  // Local installs skip statusLine by default: repo settings.json takes precedence over
10194
10347
  // profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -10214,7 +10367,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10214
10367
  // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
10215
10368
  // cline either lack the surface or use a different config schema.
10216
10369
  const { shouldInstallBanner, bannerCommand } = bannerOpts;
10217
- if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
10370
+ if (shouldInstallBanner && settings && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
10218
10371
  if (!bannerCommand) {
10219
10372
  console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
10220
10373
  } else {
@@ -10243,7 +10396,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10243
10396
  // Merges GSD-owned entries non-destructively (preserves existing user permissions).
10244
10397
  // Scoped to Claude only: antigravity/qwen/hermes/codebuddy also write
10245
10398
  // settings.json but use different runtimes and do not use these permission strings.
10246
- if (runtime === 'claude') {
10399
+ if (_hostBehaviors(runtime).permissionsSchema === 'claude') {
10247
10400
  mergeClaudePermissions(settings);
10248
10401
  }
10249
10402
 
@@ -10276,7 +10429,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10276
10429
  // chat model instead of pinning the resolved model. See #1156 (default-to-omit
10277
10430
  // intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern
10278
10431
  // on configureOpencodePermissions above.
10279
- if (runtime !== 'claude' && !process.env.GSD_TEST_MODE) {
10432
+ if (!_hostBehaviors(runtime).nativeModelAliases && !process.env.GSD_TEST_MODE) {
10280
10433
  const gsdDir = path.join(os.homedir(), '.gsd');
10281
10434
  const defaultsPath = path.join(gsdDir, 'defaults.json');
10282
10435
  try {
@@ -10318,7 +10471,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10318
10471
  // Restart is required for CC to pick up newly-installed skills, and the
10319
10472
  // slash-menu surface depends on CC version — so the instruction needs to
10320
10473
  // cover both invocation paths to avoid #2957-style "no commands appear".
10321
- if (runtime === 'claude' && isGlobal) {
10474
+ if (_hostBehaviors(runtime).skillsGlobalOnboarding && isGlobal) {
10322
10475
  console.log(`
10323
10476
  ${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
10477
 
@@ -10477,7 +10630,7 @@ function parseRuntimeInput(answer) {
10477
10630
  }
10478
10631
  }
10479
10632
 
10480
- return selected.length > 0 ? selected : ['claude'];
10633
+ return selected.length > 0 ? selected : [DEFAULT_RUNTIME];
10481
10634
  }
10482
10635
 
10483
10636
  function promptRuntime(callback) {
@@ -11096,7 +11249,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
11096
11249
  throw error;
11097
11250
  }
11098
11251
 
11099
- const statuslineRuntimes = ['claude'];
11252
+ const statuslineRuntimes = [DEFAULT_RUNTIME];
11100
11253
  const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
11101
11254
 
11102
11255
  const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
@@ -11193,6 +11346,13 @@ module.exports = {
11193
11346
  generateCodexAgentToml,
11194
11347
  cleanupCodexSkillMetadataSidecars,
11195
11348
  cleanupWindsurfLegacyDevinSkills,
11349
+ cleanupMovedSkillsOldLocation,
11350
+ _resolveMovedSkillsOldDir,
11351
+ _resolveSkillsRootDir,
11352
+ codexBareAgentsHasOnlyKnownScalars,
11353
+ extractCodexUserAgentsScalars,
11354
+ spliceCodexAgentsScalars,
11355
+ CODEX_EXTENDED_HOOK_EVENTS,
11196
11356
  generateCodexConfigBlock,
11197
11357
  stripGsdFromCodexConfig,
11198
11358
  migrateCodexHooksMapFormat,
@@ -11212,6 +11372,9 @@ module.exports = {
11212
11372
  install,
11213
11373
  installAllRuntimes,
11214
11374
  uninstall,
11375
+ // #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
11376
+ _resolveHostBehaviors,
11377
+ FALLBACK_HOST_BEHAVIORS,
11215
11378
  convertSlashCommandsToCodexSkillMentions,
11216
11379
  convertClaudeCommandToCodexSkill,
11217
11380
  convertClaudeCommandToKimiSkill,
@@ -11276,6 +11439,11 @@ module.exports = {
11276
11439
  mergeGsdAgentsMd,
11277
11440
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
11278
11441
  GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
11442
+ GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT,
11443
+ GSD_CURSOR_STOP_HOOK_SCRIPT,
11444
+ GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
11445
+ GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
11446
+ GSD_CURSOR_HOOK_SCRIPTS,
11279
11447
  GSD_CURSOR_HOOK_MARKER,
11280
11448
  buildCursorHookEntry,
11281
11449
  isManagedCursorHookEntry,
@@ -11380,7 +11548,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11380
11548
  console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
11381
11549
  process.exit(1);
11382
11550
  }
11383
- const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
11551
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : [DEFAULT_RUNTIME];
11384
11552
  for (const runtime of runtimes) {
11385
11553
  uninstall(hasGlobal, runtime);
11386
11554
  }
@@ -11392,12 +11560,12 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11392
11560
  }
11393
11561
  } else if (hasGlobal || hasLocal) {
11394
11562
  // Default to Claude if no runtime specified but location is
11395
- installAllRuntimes(['claude'], hasGlobal, false);
11563
+ installAllRuntimes([DEFAULT_RUNTIME], hasGlobal, false);
11396
11564
  } else {
11397
11565
  // Interactive
11398
11566
  if (!process.stdin.isTTY) {
11399
11567
  console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
11400
- installAllRuntimes(['claude'], true, false);
11568
+ installAllRuntimes([DEFAULT_RUNTIME], true, false);
11401
11569
  } else {
11402
11570
  promptRuntime((runtimes) => {
11403
11571
  promptLocation(runtimes);