@opengsd/gsd-core 1.7.0-rc.3 → 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 (115) 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/README.ja-JP.md +4 -3
  5. package/README.ko-KR.md +4 -3
  6. package/README.md +4 -3
  7. package/README.pt-BR.md +4 -3
  8. package/README.zh-CN.md +4 -3
  9. package/agents/gsd-doc-classifier.md +105 -0
  10. package/agents/gsd-doc-synthesizer.md +61 -0
  11. package/agents/gsd-ui-checker.md +28 -0
  12. package/bin/install.js +652 -466
  13. package/commands/gsd/map-codebase.md +4 -4
  14. package/commands/gsd/ns-project.md +2 -1
  15. package/commands/gsd/onboard.md +46 -0
  16. package/gsd-core/bin/gsd-tools.cjs +87 -3
  17. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  18. package/gsd-core/bin/lib/audit.cjs +6 -3
  19. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  20. package/gsd-core/bin/lib/capability-registry.cjs +741 -62
  21. package/gsd-core/bin/lib/capability-state.cjs +117 -18
  22. package/gsd-core/bin/lib/capability-validator.cjs +1 -1
  23. package/gsd-core/bin/lib/capability-writer.cjs +21 -2
  24. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  25. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +136 -0
  26. package/gsd-core/bin/lib/claude-orchestration.cjs +404 -0
  27. package/gsd-core/bin/lib/clusters.cjs +1 -0
  28. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  29. package/gsd-core/bin/lib/commands.cjs +7 -5
  30. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  31. package/gsd-core/bin/lib/config.cjs +96 -0
  32. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  33. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  34. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  35. package/gsd-core/bin/lib/host-integration.cjs +16 -0
  36. package/gsd-core/bin/lib/init-command-router.cjs +4 -0
  37. package/gsd-core/bin/lib/init.cjs +99 -99
  38. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  39. package/gsd-core/bin/lib/install-engine.cjs +159 -16
  40. package/gsd-core/bin/lib/install-profiles.cjs +2 -0
  41. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  42. package/gsd-core/bin/lib/loop-resolver.cjs +75 -18
  43. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  44. package/gsd-core/bin/lib/milestone.cjs +3 -3
  45. package/gsd-core/bin/lib/model-resolver.cjs +69 -4
  46. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  47. package/gsd-core/bin/lib/onboard-projection.cjs +309 -0
  48. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  49. package/gsd-core/bin/lib/phase.cjs +78 -16
  50. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  51. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  53. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  54. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
  56. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  57. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
  58. package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
  59. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
  60. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  61. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  62. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  63. package/gsd-core/bin/lib/state.cjs +24 -24
  64. package/gsd-core/bin/lib/surface.cjs +81 -30
  65. package/gsd-core/bin/lib/uat.cjs +4 -1
  66. package/gsd-core/bin/lib/validate.cjs +15 -6
  67. package/gsd-core/bin/lib/verify.cjs +33 -37
  68. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  69. package/gsd-core/bin/shared/model-catalog.json +11 -6
  70. package/gsd-core/references/api-coverage.md +104 -0
  71. package/gsd-core/references/gsd-run-resolver.md +8 -0
  72. package/gsd-core/references/model-profiles.md +2 -2
  73. package/gsd-core/references/planning-config.md +2 -0
  74. package/gsd-core/references/specless-probe-fallback.md +172 -0
  75. package/gsd-core/templates/config.json +2 -1
  76. package/gsd-core/templates/project.md +1 -1
  77. package/gsd-core/workflows/audit-fix.md +9 -1
  78. package/gsd-core/workflows/code-review-fix.md +7 -3
  79. package/gsd-core/workflows/code-review.md +4 -1
  80. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  81. package/gsd-core/workflows/do.md +4 -3
  82. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  83. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  84. package/gsd-core/workflows/execute-phase.md +1 -25
  85. package/gsd-core/workflows/help/modes/brief.md +2 -1
  86. package/gsd-core/workflows/help/modes/default.md +2 -1
  87. package/gsd-core/workflows/help/modes/full.md +11 -1
  88. package/gsd-core/workflows/help/modes/topic.md +1 -1
  89. package/gsd-core/workflows/onboard.md +277 -0
  90. package/gsd-core/workflows/plan-phase.md +31 -2
  91. package/gsd-core/workflows/quick.md +22 -2
  92. package/gsd-core/workflows/review.md +59 -13
  93. package/gsd-core/workflows/settings-advanced.md +5 -5
  94. package/gsd-core/workflows/settings.md +2 -2
  95. package/gsd-core/workflows/verify-phase.md +3 -2
  96. package/gsd-core/workflows/verify-work.md +38 -0
  97. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  98. package/hooks/dist/gsd-cursor-stop.js +48 -0
  99. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  100. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  101. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  102. package/hooks/gsd-cursor-pre-tool.js +76 -0
  103. package/hooks/gsd-cursor-stop.js +48 -0
  104. package/hooks/gsd-cursor-subagent-start.js +50 -0
  105. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  106. package/hooks/managed-hooks-registry.cjs +4 -0
  107. package/package.json +2 -1
  108. package/scripts/build-hooks.js +5 -1
  109. package/scripts/gen-golden-install-parity-zcode.cjs +77 -0
  110. package/scripts/lint-phase-id-drift.cjs +150 -0
  111. package/scripts/run-tests.cjs +26 -4
  112. package/scripts/sync-runtime-launcher.cjs +20 -2
  113. package/skills/gsd-map-codebase/SKILL.md +3 -3
  114. package/skills/gsd-ns-project/SKILL.md +1 -0
  115. package/skills/gsd-onboard/SKILL.md +46 -0
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,
@@ -434,7 +529,7 @@ if (hasMinimal && _profileArgRaw) {
434
529
 
435
530
  function selectRuntimesFromArgs(runtimeArgs) {
436
531
  if (runtimeArgs.includes('--all')) {
437
- return ['claude', 'kimi', 'kilo', 'opencode', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline'];
532
+ return ['claude', 'kimi', 'kilo', 'opencode', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'zcode'];
438
533
  }
439
534
  if (runtimeArgs.includes('--both')) {
440
535
  return ['claude', 'opencode'];
@@ -456,6 +551,7 @@ function selectRuntimesFromArgs(runtimeArgs) {
456
551
  if (runtimeArgs.includes('--kimi')) selected.push('kimi');
457
552
  if (runtimeArgs.includes('--codebuddy')) selected.push('codebuddy');
458
553
  if (runtimeArgs.includes('--cline')) selected.push('cline');
554
+ if (runtimeArgs.includes('--zcode')) selected.push('zcode');
459
555
  return selected;
460
556
  }
461
557
 
@@ -579,7 +675,7 @@ const banner = '\n' +
579
675
  ' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
580
676
  ' Git. Ship. Done.\n' +
581
677
  ' A meta-prompting, context engineering and spec-driven\n' +
582
- ' development workflows for Claude Code, OpenCode, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n';
678
+ ' development workflows for Claude Code, OpenCode, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline, CodeBuddy and ZCode.\n';
583
679
 
584
680
  // Pure seam: parse --config-dir / -c from an arbitrary args array.
585
681
  // Returns the path string, '' for an empty equals-form value, or null when the
@@ -636,7 +732,7 @@ if (hasUninstall) {
636
732
 
637
733
  // Show help if requested
638
734
  if (hasHelp) {
639
- console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
735
+ console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--zcode${reset} Install for ZCode only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
640
736
  process.exit(0);
641
737
  }
642
738
 
@@ -909,6 +1005,9 @@ function resolveKiloConfigPath(configDir) {
909
1005
  return path.join(configDir, 'kilo.json');
910
1006
  }
911
1007
 
1008
+ // #2087 — attribution config-path resolvers, keyed by descriptor (hostBehaviors.attributionConfigResolver)
1009
+ const ATTRIBUTION_CONFIG_RESOLVERS = { opencode: resolveOpencodeConfigPath, kilo: resolveKiloConfigPath };
1010
+
912
1011
  /**
913
1012
  * Strip JSONC comments (// and /* *​/) from a string to produce valid JSON.
914
1013
  * Handles comments inside strings correctly (does not strip them).
@@ -1070,126 +1169,6 @@ function readGsdEffectiveModelOverrides(targetDir = null) {
1070
1169
  return { ...(global || {}), ...(projectOverrides || {}) };
1071
1170
  }
1072
1171
 
1073
- /**
1074
- * #443 — Read the merged `effort` config block for install-time effort resolution.
1075
- *
1076
- * Probes the same config sources as readGsdRuntimeProfileResolver (per-project
1077
- * `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the
1078
- * `effort` object instead of the model-profile fields.
1079
- *
1080
- * Returns the merged `effort` object or null when neither source defines one.
1081
- * The caller can pass this to resolveInstallTimeEffort() which is pure and
1082
- * requires no filesystem access beyond what this helper already performs.
1083
- *
1084
- * @param {string|null} targetDir Runtime install root (walks up to find .planning/).
1085
- * @returns {object|null}
1086
- */
1087
- function readGsdEffectiveEffortConfig(targetDir = null) {
1088
- const homeDefaults = _readGsdConfigFile(
1089
- path.join(os.homedir(), '.gsd', 'defaults.json'),
1090
- '~/.gsd/defaults.json'
1091
- );
1092
-
1093
- let projectConfig = null;
1094
- if (targetDir) {
1095
- let probeDir = path.resolve(targetDir);
1096
- for (let depth = 0; depth < 8; depth += 1) {
1097
- const candidate = path.join(probeDir, '.planning', 'config.json');
1098
- if (fs.existsSync(candidate)) {
1099
- projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
1100
- break;
1101
- }
1102
- const parent = path.dirname(probeDir);
1103
- if (parent === probeDir) break;
1104
- probeDir = parent;
1105
- }
1106
- }
1107
-
1108
- const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort))
1109
- ? homeDefaults.effort
1110
- : null;
1111
- const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort))
1112
- ? projectConfig.effort
1113
- : null;
1114
-
1115
- if (!homeEffort && !projectEffort) return null;
1116
-
1117
- // Per-project wins on conflict within each sub-field. Merge field-by-field so
1118
- // a project config that only sets agent_overrides still inherits global
1119
- // routing_tier_defaults and default.
1120
- return {
1121
- ...(homeEffort || {}),
1122
- ...(projectEffort || {}),
1123
- // Deep-merge agent_overrides (project wins per-key)
1124
- agent_overrides: {
1125
- ...((homeEffort && homeEffort.agent_overrides) || {}),
1126
- ...((projectEffort && projectEffort.agent_overrides) || {}),
1127
- },
1128
- };
1129
- }
1130
-
1131
-
1132
- /**
1133
- * #443 — Resolve install-time effort for a given agent, using the same
1134
- * precedence chain as resolveEffortInternal() in core.cjs, but operating
1135
- * on a pre-loaded effortCfg object (no loadConfig side-effects at install).
1136
- *
1137
- * Precedence (mirrors resolveEffortInternal):
1138
- * 1. effortCfg.agent_overrides[agentName]
1139
- * 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present)
1140
- * — OR manifest tier defaults when effortCfg is null
1141
- * 3. effortCfg.default
1142
- * 4. 'high' (hardcoded fallback)
1143
- *
1144
- * @param {object|null} effortCfg Result of readGsdEffectiveEffortConfig().
1145
- * @param {string} agentName e.g. 'gsd-planner'
1146
- * @returns {string} Universal effort string (low/medium/high/xhigh/max/minimal)
1147
- */
1148
- function resolveInstallTimeEffort(effortCfg, agentName) {
1149
- // Validates each candidate against the canonical EFFORT_SET (sourced once
1150
- // from core.cjs) before accepting it, mirroring resolveEffortInternal exactly.
1151
- // Invalid values fall through to the next precedence layer; final fallback 'high'.
1152
-
1153
- // Step 1: agent_overrides
1154
- if (effortCfg) {
1155
- const ao = effortCfg.agent_overrides;
1156
- if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
1157
- const v = ao[agentName];
1158
- if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
1159
- }
1160
- }
1161
-
1162
- // Step 2: routing_tier_defaults keyed by the agent's catalog tier
1163
- const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog();
1164
- const agentTier = AGENT_DEFAULT_TIERS[agentName];
1165
- if (agentTier) {
1166
- if (effortCfg && effortCfg.routing_tier_defaults &&
1167
- typeof effortCfg.routing_tier_defaults === 'object' &&
1168
- !Array.isArray(effortCfg.routing_tier_defaults)) {
1169
- const v = effortCfg.routing_tier_defaults[agentTier];
1170
- if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
1171
- } else if (!effortCfg) {
1172
- // No effort config — use manifest tier defaults
1173
- const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier];
1174
- if (typeof v === 'string' && GSD_EFFORT_SET.has(v)) return v;
1175
- }
1176
- // effortCfg exists but has no routing_tier_defaults — fall through
1177
- }
1178
-
1179
- // Step 3: effort.default
1180
- if (effortCfg) {
1181
- const d = effortCfg.default;
1182
- if (typeof d === 'string' && GSD_EFFORT_SET.has(d)) return d;
1183
- }
1184
-
1185
- // Step 4: manifest default (sourced from config-defaults.manifest.json effort.default)
1186
- // If even the manifest default is invalid, fall back to 'high'.
1187
- if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) {
1188
- return EFFORT_MANIFEST_DEFAULT;
1189
- }
1190
- return 'high';
1191
- }
1192
-
1193
1172
  /**
1194
1173
  * #443 — Inject `effort: <value>` into YAML frontmatter of a Claude .md agent
1195
1174
  * file in a newline-agnostic way (LF and CRLF source files are both handled).
@@ -1295,29 +1274,6 @@ const READONLY_AGENT_DISALLOWED_TOOLS = {
1295
1274
  'gsd-ui-auditor': 'Edit, MultiEdit',
1296
1275
  };
1297
1276
 
1298
- /**
1299
- * #2517 — Read a single GSD config file (defaults.json or per-project
1300
- * config.json) into a plain object, returning null on missing/empty files
1301
- * and warning to stderr on JSON parse failures so silent corruption can't
1302
- * mask broken configs (review finding #5).
1303
- */
1304
- function _readGsdConfigFile(absPath, label) {
1305
- if (!fs.existsSync(absPath)) return null;
1306
- let raw;
1307
- try {
1308
- raw = fs.readFileSync(absPath, 'utf-8');
1309
- } catch (err) {
1310
- process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`);
1311
- return null;
1312
- }
1313
- try {
1314
- return JSON.parse(raw);
1315
- } catch (err) {
1316
- process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`);
1317
- return null;
1318
- }
1319
- }
1320
-
1321
1277
  /**
1322
1278
  * #2517 — Build a runtime-aware tier resolver for the install path.
1323
1279
  *
@@ -1420,15 +1376,14 @@ function getCommitAttribution(runtime) {
1420
1376
 
1421
1377
  let result;
1422
1378
 
1423
- if (runtime === 'opencode' || runtime === 'kilo') {
1424
- const resolveConfigPath = runtime === 'opencode'
1425
- ? resolveOpencodeConfigPath
1426
- : resolveKiloConfigPath;
1379
+ const _attrResolverKey = _hostBehaviors(runtime).attributionConfigResolver;
1380
+ if (_attrResolverKey && ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey]) {
1381
+ const resolveConfigPath = ATTRIBUTION_CONFIG_RESOLVERS[_attrResolverKey];
1427
1382
  const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
1428
1383
  result = (config && config.disable_ai_attribution === true) ? null : undefined;
1429
- } else if (runtime === 'claude') {
1384
+ } else if (_hostBehaviors(runtime).attributionSource === 'settings-json-commit') {
1430
1385
  // Claude Code
1431
- const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
1386
+ const settings = readSettings(path.join(getGlobalConfigDir(runtime, explicitConfigDir), 'settings.json'));
1432
1387
  if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
1433
1388
  result = undefined;
1434
1389
  } else if (settings.attribution.commit === '') {
@@ -1896,7 +1851,7 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
1896
1851
  // Hermes' SKILL.md spec lists `version` as a required frontmatter field.
1897
1852
  // Track GSD's package version so Hermes' skill_view() reports a stable
1898
1853
  // identifier per install.
1899
- if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
1854
+ if (_hostBehaviors(runtime).skillFrontmatterVersion) fm += `version: ${yamlQuote(pkg.version)}\n`;
1900
1855
  // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
1901
1856
  // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
1902
1857
  // we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
@@ -3265,6 +3220,77 @@ function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
3265
3220
  return removed;
3266
3221
  }
3267
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
+
3268
3294
  /**
3269
3295
  * Generate the GSD config block for Codex config.toml.
3270
3296
  * @param {Array<{name: string, description: string}>} agents
@@ -3280,6 +3306,17 @@ function generateCodexConfigBlock(agents, targetDir) {
3280
3306
  '',
3281
3307
  ];
3282
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
+
3283
3320
  for (const { name, description } of agents) {
3284
3321
  // #2727 — Codex 0.124.0 requires [agents.<name>] struct format, not [[agents]] sequence.
3285
3322
  // [[agents]] (introduced in #2645) is rejected by codex-cli 0.124.0 with
@@ -3293,6 +3330,52 @@ function generateCodexConfigBlock(agents, targetDir) {
3293
3330
  return lines.join('\n');
3294
3331
  }
3295
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
+
3296
3379
  /**
3297
3380
  * Strip any managed GSD agent sections from a TOML string.
3298
3381
  *
@@ -3315,6 +3398,16 @@ function stripCodexGsdAgentSections(content) {
3315
3398
  return true;
3316
3399
  }
3317
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
+
3318
3411
  // Legacy `[[agents]]` array-of-tables (#2645) — only strip blocks whose
3319
3412
  // `name = "gsd-..."`, preserving user-authored [[agents]] entries.
3320
3413
  if (section.array && section.path === 'agents') {
@@ -3342,7 +3435,11 @@ function stripGsdFromCodexConfig(content) {
3342
3435
  const codexHooksOwnership = getManagedCodexHooksOwnership(content);
3343
3436
 
3344
3437
  if (markerIndex !== -1) {
3345
- // 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));
3346
3443
  let before = content.substring(0, markerIndex);
3347
3444
  before = stripCodexHooksFeatureAssignments(before, codexHooksOwnership);
3348
3445
  // Also strip GSD-injected feature keys above the marker (Case 3 inject)
@@ -3351,6 +3448,9 @@ function stripGsdFromCodexConfig(content) {
3351
3448
  before = before.replace(/^\[features\]\s*\n(?=\[|$)/m, '');
3352
3449
  before = before.replace(/^\[agents\]\s*\n(?=\[|$)/m, '');
3353
3450
  before = before.replace(/^(?:\r?\n)+/, '').trimEnd();
3451
+ if (preservedScalars.length > 0) {
3452
+ before = (before ? before + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
3453
+ }
3354
3454
  if (!before) return null;
3355
3455
  return before + eol;
3356
3456
  }
@@ -3361,7 +3461,11 @@ function stripGsdFromCodexConfig(content) {
3361
3461
  cleaned = cleaned.replace(/^multi_agent\s*=\s*true\s*(?:\r?\n)?/m, '');
3362
3462
  cleaned = cleaned.replace(/^default_mode_request_user_input\s*=\s*true\s*(?:\r?\n)?/m, '');
3363
3463
 
3364
- // 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.
3365
3469
  cleaned = stripCodexGsdAgentSections(cleaned);
3366
3470
 
3367
3471
  // Remove [features] section if now empty (only header, no keys before next section)
@@ -3372,6 +3476,10 @@ function stripGsdFromCodexConfig(content) {
3372
3476
 
3373
3477
  cleaned = cleaned.replace(/^(?:\r?\n)+/, '').trimEnd();
3374
3478
 
3479
+ if (preservedScalars.length > 0) {
3480
+ cleaned = (cleaned ? cleaned + eol + eol : '') + '[agents]' + eol + preservedScalars.join(eol);
3481
+ }
3482
+
3375
3483
  if (!cleaned) return null;
3376
3484
  return cleaned + eol;
3377
3485
  }
@@ -4843,6 +4951,33 @@ function parseTomlToObject(content) {
4843
4951
  * - `hooks.<Event>` MUST be an array of tables when present (Codex ≥0.124
4844
4952
  * rejects bare `[hooks.<Event>]` single-bracket maps).
4845
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
+
4846
4981
  function validateCodexConfigSchema(content) {
4847
4982
  let parsed;
4848
4983
  try {
@@ -4871,10 +5006,21 @@ function validateCodexConfigSchema(content) {
4871
5006
  }
4872
5007
 
4873
5008
  if (!section.array && section.path === 'agents') {
4874
- return {
4875
- ok: false,
4876
- reason: 'bare [agents] table is invalid in current Codex schema (expected [agents.<name>] struct form)',
4877
- };
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
+ }
4878
5024
  }
4879
5025
 
4880
5026
  // hooks.state.* is Codex's persistent hook-trust namespace (added in
@@ -5150,7 +5296,13 @@ function mergeCodexConfig(configPath, gsdBlock) {
5150
5296
 
5151
5297
  const existing = fs.readFileSync(configPath, 'utf8');
5152
5298
  const eol = detectLineEnding(existing);
5153
- 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);
5154
5306
  const markerIndex = existing.indexOf(GSD_CODEX_MARKER);
5155
5307
 
5156
5308
  // Case 2: Has GSD marker — truncate and re-append
@@ -6104,62 +6256,14 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
6104
6256
  // convertClaudeCommandToKiloSkill: moved to src/install-engine.cts (ADR-1239 Phase B).
6105
6257
  // Imported from installEngine above.
6106
6258
 
6107
- /**
6108
- * Copy commands to a flat structure for OpenCode
6109
- * OpenCode expects: command/gsd-help.md (invoked as /gsd-help)
6110
- * Source structure: commands/gsd/help.md
6111
- *
6112
- * @param {string} srcDir - Source directory (e.g., commands/gsd/)
6113
- * @param {string} destDir - Destination directory (e.g., command/)
6114
- * @param {string} prefix - Prefix for filenames (e.g., 'gsd')
6115
- * @param {string} pathPrefix - Path prefix for file references
6116
- * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
6117
- */
6118
6259
  // applyOpencodeFamilyPathPrefix: moved to src/install-engine.cts (ADR-1239 Phase B).
6119
6260
  // Imported from installEngine above.
6120
-
6121
- function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
6122
- if (!fs.existsSync(srcDir)) {
6123
- return;
6124
- }
6125
-
6126
- // Remove old gsd-*.md files before copying new ones
6127
- if (fs.existsSync(destDir)) {
6128
- for (const file of fs.readdirSync(destDir)) {
6129
- if (file.startsWith(`${prefix}-`) && file.endsWith('.md')) {
6130
- fs.unlinkSync(path.join(destDir, file));
6131
- }
6132
- }
6133
- } else {
6134
- fs.mkdirSync(destDir, { recursive: true });
6135
- }
6136
-
6137
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
6138
-
6139
- for (const entry of entries) {
6140
- const srcPath = path.join(srcDir, entry.name);
6141
-
6142
- if (entry.isDirectory()) {
6143
- // Recurse into subdirectories, adding to prefix
6144
- // e.g., commands/gsd/debug/start.md -> command/gsd-debug-start.md
6145
- copyFlattenedCommands(srcPath, destDir, `${prefix}-${entry.name}`, pathPrefix, runtime);
6146
- } else if (entry.name.endsWith('.md')) {
6147
- // Flatten: help.md -> gsd-help.md
6148
- const baseName = entry.name.replace('.md', '');
6149
- const destName = `${prefix}-${baseName}.md`;
6150
- const destPath = path.join(destDir, destName);
6151
-
6152
- let content = fs.readFileSync(srcPath, 'utf8');
6153
- content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
6154
- content = processAttribution(content, getCommitAttribution(runtime));
6155
- content = runtime === 'kilo'
6156
- ? convertClaudeToKiloFrontmatter(content)
6157
- : convertClaudeToOpencodeFrontmatter(content);
6158
-
6159
- fs.writeFileSync(destPath, content);
6160
- }
6161
- }
6162
- }
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.
6163
6267
 
6164
6268
  function listCodexSkillNames(skillsDir, prefix = 'gsd-') {
6165
6269
  if (!fs.existsSync(skillsDir)) return [];
@@ -6461,7 +6565,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
6461
6565
  // copyWithPathReplacement is the emit path for gsd-core/workflows/*.md;
6462
6566
  // _applyRuntimeRewrites is NOT invoked here, so this is what makes the fix
6463
6567
  // live in real installs (it is a no-op for files without those lines).
6464
- if (runtime !== 'claude') {
6568
+ if (!_hostBehaviors(runtime).authorsCanonicalWorkflow) {
6465
6569
  content = _stampNonClaudeRuntimeDefaults(content, runtime);
6466
6570
  }
6467
6571
 
@@ -6663,16 +6767,20 @@ const GSD_UNINSTALL_HOOKS = [
6663
6767
  * @param {boolean} isGlobal - Whether to uninstall from global or local
6664
6768
  * @param {string} runtime - Target runtime ('claude', 'opencode', 'codex', 'copilot')
6665
6769
  */
6666
- function uninstall(isGlobal, runtime = 'claude') {
6770
+ function uninstall(isGlobal, runtime = DEFAULT_RUNTIME) {
6667
6771
  const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
6668
6772
  const dirName = getDirName(runtime);
6669
6773
 
6670
6774
  // Get the target directory based on runtime and install type. Cline local
6671
6775
  // installs write to the project root (.clinerules/ lives at the root, not in
6672
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.
6673
6781
  const targetDir = isGlobal
6674
6782
  ? getGlobalConfigDir(runtime, explicitConfigDir)
6675
- : runtime === 'cline'
6783
+ : _hostBehaviors(runtime).localTargetIsProjectRoot
6676
6784
  ? process.cwd()
6677
6785
  : path.join(process.cwd(), dirName);
6678
6786
 
@@ -6721,11 +6829,34 @@ function uninstall(isGlobal, runtime = 'claude') {
6721
6829
 
6722
6830
  // 1. Remove GSD commands/skills (layout-driven)
6723
6831
  const scope = isGlobal ? 'global' : 'local';
6724
- 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
+ }
6725
6840
  removedCount++;
6726
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
+
6727
6858
  // 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json
6728
- if (isCodex) {
6859
+ if (_hostBehaviors(runtime).tomlConfigInstall) {
6729
6860
  const codexAgentsDir = path.join(targetDir, 'agents');
6730
6861
  if (fs.existsSync(codexAgentsDir)) {
6731
6862
  const tomlFiles = fs.readdirSync(codexAgentsDir);
@@ -6764,8 +6895,10 @@ function uninstall(isGlobal, runtime = 'claude') {
6764
6895
  console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
6765
6896
  }
6766
6897
 
6767
- // #772: remove new Codex hook event registrations added by this enhancement.
6768
- 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) {
6769
6902
  const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
6770
6903
  if (eventCleanup.changed) {
6771
6904
  removedCount++;
@@ -6812,7 +6945,9 @@ function uninstall(isGlobal, runtime = 'claude') {
6812
6945
  // 1b-cline. Non-layout Cline side-effects (issue #787): remove the
6813
6946
  // directory-form rules + PreToolUse hook, and strip the GSD block from the
6814
6947
  // global cross-tool ~/.agents/AGENTS.md target.
6815
- if (runtime === 'cline') {
6948
+ // Descriptor-driven (ADR-1239 / #2090): folded from `runtime === 'cline'`
6949
+ // into hostBehaviors.clineRulesSurface.
6950
+ if (_hostBehaviors(runtime).clineRulesSurface) {
6816
6951
  const clinerulesDir = path.join(targetDir, '.clinerules');
6817
6952
  for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
6818
6953
  const p = path.join(clinerulesDir, rel);
@@ -6858,17 +6993,20 @@ function uninstall(isGlobal, runtime = 'claude') {
6858
6993
  }
6859
6994
  }
6860
6995
 
6861
- // 1b-cursor. Non-layout Cursor side-effects (issue #777): remove GSD-managed
6862
- // hook entries from hooks.json and clean up the managed hook scripts.
6863
- 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) {
6864
7001
  const hooksJsonCleanup = removeCursorHooksJson(targetDir);
6865
7002
  if (hooksJsonCleanup.changed) {
6866
7003
  removedCount++;
6867
7004
  console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`);
6868
7005
  }
6869
- // 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).
6870
7008
  const hooksDir = path.join(targetDir, 'hooks');
6871
- for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) {
7009
+ for (const script of GSD_CURSOR_HOOK_SCRIPTS) {
6872
7010
  const p = path.join(hooksDir, script);
6873
7011
  try {
6874
7012
  if (fs.existsSync(p)) {
@@ -6887,7 +7025,7 @@ function uninstall(isGlobal, runtime = 'claude') {
6887
7025
 
6888
7026
  // 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout,
6889
7027
  // #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs.
6890
- if (!isGlobal && runtime === 'claude') {
7028
+ if (!isGlobal && _hostBehaviors(runtime).localInstallStyle === 'legacy-flat') {
6891
7029
  const commandsDir = path.join(targetDir, 'commands');
6892
7030
  // Remove flat gsd-*.md files (current layout after #1367 fix)
6893
7031
  if (fs.existsSync(commandsDir)) {
@@ -6929,7 +7067,7 @@ function uninstall(isGlobal, runtime = 'claude') {
6929
7067
  // removes the directory; we must preserve/restore user artifacts before that path.
6930
7068
  // This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory
6931
7069
  // was already removed and skip if so (idempotent).
6932
- if (isQwen || isHermes) {
7070
+ if (isQwen || _hostBehaviors(runtime).legacyCommandsGsdCleanup === true) {
6933
7071
  // dev-preferences may have survived in skills/ as SKILL.md — nothing to do for
6934
7072
  // that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed),
6935
7073
  // attempt migration. In practice _runLegacyUninstallCleanup removes it first,
@@ -7042,9 +7180,10 @@ function uninstall(isGlobal, runtime = 'claude') {
7042
7180
  // 4z. Remove the OpenCode native plugin adapter (#1914). Only GSD's own
7043
7181
  // plugin file is removed; the plugins/ dir is pruned only if it becomes
7044
7182
  // empty, preserving any user-authored OpenCode plugins.
7045
- if (isOpencode) {
7046
- const pluginsDir = path.join(targetDir, 'plugins');
7047
- 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);
7048
7187
  if (fs.existsSync(pluginPath)) {
7049
7188
  try {
7050
7189
  fs.unlinkSync(pluginPath);
@@ -7190,7 +7329,7 @@ function uninstall(isGlobal, runtime = 'claude') {
7190
7329
  // to preserve any user-added allow/deny entries.
7191
7330
  // Uses a local flag to avoid the shared `settingsModified` producing a false
7192
7331
  // "Removed GSD permissions" message when only hooks/statusline changed.
7193
- if (runtime === 'claude' && settings.permissions) {
7332
+ if (_hostBehaviors(runtime).permissionsSchema === 'claude' && settings.permissions) {
7194
7333
  let permissionsModified = false;
7195
7334
  if (Array.isArray(settings.permissions.allow)) {
7196
7335
  const before = settings.permissions.allow.length;
@@ -7223,7 +7362,7 @@ function uninstall(isGlobal, runtime = 'claude') {
7223
7362
  }
7224
7363
 
7225
7364
  // 6. For OpenCode, clean up permissions from opencode.json or opencode.jsonc
7226
- if (isOpencode) {
7365
+ if (resolveInstallPlan(runtime).finishPermissionWriter === 'opencode') {
7227
7366
  const configPath = resolveOpencodeConfigPath(targetDir);
7228
7367
  if (fs.existsSync(configPath)) {
7229
7368
  try {
@@ -7665,19 +7804,24 @@ function resolveInstallRelativePath(baseDir, relPath) {
7665
7804
  /**
7666
7805
  * Write file manifest after installation for future modification detection
7667
7806
  */
7668
- function writeManifest(configDir, runtime = 'claude', options = {}) {
7807
+ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) {
7669
7808
  const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
7670
7809
  const gsdDir = path.join(configDir, 'gsd-core');
7671
7810
  // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/).
7672
7811
  // Claude local uses flatCommandsDir instead for manifest recording.
7673
7812
  const flatCommandsDir = path.join(configDir, 'commands');
7674
- const opencodeCommandDir = path.join(configDir, 'command');
7675
- // 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').
7676
7816
  // All other runtimes that use the Codex-style skills layout use a flat skills/ root.
7677
- const codexSkillsDir = isHermes
7678
- ? path.join(configDir, 'skills', 'gsd')
7679
- : path.join(configDir, 'skills');
7680
- 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/';
7681
7825
  const agentsDir = path.join(configDir, 'agents');
7682
7826
  const manifest = {
7683
7827
  version: pkg.version,
@@ -7703,21 +7847,21 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7703
7847
  // Claude local (#1367): flat gsd-*.md files at commands/ level.
7704
7848
  // Only claude local writes gsd-*.md here; global installs don't emit commands,
7705
7849
  // so this branch is a no-op for global (no matching files to find).
7706
- if (runtime === 'claude' && fs.existsSync(flatCommandsDir)) {
7850
+ if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && fs.existsSync(flatCommandsDir)) {
7707
7851
  for (const file of fs.readdirSync(flatCommandsDir)) {
7708
7852
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
7709
7853
  manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file));
7710
7854
  }
7711
7855
  }
7712
7856
  }
7713
- if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) {
7857
+ if (_hostBehaviors(runtime).flatCommandDir && fs.existsSync(opencodeCommandDir)) {
7714
7858
  for (const file of fs.readdirSync(opencodeCommandDir)) {
7715
7859
  if (file.startsWith('gsd-') && file.endsWith('.md')) {
7716
7860
  manifest.files['command/' + file] = fileHash(path.join(opencodeCommandDir, file));
7717
7861
  }
7718
7862
  }
7719
7863
  }
7720
- if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || !isOpencode) && fs.existsSync(codexSkillsDir)) {
7864
+ if (!_hostBehaviors(runtime).skipCodexSkillsManifest && fs.existsSync(codexSkillsDir)) {
7721
7865
  // All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
7722
7866
  const skillListPrefix = 'gsd-';
7723
7867
  for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
@@ -7727,8 +7871,8 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7727
7871
  manifest.files[`${codexSkillsManifestPrefix}${skillName}/${rel}`] = hash;
7728
7872
  }
7729
7873
  }
7730
- // For Hermes, also hash the category DESCRIPTION.md so reinstall detects drift.
7731
- if (isHermes) {
7874
+ // Descriptor-driven (#2090): hash the category DESCRIPTION.md so reinstall detects drift.
7875
+ if (_hostBehaviors(runtime).trackCategoryDescription) {
7732
7876
  const descPath = path.join(codexSkillsDir, 'DESCRIPTION.md');
7733
7877
  if (fs.existsSync(descPath)) {
7734
7878
  manifest.files['skills/gsd/DESCRIPTION.md'] = fileHash(descPath);
@@ -7754,7 +7898,9 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7754
7898
  // Track Cline directory-form artifacts in the manifest (issue #787): the
7755
7899
  // rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
7756
7900
  // marker block, not the per-configDir manifest, since it lives outside it.)
7757
- if (isCline) {
7901
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
7902
+ // hostBehaviors.clineRulesSurface.
7903
+ if (_hostBehaviors(runtime).clineRulesSurface) {
7758
7904
  for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
7759
7905
  const dest = path.join(configDir, rel);
7760
7906
  if (fs.existsSync(dest)) {
@@ -7765,7 +7911,9 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7765
7911
 
7766
7912
  // Track hook files so saveLocalPatches() can detect user modifications
7767
7913
  // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
7768
- 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) {
7769
7917
  const hooksDir = path.join(configDir, 'hooks');
7770
7918
  if (fs.existsSync(hooksDir)) {
7771
7919
  // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
@@ -7829,10 +7977,11 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7829
7977
 
7830
7978
  // Track the OpenCode native plugin adapter (#1914) so update/drift detection
7831
7979
  // and uninstall can account for it.
7832
- if (isOpencode) {
7833
- 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);
7834
7983
  if (fs.existsSync(pluginInstallPath)) {
7835
- manifest.files['plugins/gsd-core.js'] = fileHash(pluginInstallPath);
7984
+ manifest.files[`${_npM.dir}/${_npM.file}`] = fileHash(pluginInstallPath);
7836
7985
  }
7837
7986
  }
7838
7987
 
@@ -8116,7 +8265,7 @@ function saveLocalPatches(configDir, pristineCtx) {
8116
8265
  /**
8117
8266
  * After install, report backed-up patches for user to reapply.
8118
8267
  */
8119
- function reportLocalPatches(configDir, runtime = 'claude') {
8268
+ function reportLocalPatches(configDir, runtime = DEFAULT_RUNTIME) {
8120
8269
  const patchesDir = path.join(configDir, PATCHES_DIR_NAME);
8121
8270
  const metaPath = path.join(patchesDir, 'backup-meta.json');
8122
8271
  if (!fs.existsSync(metaPath)) return [];
@@ -8125,15 +8274,11 @@ function reportLocalPatches(configDir, runtime = 'claude') {
8125
8274
  try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; }
8126
8275
 
8127
8276
  if (meta.files && meta.files.length > 0) {
8128
- const reapplyCommand = (runtime === 'opencode' || runtime === 'kilo' || runtime === 'copilot')
8129
- ? '/gsd-update --reapply'
8130
- : runtime === 'codex'
8131
- ? '$gsd-update --reapply'
8132
- : runtime === 'cursor'
8133
- ? 'gsd-update --reapply (mention the skill name)'
8134
- : runtime === 'kimi'
8135
- ? '/skill:gsd-update --reapply'
8136
- : '/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';
8137
8282
  console.log('');
8138
8283
  console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
8139
8284
  for (const f of meta.files) {
@@ -8159,8 +8304,8 @@ function reportInstallerMigrationResult(result) {
8159
8304
  }
8160
8305
  }
8161
8306
 
8162
- function install(isGlobal, runtime = 'claude', options = {}) {
8163
- 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);
8164
8309
  const plan = resolveInstallPlan(runtime);
8165
8310
  const dirName = getDirName(runtime);
8166
8311
  const src = path.join(__dirname, '..');
@@ -8213,15 +8358,17 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8213
8358
  };
8214
8359
 
8215
8360
  // Get the target directory based on runtime and install type.
8216
- // Cline local installs write to the project root (like Claude Code) — .clinerules
8217
- // 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.
8218
8365
  // #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
8219
8366
  // directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
8220
8367
  // but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
8221
8368
  // auto-removed on reinstall (dual-read fallback per issue #791 spec).
8222
8369
  const targetDir = isGlobal
8223
8370
  ? getGlobalConfigDir(runtime, explicitConfigDir)
8224
- : isCline
8371
+ : _hostBehaviors(runtime).localTargetIsProjectRoot
8225
8372
  ? process.cwd()
8226
8373
  : path.join(process.cwd(), dirName);
8227
8374
 
@@ -8300,7 +8447,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8300
8447
  const isWindowsHost = process.platform === 'win32';
8301
8448
  const pathPrefix = computePathPrefix({
8302
8449
  isGlobal,
8303
- isOpencode,
8450
+ isOpencode: _hostBehaviors(runtime).skipHomePrefixSubstitution === true,
8304
8451
  isWindowsHost,
8305
8452
  resolvedTarget,
8306
8453
  homeDir,
@@ -8366,8 +8513,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8366
8513
  // Map<filename, Buffer> — content snapshot of each pre-existing gsd-* agent file.
8367
8514
  const codexPreInstallAgentContents = new Map();
8368
8515
  let codexPreInstallVersionBytes = null;
8369
- if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
8370
- const _preSkillsDir = path.join(targetDir, 'skills');
8516
+ if (_hostBehaviors(runtime).tomlConfigInstall && !isMinimalMode(_effectiveInstallMode)) {
8517
+ const _preSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
8371
8518
  if (fs.existsSync(_preSkillsDir)) {
8372
8519
  for (const entry of fs.readdirSync(_preSkillsDir, { withFileTypes: true })) {
8373
8520
  if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
@@ -8420,10 +8567,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8420
8567
  // atomic-write temp files. It is safe to call before any writes have happened.
8421
8568
  // The full restoreCodexSnapshot() (defined inside the config block) additionally
8422
8569
  // handles config.toml, which is not yet touched at this point in the pipeline.
8423
- const _codexPreConfigRollback = !isCodex || isMinimalMode(_effectiveInstallMode) ? null : () => {
8570
+ const _codexPreConfigRollback = !_hostBehaviors(runtime).tomlConfigInstall || isMinimalMode(_effectiveInstallMode) ? null : () => {
8424
8571
  rollbackInstallerMigrations();
8425
8572
  // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install).
8426
- const _earlySkillsDir = path.join(targetDir, 'skills');
8573
+ const _earlySkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
8427
8574
  for (const skillName of codexPreInstallSkillNames) {
8428
8575
  const skillDirPath = path.join(_earlySkillsDir, skillName);
8429
8576
  const fileMap = codexPreInstallSkillContents.get(skillName);
@@ -8586,23 +8733,58 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8586
8733
  // Hermes: writeHermesCategoryDescription (not a layout kind)
8587
8734
  // Cline global: skills emitted via layout; .clinerules still written below (#782)
8588
8735
  // Cline local: no skills (only .clinerules) — falls through to cline-rules surface
8589
- // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
8590
8736
  // Claude local: copyWithPathReplacement + stale-skills cleanup
8591
8737
 
8592
8738
  // Layout-driven path for all skills-based runtimes (full and minimal modes).
8593
8739
  // applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts)
8594
8740
  // handles per-runtime path + branding rewrites, including Qwen/Hermes.
8595
8741
  // Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
8596
- const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
8597
- isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
8598
- isKimi ||
8599
- (runtime === 'claude' && isGlobal) ||
8600
- (isCline && isGlobal);
8742
+ // Descriptor-driven (ADR-1016 / ADR-1239): a runtime takes the layout-driven
8743
+ // installRuntimeArtifacts path when its scoped artifactLayout is non-empty
8744
+ // (it declares any skills/commands/agents/kimi-agents kind for this scope).
8745
+ // This replaces the prior hardcoded `isCodex || isCopilot || ...` roster so a
8746
+ // newly-added runtime with an artifact layout installs without a per-runtime
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
8753
+ // (copyWithPathReplacement + stale-skills cleanup).
8754
+ const _isSkillsRuntime = (() => {
8755
+ if (_hostBehaviors(runtime).localInstallStyle === 'legacy-flat' && !isGlobal) return false; // legacy flat local path (descriptor-driven; #2086)
8756
+ const cap = _capabilityRegistry && _capabilityRegistry.runtimes && _capabilityRegistry.runtimes[runtime];
8757
+ const layout = cap && cap.runtime && cap.runtime.artifactLayout;
8758
+ if (!layout) return false;
8759
+ const scopeLayout = isGlobal ? layout.global : layout.local;
8760
+ return Array.isArray(scopeLayout) && scopeLayout.length > 0;
8761
+ })();
8601
8762
 
8602
8763
  if (_isSkillsRuntime) {
8603
8764
  // Layout-driven install for skills-based runtimes (full and minimal modes)
8604
8765
  const scope = isGlobal ? 'global' : 'local';
8605
- 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
+ }
8606
8788
 
8607
8789
  // #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
8608
8790
  // gsd-* skill dirs. Prior installs wrote these files so Codex would show a
@@ -8610,8 +8792,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8610
8792
  // index BOTH SKILL.md and the sidecar, causing each GSD skill to appear twice
8611
8793
  // in autocomplete. Cleaning them up fixes the duplication; SKILL.md alone is
8612
8794
  // sufficient for Codex discovery. User-owned dirs are never touched.
8613
- if (isCodex) {
8614
- 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
+ }
8615
8812
  }
8616
8813
 
8617
8814
  // #1629 Finding B: Windsurf local only — remove legacy .devin/skills/gsd-*
@@ -8625,13 +8822,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8625
8822
  }
8626
8823
  }
8627
8824
 
8628
- // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
8629
- if (isHermes) {
8825
+ // Descriptor-driven (#2090): write DESCRIPTION.md for the gsd/ category after layout install
8826
+ if (_hostBehaviors(runtime).writeCategoryDescription) {
8630
8827
  writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
8631
8828
  }
8632
8829
 
8633
8830
  // Verify installed artifacts and report
8634
- if (isHermes) {
8831
+ if (_hostBehaviors(runtime).reportSkillsCount) {
8635
8832
  const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
8636
8833
  if (fs.existsSync(hermesSkillsDir)) {
8637
8834
  // Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
@@ -8683,7 +8880,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8683
8880
  }
8684
8881
  }
8685
8882
  } else {
8686
- const skillsDir = path.join(targetDir, 'skills');
8883
+ const skillsDir = _skillsRootDir;
8687
8884
  if (fs.existsSync(skillsDir)) {
8688
8885
  const count = fs.readdirSync(skillsDir, { withFileTypes: true })
8689
8886
  .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
@@ -8711,8 +8908,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8711
8908
  }
8712
8909
  }
8713
8910
 
8714
- // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands)
8715
- 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) {
8716
8914
  const commandsDir = path.join(targetDir, 'commands');
8717
8915
  if (fs.existsSync(commandsDir)) {
8718
8916
  const cmdCount = fs.readdirSync(commandsDir)
@@ -8743,68 +8941,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8743
8941
  }
8744
8942
  }
8745
8943
  }
8746
- } else if (isOpencode || isKilo) {
8747
- // OpenCode/Kilo: flat structure in command/ directory
8748
- const commandDir = path.join(targetDir, 'command');
8749
- fs.mkdirSync(commandDir, { recursive: true });
8750
-
8751
- // Copy commands/gsd/*.md as command/gsd-*.md (flatten structure)
8752
- const gsdSrc = _stageSkills(_commandsDir);
8753
- copyFlattenedCommands(gsdSrc, commandDir, 'gsd', pathPrefix, runtime);
8754
- if (verifyInstalled(commandDir, 'command/gsd-*')) {
8755
- const count = fs.readdirSync(commandDir).filter(f => f.startsWith('gsd-')).length;
8756
- console.log(` ${green}✓${reset} Installed ${count} commands to command/`);
8757
- } else {
8758
- failures.push('command/gsd-*');
8759
- }
8760
-
8761
- // Also emit OpenCode-family skills (skills/<name>/SKILL.md). OpenCode and
8762
- // Kilo support native, on-demand skills in addition to flat commands — see
8763
- // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
8764
- // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
8765
- const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix, getCommitAttribution);
8766
- if (_skillCount > 0) {
8767
- console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
8768
- } else {
8769
- failures.push('skills/gsd-*');
8770
- }
8771
-
8772
- // OpenCode-only: install the native plugin adapter (#1914). OpenCode
8773
- // declares hooksSurface: 'none', so GSD's lifecycle hooks are never
8774
- // registered as settings.json hooks the way Claude Code does — the hook
8775
- // *scripts* ship to <configDir>/hooks/ but nothing invokes them. This
8776
- // plugin bridges OpenCode's event bus onto those existing hook scripts
8777
- // (prompt guard, read guard, injection scanner, context monitor, ...),
8778
- // spawning them as subprocesses. OpenCode auto-discovers plugin files under
8779
- // <configDir>/plugins/ at startup — no opencode.json registration needed
8780
- // (its `plugin` array is for npm packages, not local file paths).
8781
- //
8782
- // The file MUST land as `.js`: OpenCode's loader globs
8783
- // `{plugin,plugins}/*.{ts,js}` (verified against its source) — a `.cjs`
8784
- // extension would never be discovered. The config dir carries a
8785
- // `{"type":"commonjs"}` package.json (written above), so the `.js` file is
8786
- // interpreted as CommonJS, matching the adapter's module.exports/require.
8787
- // Kilo has no plugin surface, so this is gated to OpenCode only.
8788
- if (isOpencode) {
8789
- const pluginSrc = path.join(src, '.opencode', 'plugins', 'gsd-core.js');
8790
- const pluginDestDir = path.join(targetDir, 'plugins');
8791
- const pluginDest = path.join(pluginDestDir, 'gsd-core.js');
8792
- if (fs.existsSync(pluginSrc)) {
8793
- fs.mkdirSync(pluginDestDir, { recursive: true });
8794
- fs.copyFileSync(pluginSrc, pluginDest);
8795
- if (fs.existsSync(pluginDest)) {
8796
- console.log(` ${green}✓${reset} Installed OpenCode plugin (bridges GSD hooks)`);
8797
- } else {
8798
- failures.push('plugins/gsd-core.js');
8799
- }
8800
- } else {
8801
- failures.push('plugins/gsd-core.js');
8802
- }
8803
- }
8804
- } else if (isCline) {
8944
+ } else if (_hostBehaviors(runtime).localCommandsViaRules) {
8805
8945
  // Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
8806
8946
  // No skills/commands directory needed for local installs.
8807
8947
  // Global installs are handled above by _isSkillsRuntime (#782).
8948
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
8949
+ // hostBehaviors.localCommandsViaRules.
8808
8950
  console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
8809
8951
  } else {
8810
8952
  // Claude Code local: flat gsd-<cmd>.md layout — Claude Code registers
@@ -8902,11 +9044,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8902
9044
  // other runtime/scope deploys commands/gsd, so its walk-up already resolves
8903
9045
  // and needs no marker. Guarded on source presence so a half-published
8904
9046
  // package never writes a dangling marker.
8905
- if (runtime === 'claude' && isGlobal) {
9047
+ if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
8906
9048
  const gsdSourceCommands = path.join(src, 'commands', 'gsd');
8907
9049
  if (fs.existsSync(gsdSourceCommands)) {
8908
9050
  try {
8909
- 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');
8910
9055
  } catch (err) {
8911
9056
  // Non-fatal: install proceeds. But on the Claude-global layout walk-up
8912
9057
  // also fails (no commands/gsd source tree), so a silent write failure
@@ -8978,9 +9123,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8978
9123
  // (by installRuntimeArtifacts at line 8912), which also performs its own
8979
9124
  // stale-file prune pass. The inline stale-removal + inline loop both skip them.
8980
9125
  // Trivial group (cursor/windsurf/augment/trae/codebuddy) cut over together.
8981
- // cline is excluded: it takes a rules-only local branch and has a local/global
8982
- // complication that the descriptor-driven path does not handle correctly.
8983
- const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy']);
9126
+ // #1575: copilot and antigravity cut over — copilot gets .agent.md filename
9127
+ // rename via _copyStaged(runtime); antigravity uses scope-aware converter.
9128
+ // cline remains excluded: rules-only local branch + local/global complication
9129
+ // that the descriptor-driven path does not handle correctly.
9130
+ const _DESCRIPTOR_AGENTS_RUNTIMES = new Set(['cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'copilot', 'antigravity']);
8984
9131
 
8985
9132
  // Always remove stale gsd-* agents first so re-installing with
8986
9133
  // `--minimal` actually shrinks a previously-full install.
@@ -8991,7 +9138,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8991
9138
  for (const file of fs.readdirSync(agentsDest)) {
8992
9139
  if (
8993
9140
  file.startsWith('gsd-') &&
8994
- (file.endsWith('.md') || (isCodex && file.endsWith('.toml')))
9141
+ (file.endsWith('.md') || (_hostBehaviors(runtime).agentTomlFiles && file.endsWith('.toml')))
8995
9142
  ) {
8996
9143
  fs.unlinkSync(path.join(agentsDest, file));
8997
9144
  }
@@ -9009,7 +9156,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9009
9156
  // Without stripping them here, a full → minimal reinstall would leave the
9010
9157
  // runtime advertising the old full agent surface even though the agent
9011
9158
  // files are gone. Reuse the same helper that powers `--uninstall`.
9012
- if (isCodex) {
9159
+ if (_hostBehaviors(runtime).tomlConfigInstall) {
9013
9160
  const codexConfigPath = path.join(targetDir, 'config.toml');
9014
9161
  if (fs.existsSync(codexConfigPath)) {
9015
9162
  const existing = fs.readFileSync(codexConfigPath, 'utf8');
@@ -9044,7 +9191,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9044
9191
  }
9045
9192
  content = processAttribution(content, getCommitAttribution(runtime));
9046
9193
  // Convert frontmatter for runtime compatibility (agents need different handling)
9047
- if (isOpencode) {
9194
+ if (_hostBehaviors(runtime).frontmatterDialect === 'opencode') {
9048
9195
  // Resolve per-agent model for OpenCode agents.
9049
9196
  // Precedence: model_overrides[agent] > model_profile_overrides.opencode.<tier> > omit.
9050
9197
  // model_overrides (#2256): explicit per-agent override, highest precedence.
@@ -9063,16 +9210,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9063
9210
  }
9064
9211
  }
9065
9212
  content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true, modelOverride: _ocModelOverride });
9066
- } else if (isKilo) {
9213
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'kilo') {
9067
9214
  content = convertClaudeToKiloFrontmatter(content, { isAgent: true });
9068
- } else if (isCodex) {
9215
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'codex') {
9069
9216
  content = convertClaudeAgentToCodexAgent(content);
9070
9217
  } else if (isCopilot) {
9071
9218
  content = convertClaudeAgentToCopilotAgent(content, isGlobal);
9072
9219
  } else if (isAntigravity) {
9073
9220
  content = convertClaudeAgentToAntigravityAgent(content, isGlobal);
9074
- } else if (isCursor) {
9075
- content = convertClaudeAgentToCursorAgent(content);
9076
9221
  } else if (isWindsurf) {
9077
9222
  content = convertClaudeAgentToWindsurfAgent(content);
9078
9223
  } else if (isAugment) {
@@ -9081,13 +9226,15 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9081
9226
  content = convertClaudeAgentToTraeAgent(content);
9082
9227
  } else if (isCodebuddy) {
9083
9228
  content = convertClaudeAgentToCodebuddyAgent(content);
9084
- } else if (isCline) {
9229
+ } else if (_hostBehaviors(runtime).frontmatterDialect === 'cline') {
9230
+ // Descriptor-driven (ADR-1239 / #2090): folded from `isCline` into
9231
+ // hostBehaviors.frontmatterDialect === 'cline'.
9085
9232
  content = convertClaudeAgentToClineAgent(content);
9086
9233
  } else if (isQwen) {
9087
9234
  content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9088
9235
  content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9089
9236
  content = content.replace(/\.claude\//g, '.qwen/');
9090
- } else if (isHermes) {
9237
+ } else if (_hostBehaviors(runtime).brandingRewrites) {
9091
9238
  content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9092
9239
  content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
9093
9240
  content = content.replace(/\.claude\//g, '.hermes/');
@@ -9099,11 +9246,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9099
9246
  // Claude Code reads per-subagent `effort:` frontmatter (anthropics/claude-code #31536).
9100
9247
  // Injection is per-runtime at install time because the canonical source
9101
9248
  // agents/*.md must stay runtime-safe (no effort: key in source).
9102
- if (runtime === 'claude') {
9249
+ if ((_hostBehaviors(runtime).agentFrontmatterExtensions || []).includes('effort')) {
9103
9250
  const _effortCfg = readGsdEffectiveEffortConfig(targetDir);
9104
9251
  const _agentName = entry.name.replace(/\.md$/, '');
9105
9252
  const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
9106
- const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
9253
+ const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime(runtime, _universalEffort).value;
9107
9254
  content = injectEffortFrontmatter(content, _renderedEffort);
9108
9255
  const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
9109
9256
  if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
@@ -9147,7 +9294,17 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9147
9294
  failures.push('VERSION');
9148
9295
  }
9149
9296
 
9150
- 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) {
9151
9308
  // Write package.json to force CommonJS mode for GSD scripts
9152
9309
  // Prevents "require is not defined" errors when project has "type": "module"
9153
9310
  // Node.js walks up looking for package.json - this stops inheritance from project
@@ -9176,7 +9333,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9176
9333
  content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
9177
9334
  content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
9178
9335
  }
9179
- if (isHermes) {
9336
+ if (_hostBehaviors(runtime).brandingRewrites) {
9180
9337
  content = content.replace(/CLAUDE\.md/g, 'HERMES.md');
9181
9338
  content = content.replace(/\bClaude Code\b/g, 'Hermes Agent');
9182
9339
  }
@@ -9238,12 +9395,16 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9238
9395
 
9239
9396
  // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
9240
9397
  // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
9241
- // (Cursor uses standalone .js hook scripts registered via hooks.json; Codex uses
9242
- // hooks.json directly; the others skip hooks entirely), so they must not receive
9243
- // the hooks/lib/ helpers — otherwise the Codex comment downstream
9244
- // ("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.
9245
9406
  const hooksLibSrc = path.join(src, 'hooks', 'lib');
9246
- 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)) {
9247
9408
  const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
9248
9409
  fs.mkdirSync(hooksLibDest, { recursive: true });
9249
9410
  copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
@@ -9370,14 +9531,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9370
9531
  }
9371
9532
 
9372
9533
  // Write file manifest for future modification detection
9373
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
9534
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9374
9535
  console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`);
9375
9536
 
9376
9537
  // Report any backed-up local patches
9377
9538
  reportLocalPatches(targetDir, runtime);
9378
9539
 
9379
9540
  // Verify no leaked .claude paths in non-Claude runtimes (manifest-scoped)
9380
- if (runtime !== 'claude') {
9541
+ if (!_hostBehaviors(runtime).ownsClaudePaths) {
9381
9542
  const leakedPaths = [];
9382
9543
  // Only scan files that were written by this install (manifest-tracked).
9383
9544
  // Scanning the entire targetDir can match user-authored content that
@@ -9513,7 +9674,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9513
9674
  // (copyCommandsAsCodexSkills removes pre-existing gsd-* dirs before re-writing)
9514
9675
  // are restored even when they are absent from disk at rollback time (#3245 CR).
9515
9676
  // • Dirs that did not pre-exist: remove entirely.
9516
- const _rollbackSkillsDir = path.join(targetDir, 'skills');
9677
+ const _rollbackSkillsDir = _resolveSkillsRootDir(runtime, targetDir, isGlobal ? 'global' : 'local');
9517
9678
  // Pass 1 — restore snapshot entries (may be absent from disk if deleted mid-install).
9518
9679
  for (const skillName of codexPreInstallSkillNames) {
9519
9680
  const skillDirPath = path.join(_rollbackSkillsDir, skillName);
@@ -9624,7 +9785,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9624
9785
  // Re-write the manifest now that .toml agent files exist on disk.
9625
9786
  // The initial writeManifest call (before Codex config generation) could
9626
9787
  // not include agents/gsd-*.toml because those files did not yet exist.
9627
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
9788
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9628
9789
  } else {
9629
9790
  console.log(` ${dim}↳${reset} Skipping Codex agent config generation (minimal install)`);
9630
9791
  }
@@ -9764,24 +9925,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9764
9925
  }
9765
9926
  }
9766
9927
 
9767
- // ── Codex extended hook events (#772) ────────────────────────────────
9768
- // Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register
9769
- // three new high-value lifecycle events — all routed through
9770
- // gsd-context-monitor.js so context-headroom warnings surface at:
9771
- // SubagentStart — subagent session open (environment / agent-name aware)
9772
- // Stop — model stop / session final-response moment
9773
- // PostToolUse — after each tool invocation (mirrors Claude baseline)
9774
- //
9775
- // Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
9776
- // tool_name is Write|Edit (PreToolUse payload shape), so it would be a
9777
- // silent no-op for the UserPromptSubmit payload. Registration deferred
9778
- // 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.
9779
9939
  //
9780
9940
  // Guard: only register when the context-monitor file exists and the node
9781
9941
  // runner is available — same guards as the SessionStart path above.
9782
9942
  const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
9783
9943
  if (codexNodeRunner && fs.existsSync(contextMonitorFile)) {
9784
- for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) {
9944
+ for (const codexEvent of CODEX_EXTENDED_HOOK_EVENTS) {
9785
9945
  const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, {
9786
9946
  absoluteRunner: codexNodeRunner,
9787
9947
  platform: process.platform,
@@ -9793,7 +9953,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9793
9953
  }
9794
9954
  }
9795
9955
  } else if (!codexNodeRunner) {
9796
- 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.`);
9797
9957
  }
9798
9958
  // ── end Codex extended hook events ────────────────────────────────────
9799
9959
  }
@@ -9858,16 +10018,20 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9858
10018
  }
9859
10019
 
9860
10020
  if (plan.installSurface === 'cursor-hooks-json') {
9861
- // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
9862
- // Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
9863
- 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
+ });
9864
10028
  if (cursorHookResult.changed) {
9865
- 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)`);
9866
10030
  } else {
9867
10031
  console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
9868
10032
  }
9869
10033
  // Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
9870
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
10034
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9871
10035
  persistActiveProfileMarker();
9872
10036
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9873
10037
  }
@@ -9885,7 +10049,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9885
10049
  writeClineArtifacts(targetDir, isGlobal);
9886
10050
  // Re-run the manifest pass: these artifacts are written *after* the earlier
9887
10051
  // writeManifest() call, so a second pass is needed to hash-track them.
9888
- writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
10052
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode, scope: isGlobal ? 'global' : 'local' });
9889
10053
  persistActiveProfileMarker();
9890
10054
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9891
10055
  }
@@ -9900,9 +10064,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9900
10064
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
9901
10065
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
9902
10066
  // settings.json. Global installs and all other runtimes continue to use settings.json.
9903
- const isLocalClaude = (runtime === 'claude' && !isGlobal);
9904
- const settingsFileName = isLocalClaude ? 'settings.local.json' : 'settings.json';
9905
- 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);
9906
10075
 
9907
10076
  // #338 migration: if a prior local Claude install wrote GSD-shaped entries to settings.json,
9908
10077
  // relocate them to settings.local.json and clear them from the shared file in the same run.
@@ -10085,7 +10254,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10085
10254
  // installAllRuntimes can register it at finalize time when the user opts
10086
10255
  // in (#2795). Computed here (not in finishInstall) so the same buildHookCommand
10087
10256
  // / localCmd resolution logic is shared with the other JS hooks.
10088
- const updateBannerCommand = isOpencode || isKilo
10257
+ const updateBannerCommand = _hostBehaviors(runtime).skipUpdateBannerCommand
10089
10258
  ? null
10090
10259
  : (isGlobal
10091
10260
  ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
@@ -10168,11 +10337,11 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10168
10337
  /**
10169
10338
  * Apply statusline config, then print completion message
10170
10339
  */
10171
- 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 = {}) {
10172
10341
  const { isOpencode, isKilo, isCodex, isCopilot, isAntigravity, isCursor, isWindsurf, isAugment, isTrae, isQwen, isHermes, isCodebuddy, isCline, isKimi } = runtimeFlags(runtime);
10173
10342
  const plan = resolveInstallPlan(runtime);
10174
10343
 
10175
- if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
10344
+ if (shouldInstallStatusline && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
10176
10345
  if (!isGlobal && !forceStatusline) {
10177
10346
  // Local installs skip statusLine by default: repo settings.json takes precedence over
10178
10347
  // profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -10198,7 +10367,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10198
10367
  // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
10199
10368
  // cline either lack the surface or use a different config schema.
10200
10369
  const { shouldInstallBanner, bannerCommand } = bannerOpts;
10201
- if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
10370
+ if (shouldInstallBanner && settings && plan.writesSharedSettings && !_hostBehaviors(runtime).skipSettingsUi) {
10202
10371
  if (!bannerCommand) {
10203
10372
  console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
10204
10373
  } else {
@@ -10227,7 +10396,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10227
10396
  // Merges GSD-owned entries non-destructively (preserves existing user permissions).
10228
10397
  // Scoped to Claude only: antigravity/qwen/hermes/codebuddy also write
10229
10398
  // settings.json but use different runtimes and do not use these permission strings.
10230
- if (runtime === 'claude') {
10399
+ if (_hostBehaviors(runtime).permissionsSchema === 'claude') {
10231
10400
  mergeClaudePermissions(settings);
10232
10401
  }
10233
10402
 
@@ -10260,7 +10429,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10260
10429
  // chat model instead of pinning the resolved model. See #1156 (default-to-omit
10261
10430
  // intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern
10262
10431
  // on configureOpencodePermissions above.
10263
- if (runtime !== 'claude' && !process.env.GSD_TEST_MODE) {
10432
+ if (!_hostBehaviors(runtime).nativeModelAliases && !process.env.GSD_TEST_MODE) {
10264
10433
  const gsdDir = path.join(os.homedir(), '.gsd');
10265
10434
  const defaultsPath = path.join(gsdDir, 'defaults.json');
10266
10435
  try {
@@ -10302,7 +10471,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10302
10471
  // Restart is required for CC to pick up newly-installed skills, and the
10303
10472
  // slash-menu surface depends on CC version — so the instruction needs to
10304
10473
  // cover both invocation paths to avoid #2957-style "no commands appear".
10305
- if (runtime === 'claude' && isGlobal) {
10474
+ if (_hostBehaviors(runtime).skillsGlobalOnboarding && isGlobal) {
10306
10475
  console.log(`
10307
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.
10308
10477
 
@@ -10401,10 +10570,11 @@ const runtimeMap = {
10401
10570
  '12': 'opencode',
10402
10571
  '13': 'qwen',
10403
10572
  '14': 'trae',
10404
- '15': 'windsurf'
10573
+ '15': 'windsurf',
10574
+ '16': 'zcode'
10405
10575
  };
10406
- const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
10407
- const ALL_RUNTIMES_OPTION = '16';
10576
+ const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf', 'zcode'];
10577
+ const ALL_RUNTIMES_OPTION = '17';
10408
10578
 
10409
10579
  /**
10410
10580
  * Build the runtime-selection prompt text shown by the interactive installer.
@@ -10427,7 +10597,8 @@ function buildRuntimePromptText() {
10427
10597
  ${cyan}13${reset}) Qwen Code ${dim}(~/.qwen)${reset}
10428
10598
  ${cyan}14${reset}) Trae ${dim}(~/.trae)${reset}
10429
10599
  ${cyan}15${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
10430
- ${cyan}16${reset}) All
10600
+ ${cyan}16${reset}) ZCode ${dim}(~/.zcode)${reset}
10601
+ ${cyan}17${reset}) All
10431
10602
 
10432
10603
  ${dim}Select multiple: 1,2,6 or 1 2 6${reset}
10433
10604
  `;
@@ -10459,7 +10630,7 @@ function parseRuntimeInput(answer) {
10459
10630
  }
10460
10631
  }
10461
10632
 
10462
- return selected.length > 0 ? selected : ['claude'];
10633
+ return selected.length > 0 ? selected : [DEFAULT_RUNTIME];
10463
10634
  }
10464
10635
 
10465
10636
  function promptRuntime(callback) {
@@ -11078,7 +11249,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
11078
11249
  throw error;
11079
11250
  }
11080
11251
 
11081
- const statuslineRuntimes = ['claude'];
11252
+ const statuslineRuntimes = [DEFAULT_RUNTIME];
11082
11253
  const primaryStatuslineResult = results.find(r => statuslineRuntimes.includes(r.runtime));
11083
11254
 
11084
11255
  const finalize = (shouldInstallStatusline, shouldInstallBanner) => {
@@ -11175,6 +11346,13 @@ module.exports = {
11175
11346
  generateCodexAgentToml,
11176
11347
  cleanupCodexSkillMetadataSidecars,
11177
11348
  cleanupWindsurfLegacyDevinSkills,
11349
+ cleanupMovedSkillsOldLocation,
11350
+ _resolveMovedSkillsOldDir,
11351
+ _resolveSkillsRootDir,
11352
+ codexBareAgentsHasOnlyKnownScalars,
11353
+ extractCodexUserAgentsScalars,
11354
+ spliceCodexAgentsScalars,
11355
+ CODEX_EXTENDED_HOOK_EVENTS,
11178
11356
  generateCodexConfigBlock,
11179
11357
  stripGsdFromCodexConfig,
11180
11358
  migrateCodexHooksMapFormat,
@@ -11194,6 +11372,9 @@ module.exports = {
11194
11372
  install,
11195
11373
  installAllRuntimes,
11196
11374
  uninstall,
11375
+ // #2086 — host-behavior resolution + the #338 privacy fail-safe floor (exported for tests)
11376
+ _resolveHostBehaviors,
11377
+ FALLBACK_HOST_BEHAVIORS,
11197
11378
  convertSlashCommandsToCodexSkillMentions,
11198
11379
  convertClaudeCommandToCodexSkill,
11199
11380
  convertClaudeCommandToKimiSkill,
@@ -11258,6 +11439,11 @@ module.exports = {
11258
11439
  mergeGsdAgentsMd,
11259
11440
  GSD_CURSOR_SESSION_HOOK_SCRIPT,
11260
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,
11261
11447
  GSD_CURSOR_HOOK_MARKER,
11262
11448
  buildCursorHookEntry,
11263
11449
  isManagedCursorHookEntry,
@@ -11362,7 +11548,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11362
11548
  console.error(` ${yellow}--uninstall requires --global or --local${reset}`);
11363
11549
  process.exit(1);
11364
11550
  }
11365
- const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : ['claude'];
11551
+ const runtimes = selectedRuntimes.length > 0 ? selectedRuntimes : [DEFAULT_RUNTIME];
11366
11552
  for (const runtime of runtimes) {
11367
11553
  uninstall(hasGlobal, runtime);
11368
11554
  }
@@ -11374,12 +11560,12 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11374
11560
  }
11375
11561
  } else if (hasGlobal || hasLocal) {
11376
11562
  // Default to Claude if no runtime specified but location is
11377
- installAllRuntimes(['claude'], hasGlobal, false);
11563
+ installAllRuntimes([DEFAULT_RUNTIME], hasGlobal, false);
11378
11564
  } else {
11379
11565
  // Interactive
11380
11566
  if (!process.stdin.isTTY) {
11381
11567
  console.log(` ${yellow}Non-interactive terminal detected, defaulting to Claude Code global install${reset}\n`);
11382
- installAllRuntimes(['claude'], true, false);
11568
+ installAllRuntimes([DEFAULT_RUNTIME], true, false);
11383
11569
  } else {
11384
11570
  promptRuntime((runtimes) => {
11385
11571
  promptLocation(runtimes);