@opengsd/gsd-core 1.3.1 → 1.4.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.claude-plugin/plugin.json +23 -0
  2. package/GEMINI.md +53 -0
  3. package/agents/gsd-advisor-researcher.md +1 -20
  4. package/agents/gsd-ai-researcher.md +2 -21
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-code-reviewer.md +1 -1
  7. package/agents/gsd-domain-researcher.md +2 -21
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-eval-planner.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-framework-selector.md +1 -1
  12. package/agents/gsd-nyquist-auditor.md +1 -1
  13. package/agents/gsd-pattern-mapper.md +1 -1
  14. package/agents/gsd-phase-researcher.md +92 -166
  15. package/agents/gsd-planner.md +9 -36
  16. package/agents/gsd-project-researcher.md +62 -141
  17. package/agents/gsd-security-auditor.md +1 -1
  18. package/agents/gsd-ui-auditor.md +1 -1
  19. package/agents/gsd-ui-checker.md +1 -1
  20. package/agents/gsd-ui-researcher.md +3 -22
  21. package/agents/gsd-user-profiler.md +1 -1
  22. package/agents/gsd-verifier.md +8 -2
  23. package/bin/install.js +1977 -339
  24. package/commands/gsd/autonomous.md +2 -0
  25. package/commands/gsd/execute-phase.md +2 -0
  26. package/commands/gsd/graphify.md +11 -6
  27. package/commands/gsd/import.md +6 -2
  28. package/commands/gsd/plan-phase.md +4 -2
  29. package/commands/gsd/progress.md +1 -0
  30. package/commands/gsd/stats.md +1 -0
  31. package/commands/gsd/update.md +3 -2
  32. package/gemini-extension.json +6 -0
  33. package/gsd-core/bin/check-latest-version.cjs +61 -6
  34. package/gsd-core/bin/gsd-tools.cjs +238 -32
  35. package/gsd-core/bin/lib/check-command-router.cjs +1 -0
  36. package/gsd-core/bin/lib/cli-exit.cjs +42 -0
  37. package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
  38. package/gsd-core/bin/lib/commands.cjs +5 -4
  39. package/gsd-core/bin/lib/config.cjs +28 -4
  40. package/gsd-core/bin/lib/core.cjs +72 -28
  41. package/gsd-core/bin/lib/graphify.cjs +2 -2
  42. package/gsd-core/bin/lib/init-command-router.cjs +2 -2
  43. package/gsd-core/bin/lib/init.cjs +19 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +58 -0
  45. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  46. package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +1 -1
  47. package/gsd-core/bin/lib/intel.cjs +3 -20
  48. package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
  49. package/gsd-core/bin/lib/phase.cjs +3 -3
  50. package/gsd-core/bin/lib/research-provider.cjs +137 -0
  51. package/gsd-core/bin/lib/research-store.cjs +167 -0
  52. package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -9
  54. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +56 -0
  55. package/gsd-core/bin/lib/runtime-homes.cjs +32 -11
  56. package/gsd-core/bin/lib/security.cjs +73 -0
  57. package/gsd-core/bin/lib/shell-command-projection.cjs +9 -0
  58. package/gsd-core/bin/lib/surface.cjs +54 -11
  59. package/gsd-core/bin/lib/validate.cjs +2 -2
  60. package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
  61. package/gsd-core/bin/lib/verification.cjs +193 -0
  62. package/gsd-core/bin/lib/verify.cjs +2 -2
  63. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
  64. package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
  65. package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
  66. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  67. package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
  68. package/gsd-core/references/planner-load-graph-context.md +36 -0
  69. package/gsd-core/references/planning-config.md +3 -1
  70. package/gsd-core/references/research-documentation-lookup.md +29 -0
  71. package/gsd-core/references/research-philosophy.md +29 -0
  72. package/gsd-core/references/research-verification-protocol.md +27 -0
  73. package/gsd-core/workflows/execute-phase.md +19 -8
  74. package/gsd-core/workflows/help/modes/full.md +4 -3
  75. package/gsd-core/workflows/ingest-docs.md +3 -2
  76. package/gsd-core/workflows/plan-phase.md +14 -10
  77. package/gsd-core/workflows/plan-review-convergence.md +3 -3
  78. package/gsd-core/workflows/review.md +24 -7
  79. package/gsd-core/workflows/ship.md +5 -8
  80. package/gsd-core/workflows/spec-phase.md +2 -1
  81. package/gsd-core/workflows/update.md +34 -6
  82. package/hooks/dist/gsd-config-reload.js +133 -0
  83. package/hooks/dist/gsd-context-monitor.js +1 -1
  84. package/hooks/dist/gsd-cursor-post-tool.js +75 -0
  85. package/hooks/dist/gsd-cursor-session-start.js +52 -0
  86. package/hooks/dist/gsd-workflow-guard.js +1 -0
  87. package/hooks/dist/gsd-worktree-path-guard.js +1 -1
  88. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  89. package/hooks/gsd-config-reload.js +133 -0
  90. package/hooks/gsd-context-monitor.js +1 -1
  91. package/hooks/gsd-cursor-post-tool.js +75 -0
  92. package/hooks/gsd-cursor-session-start.js +52 -0
  93. package/hooks/gsd-workflow-guard.js +1 -0
  94. package/hooks/gsd-worktree-path-guard.js +1 -1
  95. package/hooks/hooks.json +69 -0
  96. package/hooks/managed-hooks-registry.cjs +3 -0
  97. package/package.json +8 -1
  98. package/scripts/affected-tests-lib.cjs +3 -2
  99. package/scripts/build-hooks.js +7 -0
  100. package/scripts/changeset/cli.cjs +226 -28
  101. package/scripts/changeset/lint.cjs +5 -4
  102. package/scripts/changeset/new.cjs +4 -4
  103. package/scripts/check-alias-drift.cjs +77 -71
  104. package/scripts/check-env.cjs +185 -179
  105. package/scripts/check-npm-integrity.cjs +115 -109
  106. package/scripts/ci-guard-runner.cjs +11 -5
  107. package/scripts/ci-prepare-test-scope.cjs +27 -22
  108. package/scripts/ci-rebase-check.cjs +46 -45
  109. package/scripts/ci-test-scope.cjs +126 -22
  110. package/scripts/diff-touches-shipped-paths.cjs +52 -44
  111. package/scripts/gen-inventory-manifest.cjs +38 -32
  112. package/scripts/gen-research-agents.cjs +276 -0
  113. package/scripts/issue-dedupe.cjs +278 -0
  114. package/scripts/lib/cli-exit.cjs +56 -0
  115. package/scripts/lint-command-contract.cjs +28 -22
  116. package/scripts/lint-descriptions.cjs +32 -28
  117. package/scripts/lint-docs-required.cjs +4 -4
  118. package/scripts/lint-legacy-dir-name.cjs +56 -52
  119. package/scripts/lint-pr-check-project-dir.cjs +3 -1
  120. package/scripts/lint-shell-command-projection-drift.cjs +27 -22
  121. package/scripts/lint-skill-deps.cjs +31 -26
  122. package/scripts/lint-test-file-count.allowlist.json +2 -0
  123. package/scripts/lint-test-file-count.cjs +5 -4
  124. package/scripts/mutation-matrix.cjs +6 -3
  125. package/scripts/prompt-injection-scan.sh +1 -1
  126. package/scripts/release-notes/discord-release-summary.cjs +373 -0
  127. package/scripts/release-notes/format-github-release-notes.cjs +8 -3
  128. package/scripts/release-tarball-smoke.cjs +6 -4
  129. package/scripts/research-profiles.cjs +149 -0
  130. package/scripts/run-affected-tests.cjs +2 -1
  131. package/scripts/run-cross-platform-tests.cjs +11 -7
  132. package/scripts/run-tests.cjs +8 -7
  133. package/scripts/strip-prose-atrefs.cjs +1 -1
  134. package/scripts/sync-manifest-versions.cjs +119 -0
  135. package/scripts/sync-runtime-launcher.cjs +0 -3
  136. package/scripts/verify-npm-publish.cjs +14 -26
package/bin/install.js CHANGED
@@ -30,7 +30,13 @@ const {
30
30
  } = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs'));
31
31
  const {
32
32
  resolveAntigravityGlobalDir,
33
+ getGlobalConfigDir,
33
34
  } = require('../gsd-core/bin/lib/runtime-homes.cjs');
35
+ const {
36
+ applyWorktreeBaseRef,
37
+ readBaseRefFromSettings,
38
+ } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
39
+ const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
34
40
 
35
41
  /**
36
42
  * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
@@ -91,10 +97,112 @@ function isCodexHooksFeatureKey(key) {
91
97
  return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
92
98
  }
93
99
 
100
+ // #768 \u2014 Claude Code permissions.allow / permissions.deny entries.
101
+ // Pre-populated during Claude installs to eliminate first-run approval friction
102
+ // for gsd-core's own known-safe tool calls, and to add defense-in-depth deny
103
+ // entries for common credential files.
104
+ //
105
+ // Format: each string uses Claude Code's documented permission rule syntax \u2014
106
+ // "Tool(pattern)" e.g. "Bash(npx gsd-core *)", "Read(.planning/*)"
107
+ // "Tool" (bare tool name, no pattern)
108
+ //
109
+ // Merge policy: additive, non-destructive \u2014 existing user entries are preserved;
110
+ // GSD entries are appended only when not already present (idempotent).
111
+ const GSD_CLAUDE_ALLOW_PERMISSIONS = Object.freeze([
112
+ 'Bash(npx gsd-core *)',
113
+ 'Read(.planning/*)',
114
+ 'Write(.planning/*)',
115
+ 'Read(STATE.md)',
116
+ 'Write(STATE.md)',
117
+ ]);
118
+ const GSD_CLAUDE_DENY_PERMISSIONS = Object.freeze([
119
+ 'Read(.env)',
120
+ 'Read(.env.*)',
121
+ 'Read(.secrets)',
122
+ ]);
123
+
124
+ /**
125
+ * Merge GSD-owned permission entries into a Claude Code settings object.
126
+ *
127
+ * Additive and idempotent: existing allow/deny entries are preserved; GSD
128
+ * entries are appended only if not already present. No other permission sub-keys
129
+ * (ask, disableBypassPermissionsMode, etc.) are touched.
130
+ *
131
+ * Defensive: if settings is not a plain object, returns immediately without
132
+ * throwing. If permissions.allow / permissions.deny exist but are not arrays
133
+ * (malformed settings), they are replaced with valid arrays.
134
+ *
135
+ * @param {object} settings - The parsed settings.json object to mutate in-place.
136
+ */
137
+ function mergeClaudePermissions(settings) {
138
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) return;
139
+
140
+ if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
141
+ settings.permissions = {};
142
+ }
143
+
144
+ if (!Array.isArray(settings.permissions.allow)) {
145
+ settings.permissions.allow = [];
146
+ }
147
+ if (!Array.isArray(settings.permissions.deny)) {
148
+ settings.permissions.deny = [];
149
+ }
150
+
151
+ for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) {
152
+ if (!settings.permissions.allow.includes(entry)) {
153
+ settings.permissions.allow.push(entry);
154
+ }
155
+ }
156
+ for (const entry of GSD_CLAUDE_DENY_PERMISSIONS) {
157
+ if (!settings.permissions.deny.includes(entry)) {
158
+ settings.permissions.deny.push(entry);
159
+ }
160
+ }
161
+ }
162
+
94
163
  // Copilot instructions marker constants
95
164
  const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by gsd-core installer -->';
96
165
  const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
97
166
 
167
+ // #786 \u2014 GitHub Copilot CLI lifecycle hook constants.
168
+ // Copilot reads hook configs from <config>/hooks/*.json (repo scope: .github/hooks/,
169
+ // user scope: ~/.copilot/hooks/) with the shape { version, hooks: { <event>: [...] } }.
170
+ // Events use camelCase (sessionStart, preToolUse, postToolUse, ...). A `command`
171
+ // hook runs an INLINE shell command (bash / powershell), so the GSD hook is fully
172
+ // self-contained \u2014 there is no separate hook script to install, and therefore
173
+ // nothing that can dangle if a script copy is skipped. See
174
+ // https://docs.github.com/en/copilot/reference/hooks-configuration
175
+ const GSD_COPILOT_HOOK_FILE = 'gsd-session.json';
176
+ // Copilot parses a command hook's stdout as the hook-output JSON. For sessionStart
177
+ // the schema is `{ additionalContext?: string }` (the text is prepended to the
178
+ // session as context). So the hook must emit that JSON envelope — not bare text.
179
+ // The two messages contain no JSON-special characters, so they embed verbatim.
180
+ const GSD_COPILOT_SESSION_MSG_PRESENT =
181
+ 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.';
182
+ const GSD_COPILOT_SESSION_MSG_ABSENT =
183
+ 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.';
184
+ const GSD_COPILOT_SESSION_HOOK_BASH =
185
+ 'if [ -f .planning/STATE.md ]; then ' +
186
+ `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` +
187
+ `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`;
188
+ const GSD_COPILOT_SESSION_HOOK_PWSH =
189
+ 'if (Test-Path .planning/STATE.md) ' +
190
+ `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
191
+ `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
192
+
193
+ // #777 — Cursor CLI lifecycle hook constants.
194
+ // Cursor reads hook configs from <project-root>/.cursor/hooks.json (local) or
195
+ // ~/.cursor/hooks.json (global) with the shape { version: 1, hooks: { <event>: [...] } }.
196
+ // Events use camelCase: sessionStart, postToolUse, preToolUse, etc.
197
+ // A `command` hook entry runs an external script. GSD registers two managed hooks:
198
+ // sessionStart → gsd-cursor-session-start.js (context injection)
199
+ // postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
200
+ // Cursor docs: https://cursor.com/docs/hooks
201
+ const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
202
+ const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
203
+ // Marker comment embedded in managed hook entries so GSD can find+remove them.
204
+ const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
205
+
98
206
  // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
99
207
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
100
208
  const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
@@ -403,218 +511,6 @@ function getConfigDirFromHome(runtime, isGlobal) {
403
511
  return "'.claude'";
404
512
  }
405
513
 
406
- /**
407
- * Get the global config directory for OpenCode
408
- * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
409
- * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
410
- */
411
- function getOpencodeGlobalDir() {
412
- // 1. Explicit OPENCODE_CONFIG_DIR env var
413
- if (process.env.OPENCODE_CONFIG_DIR) {
414
- return expandTilde(process.env.OPENCODE_CONFIG_DIR);
415
- }
416
-
417
- // 2. OPENCODE_CONFIG env var (use its directory)
418
- if (process.env.OPENCODE_CONFIG) {
419
- return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
420
- }
421
-
422
- // 3. XDG_CONFIG_HOME/opencode
423
- if (process.env.XDG_CONFIG_HOME) {
424
- return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
425
- }
426
-
427
- // 4. Default: ~/.config/opencode (XDG default)
428
- return path.join(os.homedir(), '.config', 'opencode');
429
- }
430
-
431
- /**
432
- * Get the global config directory for Kilo
433
- * Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
434
- * Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
435
- */
436
- function getKiloGlobalDir() {
437
- // 1. Explicit KILO_CONFIG_DIR env var
438
- if (process.env.KILO_CONFIG_DIR) {
439
- return expandTilde(process.env.KILO_CONFIG_DIR);
440
- }
441
-
442
- // 2. KILO_CONFIG env var (use its directory)
443
- if (process.env.KILO_CONFIG) {
444
- return path.dirname(expandTilde(process.env.KILO_CONFIG));
445
- }
446
-
447
- // 3. XDG_CONFIG_HOME/kilo
448
- if (process.env.XDG_CONFIG_HOME) {
449
- return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
450
- }
451
-
452
- // 4. Default: ~/.config/kilo (XDG default)
453
- return path.join(os.homedir(), '.config', 'kilo');
454
- }
455
-
456
- /**
457
- * Get the global config directory for a runtime
458
- * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
459
- * @param {string|null} explicitDir - Explicit directory from --config-dir flag
460
- */
461
- function getGlobalDir(runtime, explicitDir = null) {
462
- if (runtime === 'opencode') {
463
- // For OpenCode, --config-dir overrides env vars
464
- if (explicitDir) {
465
- return expandTilde(explicitDir);
466
- }
467
- return getOpencodeGlobalDir();
468
- }
469
-
470
- if (runtime === 'kilo') {
471
- // For Kilo, --config-dir overrides env vars
472
- if (explicitDir) {
473
- return expandTilde(explicitDir);
474
- }
475
- return getKiloGlobalDir();
476
- }
477
-
478
- if (runtime === 'gemini') {
479
- // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
480
- if (explicitDir) {
481
- return expandTilde(explicitDir);
482
- }
483
- if (process.env.GEMINI_CONFIG_DIR) {
484
- return expandTilde(process.env.GEMINI_CONFIG_DIR);
485
- }
486
- return path.join(os.homedir(), '.gemini');
487
- }
488
-
489
- if (runtime === 'codex') {
490
- // Codex: --config-dir > CODEX_HOME > ~/.codex
491
- if (explicitDir) {
492
- return expandTilde(explicitDir);
493
- }
494
- if (process.env.CODEX_HOME) {
495
- return expandTilde(process.env.CODEX_HOME);
496
- }
497
- return path.join(os.homedir(), '.codex');
498
- }
499
-
500
- if (runtime === 'copilot') {
501
- // Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
502
- if (explicitDir) {
503
- return expandTilde(explicitDir);
504
- }
505
- if (process.env.COPILOT_CONFIG_DIR) {
506
- return expandTilde(process.env.COPILOT_CONFIG_DIR);
507
- }
508
- return path.join(os.homedir(), '.copilot');
509
- }
510
-
511
- if (runtime === 'antigravity') {
512
- // Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
513
- // ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
514
- if (explicitDir) {
515
- return expandTilde(explicitDir);
516
- }
517
- return resolveAntigravityGlobalDir();
518
- }
519
-
520
- if (runtime === 'cursor') {
521
- // Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
522
- if (explicitDir) {
523
- return expandTilde(explicitDir);
524
- }
525
- if (process.env.CURSOR_CONFIG_DIR) {
526
- return expandTilde(process.env.CURSOR_CONFIG_DIR);
527
- }
528
- return path.join(os.homedir(), '.cursor');
529
- }
530
-
531
- if (runtime === 'windsurf') {
532
- // Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
533
- if (explicitDir) {
534
- return expandTilde(explicitDir);
535
- }
536
- if (process.env.WINDSURF_CONFIG_DIR) {
537
- return expandTilde(process.env.WINDSURF_CONFIG_DIR);
538
- }
539
- return path.join(os.homedir(), '.codeium', 'windsurf');
540
- }
541
-
542
- if (runtime === 'augment') {
543
- // Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
544
- if (explicitDir) {
545
- return expandTilde(explicitDir);
546
- }
547
- if (process.env.AUGMENT_CONFIG_DIR) {
548
- return expandTilde(process.env.AUGMENT_CONFIG_DIR);
549
- }
550
- return path.join(os.homedir(), '.augment');
551
- }
552
- if (runtime === 'trae') {
553
- // Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
554
- if (explicitDir) {
555
- return expandTilde(explicitDir);
556
- }
557
- if (process.env.TRAE_CONFIG_DIR) {
558
- return expandTilde(process.env.TRAE_CONFIG_DIR);
559
- }
560
- return path.join(os.homedir(), '.trae');
561
- }
562
-
563
- if (runtime === 'qwen') {
564
- if (explicitDir) {
565
- return expandTilde(explicitDir);
566
- }
567
- if (process.env.QWEN_CONFIG_DIR) {
568
- return expandTilde(process.env.QWEN_CONFIG_DIR);
569
- }
570
- return path.join(os.homedir(), '.qwen');
571
- }
572
-
573
- if (runtime === 'hermes') {
574
- // Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
575
- // Honors HERMES_HOME which Hermes users set for profile mode / Docker
576
- // deploys (docs: https://hermes-agent.nousresearch.com/docs).
577
- if (explicitDir) {
578
- return expandTilde(explicitDir);
579
- }
580
- if (process.env.HERMES_HOME) {
581
- return expandTilde(process.env.HERMES_HOME);
582
- }
583
- return path.join(os.homedir(), '.hermes');
584
- }
585
-
586
- if (runtime === 'codebuddy') {
587
- // CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
588
- if (explicitDir) {
589
- return expandTilde(explicitDir);
590
- }
591
- if (process.env.CODEBUDDY_CONFIG_DIR) {
592
- return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
593
- }
594
- return path.join(os.homedir(), '.codebuddy');
595
- }
596
-
597
- if (runtime === 'cline') {
598
- // Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
599
- if (explicitDir) {
600
- return expandTilde(explicitDir);
601
- }
602
- if (process.env.CLINE_CONFIG_DIR) {
603
- return expandTilde(process.env.CLINE_CONFIG_DIR);
604
- }
605
- return path.join(os.homedir(), '.cline');
606
- }
607
-
608
- // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
609
- if (explicitDir) {
610
- return expandTilde(explicitDir);
611
- }
612
- if (process.env.CLAUDE_CONFIG_DIR) {
613
- return expandTilde(process.env.CLAUDE_CONFIG_DIR);
614
- }
615
- return path.join(os.homedir(), '.claude');
616
- }
617
-
618
514
  const banner = '\n' +
619
515
  cyan + ' ██████╗ ███████╗██████╗\n' +
620
516
  ' ██╔════╝ ██╔════╝██╔══██╗\n' +
@@ -683,20 +579,10 @@ if (hasUninstall) {
683
579
 
684
580
  // Show help if requested
685
581
  if (hasHelp) {
686
- 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}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex 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 — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 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 Gemini globally${reset}\n npx ${pkg.name} --gemini --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 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 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 / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / 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`);
582
+ 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}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex 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 — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 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 Gemini globally${reset}\n npx ${pkg.name} --gemini --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 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 / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / 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`);
687
583
  process.exit(0);
688
584
  }
689
585
 
690
- /**
691
- * Expand ~ to home directory (shell doesn't expand in env vars passed to node)
692
- */
693
- function expandTilde(filePath) {
694
- if (filePath && filePath.startsWith('~/')) {
695
- return path.join(os.homedir(), filePath.slice(2));
696
- }
697
- return filePath;
698
- }
699
-
700
586
  /**
701
587
  * Compute the path prefix used for `@file` references in installed command/skill
702
588
  * markdown. For global installs into a runtime config dir under $HOME, we
@@ -991,9 +877,31 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
991
877
  return { content: updated, changed };
992
878
  }
993
879
 
994
- function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
880
+ /**
881
+ * Generic reconcile helper: ensure hooks.json contains exactly one managed GSD
882
+ * hook entry for `eventName`, while preserving all user-owned entries.
883
+ *
884
+ * Supports both known hooks.json shapes:
885
+ * 1) { "<EventName>": [...] }
886
+ * 2) { "hooks": { "<EventName>": [...] } }
887
+ *
888
+ * @param {string} targetDir - Codex config dir (e.g. ~/.codex or <project>/.codex).
889
+ * @param {string} eventName - Codex hook event name (e.g. 'SessionStart', 'Stop').
890
+ * @param {{ managedCommand?: string|null, commandWindows?: string|null, matcher?: string|null, timeout?: number|null }} opts
891
+ * managedCommand: POSIX hook command string to register, or null to remove.
892
+ * commandWindows: Windows .cmd shim path to emit as `commandWindows` field
893
+ * (#772). When provided, Codex uses this path on Windows and `managedCommand`
894
+ * on POSIX without needing per-platform config regeneration.
895
+ * matcher: optional Codex MatcherGroup pattern (e.g. 'Bash|Edit|Write').
896
+ * timeout: optional timeout in seconds.
897
+ * @returns {{ changed: boolean, wrote: boolean, path: string }}
898
+ */
899
+ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
995
900
  const hooksJsonPath = path.join(targetDir, 'hooks.json');
996
901
  const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
902
+ const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
903
+ const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
904
+ const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
997
905
  let parsed = {};
998
906
  let currentContent = null;
999
907
  if (fs.existsSync(hooksJsonPath)) {
@@ -1012,22 +920,22 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1012
920
  const usesNestedHooksObject =
1013
921
  parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
1014
922
  const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
1015
- const sessionStart = Array.isArray(hookTable.SessionStart) ? hookTable.SessionStart : [];
923
+ const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
1016
924
 
1017
925
  let removedLegacy = false;
1018
- const sanitizedSessionStart = [];
1019
- for (const entry of sessionStart) {
926
+ const sanitizedEntries = [];
927
+ for (const entry of eventEntries) {
1020
928
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
1021
929
  const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
1022
930
  if (originalHooks.length === 0) {
1023
- sanitizedSessionStart.push(entry);
931
+ sanitizedEntries.push(entry);
1024
932
  continue;
1025
933
  }
1026
- const keptHooks = originalHooks.filter((hook) => {
1027
- const cmd = hook && typeof hook === 'object' ? hook.command : null;
1028
- const managed = isManagedHookCommand(cmd, {
1029
- surface: 'codex-hooks-json',
1030
- includeLegacyAliases: true,
934
+ const keptHooks = originalHooks.filter((hook) => {
935
+ const cmd = hook && typeof hook === 'object' ? hook.command : null;
936
+ const managed = isManagedHookCommand(cmd, {
937
+ surface: 'codex-hooks-json',
938
+ includeLegacyAliases: true,
1031
939
  configDir: targetDir,
1032
940
  });
1033
941
  if (managed) removedLegacy = true;
@@ -1035,24 +943,26 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1035
943
  });
1036
944
  if (keptHooks.length === 0) continue;
1037
945
  const nextEntry = { ...entry, hooks: keptHooks };
1038
- sanitizedSessionStart.push(nextEntry);
946
+ sanitizedEntries.push(nextEntry);
1039
947
  }
1040
948
 
1041
949
  if (managedCommand) {
1042
- sanitizedSessionStart.push({
1043
- hooks: [
1044
- {
1045
- type: 'command',
1046
- command: managedCommand,
1047
- },
1048
- ],
1049
- });
1050
- }
1051
-
1052
- if (sanitizedSessionStart.length > 0) {
1053
- hookTable.SessionStart = sanitizedSessionStart;
950
+ const hookEntry = { type: 'command', command: managedCommand };
951
+ // #772: emit commandWindows so Codex picks the .cmd shim on Windows and
952
+ // the POSIX command on other platforms — without requiring per-OS config
953
+ // regeneration. Sourced from HookHandlerConfig.command_windows field in
954
+ // codex-rs/config/src/hook_config.rs (alias: commandWindows).
955
+ if (commandWindows) hookEntry.commandWindows = commandWindows;
956
+ if (timeout !== undefined) hookEntry.timeout = timeout;
957
+ const newEntry = { hooks: [hookEntry] };
958
+ if (matcher !== undefined) newEntry.matcher = matcher;
959
+ sanitizedEntries.push(newEntry);
960
+ }
961
+
962
+ if (sanitizedEntries.length > 0) {
963
+ hookTable[eventName] = sanitizedEntries;
1054
964
  } else {
1055
- delete hookTable.SessionStart;
965
+ delete hookTable[eventName];
1056
966
  }
1057
967
  if (usesNestedHooksObject) parsed.hooks = hookTable;
1058
968
 
@@ -1066,6 +976,18 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1066
976
  return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
1067
977
  }
1068
978
 
979
+ /**
980
+ * Reconcile the GSD-managed SessionStart hook entry in hooks.json.
981
+ * Delegates to the generic reconcileCodexHooksJsonEvent helper.
982
+ *
983
+ * @param {string} targetDir
984
+ * @param {{ managedCommand?: string|null, commandWindows?: string|null }} opts
985
+ * @returns {{ changed: boolean, wrote: boolean, path: string }}
986
+ */
987
+ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
988
+ return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
989
+ }
990
+
1069
991
  /**
1070
992
  * Build a typed IR for the Codex hook .cmd shim used on Windows (#3426).
1071
993
  *
@@ -1147,8 +1069,14 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1147
1069
  * 2) { "hooks": { "SessionStart": [...] } }
1148
1070
  *
1149
1071
  * On Windows, writes a .cmd shim alongside the .js hook file and uses the
1150
- * .cmd path as the hook command to avoid the `bash.exe: cannot execute binary
1151
- * file` failure (#3426).
1072
+ * .cmd shim path as the hook command to avoid the `bash.exe: cannot execute
1073
+ * binary file` failure (#3426).
1074
+ *
1075
+ * #772: also emits `commandWindows` in the hook entry so that a
1076
+ * cross-platform hooks.json works on both POSIX and Windows without
1077
+ * requiring per-OS regeneration. Codex dispatches `commandWindows` on
1078
+ * Windows and `command` on other platforms (HookHandlerConfig in
1079
+ * codex-rs/config/src/hook_config.rs).
1152
1080
  *
1153
1081
  * @param {string} targetDir
1154
1082
  * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
@@ -1160,7 +1088,18 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1160
1088
  const hooksJsonPath = path.join(targetDir, 'hooks.json');
1161
1089
  if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
1162
1090
 
1163
- const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
1091
+ // Normalize backslashes to forward slashes so isManagedHookCommand can
1092
+ // match stored commands against configDir on Windows CI runners where
1093
+ // path.resolve returns backslash paths but the stored command may use
1094
+ // forward slashes (or vice versa). Forward-slash paths are always valid on
1095
+ // Windows for both Node.js and Codex, so this normalization is safe for all
1096
+ // platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.)
1097
+ const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
1098
+
1099
+ // #772: compute the Windows .cmd shim path cross-platform so that
1100
+ // `commandWindows` can be emitted in hooks.json regardless of the host OS.
1101
+ // The .cmd path is always the .js script path with extension replaced.
1102
+ const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
1164
1103
 
1165
1104
  let managedCommand;
1166
1105
  if (platform === 'win32') {
@@ -1197,7 +1136,96 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1197
1136
  }
1198
1137
 
1199
1138
  if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
1200
- return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand });
1139
+
1140
+ // #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where
1141
+ // the shim was actually written. On POSIX, commandWindows is omitted to avoid
1142
+ // pointing Windows Codex at a non-existent .cmd file (the shim is only present
1143
+ // when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR).
1144
+ const commandWindows = platform === 'win32'
1145
+ ? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
1146
+ : undefined;
1147
+
1148
+ return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
1149
+ }
1150
+
1151
+ /**
1152
+ * Ensure hooks.json contains exactly one managed GSD hook entry for the given
1153
+ * Codex event, wired to gsd-context-monitor.js. Preserves user-owned entries.
1154
+ *
1155
+ * Used for the new Codex events added in #772:
1156
+ * SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open
1157
+ * Stop — post-session context headroom tracking
1158
+ * PostToolUse — mirror the Claude Code PostToolUse context monitor
1159
+ *
1160
+ * All three events are routed through gsd-context-monitor.js — the same hook
1161
+ * used for PostToolUse in the Claude Code baseline — so context-headroom
1162
+ * warnings surface at these key Codex session lifecycle moments.
1163
+ *
1164
+ * On Windows (#3426): writes a gsd-context-monitor.cmd shim alongside the .js
1165
+ * file and uses the .cmd path as the hook command — exactly the same fix as
1166
+ * SessionStart uses for gsd-check-update — to avoid the bash.exe POSIX-exec
1167
+ * failure when Codex's hook dispatcher tries to run node.exe through Git Bash.
1168
+ *
1169
+ * @param {string} targetDir
1170
+ * @param {string} eventName - One of 'SubagentStart', 'Stop', 'PostToolUse'.
1171
+ * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
1172
+ * @returns {{ changed: boolean, wrote: boolean, path: string }}
1173
+ */
1174
+ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1175
+ const platform = opts.platform || process.platform;
1176
+ const absoluteRunner = opts.absoluteRunner || null;
1177
+ const hooksJsonPath = path.join(targetDir, 'hooks.json');
1178
+ if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
1179
+
1180
+ // Normalize backslashes to forward slashes so that isManagedHookCommand can
1181
+ // match the stored command against configDir on Windows. path.resolve on
1182
+ // Windows returns backslash paths, but when platform is not 'win32'
1183
+ // (e.g. platform: 'linux' in a test running on a Windows CI runner),
1184
+ // projectManagedHookCommand does not normalize them — producing a mismatch
1185
+ // between the stored command and the configDir-based hook-dir prefix used
1186
+ // for deduplication. Forward-slash paths are always valid on Windows (Node.js
1187
+ // and Codex both accept them), so normalizing here is safe for all platforms.
1188
+ const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
1189
+
1190
+ let managedCommand;
1191
+ if (platform === 'win32') {
1192
+ // #3426 fix pattern: on Windows, write a .cmd shim and use its path as the
1193
+ // hook command. The same bash.exe POSIX-exec failure that affects
1194
+ // gsd-check-update.js also affects gsd-context-monitor.js.
1195
+ const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
1196
+ if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
1197
+ try {
1198
+ atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
1199
+ } catch (shimWriteErr) {
1200
+ const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
1201
+ console.warn(
1202
+ ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
1203
+ `Fix the write error (permissions? disk full?) and re-run the installer.`,
1204
+ );
1205
+ return { changed: false, wrote: false, path: hooksJsonPath };
1206
+ }
1207
+ managedCommand = shimIR.hookCommand;
1208
+ } else {
1209
+ managedCommand = projectManagedHookCommand({
1210
+ absoluteRunner,
1211
+ scriptPath,
1212
+ runtime: 'codex',
1213
+ platform,
1214
+ });
1215
+ }
1216
+
1217
+ if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
1218
+ return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
1219
+ }
1220
+
1221
+ /**
1222
+ * Remove a GSD-managed event entry from hooks.json. Called during uninstall.
1223
+ *
1224
+ * @param {string} targetDir
1225
+ * @param {string} eventName
1226
+ */
1227
+ function removeCodexHooksJsonEvent(targetDir, eventName) {
1228
+ return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
1201
1229
  }
1202
1230
 
1203
1231
  function removeCodexHooksJsonSessionStart(targetDir) {
@@ -1769,11 +1797,11 @@ function getCommitAttribution(runtime) {
1769
1797
  const resolveConfigPath = runtime === 'opencode'
1770
1798
  ? resolveOpencodeConfigPath
1771
1799
  : resolveKiloConfigPath;
1772
- const config = readSettings(resolveConfigPath(getGlobalDir(runtime, null)));
1800
+ const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
1773
1801
  result = (config && config.disable_ai_attribution === true) ? null : undefined;
1774
1802
  } else if (runtime === 'gemini') {
1775
1803
  // Gemini: check gemini settings.json for attribution config
1776
- const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
1804
+ const settings = readSettings(path.join(getGlobalConfigDir('gemini', explicitConfigDir), 'settings.json'));
1777
1805
  if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
1778
1806
  result = undefined;
1779
1807
  } else if (settings.attribution.commit === '') {
@@ -1783,7 +1811,7 @@ function getCommitAttribution(runtime) {
1783
1811
  }
1784
1812
  } else if (runtime === 'claude') {
1785
1813
  // Claude Code
1786
- const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
1814
+ const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
1787
1815
  if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
1788
1816
  result = undefined;
1789
1817
  } else if (settings.attribution.commit === '') {
@@ -1997,6 +2025,8 @@ function convertCopilotToolName(claudeTool) {
1997
2025
  if (claudeToCopilotTools[claudeTool]) {
1998
2026
  return claudeToCopilotTools[claudeTool];
1999
2027
  }
2028
+ // mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl;
2029
+ // add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up)
2000
2030
  // Default: lowercase
2001
2031
  return claudeTool.toLowerCase();
2002
2032
  }
@@ -2091,6 +2121,40 @@ function skillFrontmatterName(skillDirName) {
2091
2121
  return skillDirName;
2092
2122
  }
2093
2123
 
2124
+ /**
2125
+ * Qwen Code skills accept an optional numeric `priority` frontmatter field.
2126
+ * Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified
2127
+ * #778): HIGHER values sort EARLIER in the `/skills` TUI listing (omitted ≈ 0;
2128
+ * negatives sort below unset). It affects ONLY the `/skills` list order —
2129
+ * slash-command completion and the `/help` view stay alphabetical.
2130
+ *
2131
+ * We assign descending priorities to GSD's main-loop commands so the most-used
2132
+ * workflow skills surface first; utility skills are deliberately left unset
2133
+ * (default 0) and sort below.
2134
+ *
2135
+ * NOTE: the #778 issue body proposed the INVERSE numbering (plan-phase: 10,
2136
+ * utilities: 90+). The verified spec shows that would BURY the core loop below
2137
+ * utilities, so we implement the spec-correct direction (core = high) instead.
2138
+ * Keyed by command stem (skill dir is `gsd-<stem>`).
2139
+ */
2140
+ const QWEN_SKILL_PRIORITY = Object.freeze({
2141
+ 'new-project': 100,
2142
+ 'discuss-phase': 95,
2143
+ 'plan-phase': 90,
2144
+ 'execute-phase': 85,
2145
+ progress: 80,
2146
+ 'verify-work': 75,
2147
+ phase: 70,
2148
+ review: 65,
2149
+ ship: 60,
2150
+ config: 55,
2151
+ surface: 50,
2152
+ 'resume-work': 45,
2153
+ 'pause-work': 40,
2154
+ help: 35,
2155
+ update: 30,
2156
+ });
2157
+
2094
2158
  /**
2095
2159
  * Convert a Claude command (.md) to a Claude skill (SKILL.md).
2096
2160
  * Claude Code is the native format, so minimal conversion needed —
@@ -2113,6 +2177,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2113
2177
  const description = extractFrontmatterField(frontmatter, 'description') || '';
2114
2178
  const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
2115
2179
  const agent = extractFrontmatterField(frontmatter, 'agent');
2180
+ // #769: preserve context: and effort: from source command files so they
2181
+ // are emitted into the installed SKILL.md frontmatter unchanged.
2182
+ const context = extractFrontmatterField(frontmatter, 'context');
2183
+ const effort = extractFrontmatterField(frontmatter, 'effort');
2116
2184
 
2117
2185
  // Preserve allowed-tools as YAML multiline list (Claude native format)
2118
2186
  const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
@@ -2130,8 +2198,26 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2130
2198
  // Track GSD's package version so Hermes' skill_view() reports a stable
2131
2199
  // identifier per install.
2132
2200
  if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`;
2201
+ // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen
2202
+ // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but
2203
+ // we keep their output byte-stable). skillName is the `gsd-<stem>` dir name.
2204
+ if (runtime === 'qwen') {
2205
+ const stem = typeof skillName === 'string' && skillName.startsWith('gsd-')
2206
+ ? skillName.slice(4)
2207
+ : skillName;
2208
+ const priority = Object.prototype.hasOwnProperty.call(QWEN_SKILL_PRIORITY, stem)
2209
+ ? QWEN_SKILL_PRIORITY[stem]
2210
+ : undefined;
2211
+ if (typeof priority === 'number') fm += `priority: ${priority}\n`;
2212
+ }
2133
2213
  if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
2134
2214
  if (agent) fm += `agent: ${agent}\n`;
2215
+ // #769: emit context: and effort: when present so the runtime can honour
2216
+ // them natively (context: fork = isolated subagent window; effort: =
2217
+ // token-budget tier). Fields are Claude-specific; unknown frontmatter
2218
+ // fields are silently ignored by other runtimes (backward-compatible).
2219
+ if (context) fm += `context: ${context}\n`;
2220
+ if (effort) fm += `effort: ${effort}\n`;
2135
2221
  if (toolsBlock) fm += toolsBlock;
2136
2222
  fm += '---';
2137
2223
 
@@ -2384,6 +2470,29 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
2384
2470
  return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2385
2471
  }
2386
2472
 
2473
+ /**
2474
+ * Convert a Claude Code command to a Cursor 1.6 slash command (#785).
2475
+ *
2476
+ * Cursor slash commands live in `.cursor/commands/<name>.md` and are
2477
+ * plain markdown — no YAML frontmatter, no adapter header. The filename
2478
+ * becomes the command name (e.g. `gsd-help.md` → `/gsd-help`).
2479
+ *
2480
+ * Applies the same `convertClaudeToCursorMarkdown` transforms as the skill
2481
+ * converter (tool renames, brand substitution, slash-command normalisation),
2482
+ * then strips the YAML frontmatter block so only the prose body remains.
2483
+ *
2484
+ * @param {string} content raw Claude Code command markdown (may have frontmatter)
2485
+ * @param {string} _commandName the target command name (unused; present for
2486
+ * API symmetry with other converters so the runtime-artifact-layout stage
2487
+ * function can call it uniformly)
2488
+ * @returns {string} plain markdown body, no frontmatter
2489
+ */
2490
+ function convertClaudeCommandToCursorCommand(content, _commandName) {
2491
+ const converted = convertClaudeToCursorMarkdown(content);
2492
+ const { body } = extractFrontmatterAndBody(converted);
2493
+ return body.trimStart();
2494
+ }
2495
+
2387
2496
  /**
2388
2497
  * Convert Claude Code agent markdown to Cursor agent format.
2389
2498
  * Strips frontmatter fields Cursor doesn't support (color, skills),
@@ -2747,7 +2856,47 @@ function convertClaudeCommandToCodebuddySkill(content, skillName) {
2747
2856
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2748
2857
  // #2876: quote so YAML flow indicators (`[BETA] …`) don't break
2749
2858
  // CodeBuddy's frontmatter parser.
2750
- return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`;
2859
+ //
2860
+ // #789: mark user-invocable:false so the skill is NOT shown in CodeBuddy's
2861
+ // '/' menu (it defaults to true). The commands/ surface (#789) is the sole
2862
+ // '/' entry point; skills remain model-invocable background knowledge,
2863
+ // avoiding a duplicated /gsd-* entry per workflow.
2864
+ return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n${body}`;
2865
+ }
2866
+
2867
+ /**
2868
+ * Convert a Claude Code slash-command (.md) to a CodeBuddy slash-command (.md).
2869
+ *
2870
+ * CodeBuddy reads user-level slash commands from ~/.codebuddy/commands/<name>.md
2871
+ * (https://www.codebuddy.ai/docs/cli/slash-commands). The filename determines the
2872
+ * command name (gsd-help.md → /gsd-help), so the Claude-specific `name: gsd:<x>`
2873
+ * frontmatter field is dropped. CodeBuddy command frontmatter supports
2874
+ * `description` and `argument-hint`; both are preserved when present. The body is
2875
+ * brand/path-converted via convertClaudeToCodebuddyMarkdown.
2876
+ *
2877
+ * @param {string} content raw Claude command markdown
2878
+ * @param {string} commandName installed command name (e.g. 'gsd-help')
2879
+ * @returns {string}
2880
+ */
2881
+ function convertClaudeCommandToCodebuddyCommand(content, commandName) {
2882
+ const converted = convertClaudeToCodebuddyMarkdown(content);
2883
+ const { frontmatter, body } = extractFrontmatterAndBody(converted);
2884
+ let description = `Run GSD workflow ${commandName}.`;
2885
+ let argumentHint = '';
2886
+ if (frontmatter) {
2887
+ const maybeDescription = extractFrontmatterField(frontmatter, 'description');
2888
+ if (maybeDescription) description = maybeDescription;
2889
+ const maybeArgHint = extractFrontmatterField(frontmatter, 'argument-hint');
2890
+ if (maybeArgHint) argumentHint = maybeArgHint;
2891
+ }
2892
+ description = toSingleLine(description);
2893
+ const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2894
+ // #2876: quote values so YAML flow indicators (`[BETA] …`, `[name]`) don't
2895
+ // break CodeBuddy's frontmatter parser.
2896
+ const lines = ['---', `description: ${yamlQuote(shortDescription)}`];
2897
+ if (argumentHint) lines.push(`argument-hint: ${yamlQuote(toSingleLine(argumentHint))}`);
2898
+ lines.push('---', body.trimStart());
2899
+ return lines.join('\n');
2751
2900
  }
2752
2901
 
2753
2902
  function convertClaudeAgentToCodebuddyAgent(content) {
@@ -2773,9 +2922,15 @@ function convertClaudeToCliineMarkdown(content) {
2773
2922
  converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules');
2774
2923
  converted = converted.replace(/`CLAUDE\.md`/g, '`.clinerules`');
2775
2924
  converted = converted.replace(/\bCLAUDE\.md\b/g, '.clinerules');
2925
+ // Slash forms first (most specific — superset of bare forms)
2776
2926
  converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/');
2777
2927
  converted = converted.replace(/\.\/\.claude\//g, './.cline/');
2778
2928
  converted = converted.replace(/\.claude\//g, '.cline/');
2929
+ // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite
2930
+ converted = converted.replace(/~\/\.claude\b/g, '~/.cline');
2931
+ converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.cline');
2932
+ // Environment variable name rewrite
2933
+ converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR');
2779
2934
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2780
2935
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2781
2936
  converted = converted.replace(/\bClaude Code\b/g, 'Cline');
@@ -2792,17 +2947,62 @@ function convertClaudeAgentToClineAgent(content) {
2792
2947
  return `${cleanFrontmatter}\n${body}`;
2793
2948
  }
2794
2949
 
2950
+ /**
2951
+ * Convert a Claude command (.md) to a Cline skill (SKILL.md).
2952
+ * Emits ONLY name + description frontmatter per the Cline skills spec
2953
+ * (https://docs.cline.bot/customization/skills) — no allowed-tools,
2954
+ * argument-hint, agent, or other Claude-specific fields.
2955
+ * Body is hyphen-normalised then converted via convertClaudeToCliineMarkdown
2956
+ * (.claude/→.cline/, "Claude Code"→"Cline", etc.).
2957
+ * Cline uses Claude-Code-compatible tool names, so no adapter header is needed.
2958
+ * Targets ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.0.
2959
+ */
2960
+ function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cmdNames = null) {
2961
+ const { frontmatter, body } = extractFrontmatterAndBody(content);
2962
+ if (!frontmatter) return content;
2963
+
2964
+ // Hyphen-normalise /gsd:<cmd> → gsd-<cmd> references in the body, then
2965
+ // apply Cline-specific markdown rewrites (.claude/→.cline/, etc.).
2966
+ const names = cmdNames || readGsdCommandNames();
2967
+ const normalizedBody = transformContentToHyphen(body, names);
2968
+ const clineBody = convertClaudeToCliineMarkdown(normalizedBody);
2969
+
2970
+ // Extract description; fall back to a generic string if absent.
2971
+ let description = extractFrontmatterField(frontmatter, 'description');
2972
+ if (!description) description = `Run GSD workflow ${skillName}.`;
2973
+ description = toSingleLine(description);
2974
+ // Cline documented max is 1024 code points (not UTF-16 code units).
2975
+ // Use Array.from to iterate by code point so that multibyte characters
2976
+ // (e.g. emoji, astral-plane chars) are never split, which would produce
2977
+ // lone surrogates and corrupt the YAML output.
2978
+ const cp = Array.from(description);
2979
+ const shortDescription = cp.length > 1024
2980
+ ? cp.slice(0, 1021).join('') + '...'
2981
+ : description;
2982
+
2983
+ const fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---`;
2984
+ return `${fm}\n${clineBody}`;
2985
+ }
2986
+
2795
2987
  // ── End Cline converters ─────────────────────────────────────────────────────
2796
2988
 
2797
2989
  function convertSlashCommandsToCodexSkillMentions(content) {
2798
- // Convert colon-style skill invocations to Codex $ prefix
2990
+ // Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below).
2799
2991
  let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => {
2800
2992
  return `$gsd-${String(commandName).toLowerCase()}`;
2801
2993
  });
2802
2994
  // Convert hyphen-style command references (workflow output) to Codex $ prefix.
2803
- // Negative lookbehind excludes file paths like bin/gsd-tools.cjs where
2804
- // the slash is preceded by a word char, dot, or another slash.
2805
- converted = converted.replace(/(?<![a-zA-Z0-9./])\/gsd-([a-z0-9-]+)/gi, (_, commandName) => {
2995
+ // A real /gsd-<cmd> MENTION is defined positively by two boundaries, so any
2996
+ // in-path occurrence is excluded by construction (no denylist of preceding
2997
+ // chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill):
2998
+ // 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose
2999
+ // delimiter (backtick/quote/paren/bracket) — e.g. `/gsd-execute-phase`.
3000
+ // 2. Right boundary: the command token is NOT followed by a path separator
3001
+ // `/` (a path continues: `/gsd-core/bin/...`; a command does not). The
3002
+ // `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command.
3003
+ // This converts backtick-wrapped MENTIONS (`/gsd-foo`) while leaving backtick-
3004
+ // wrapped PATHS (`/gsd-core/workflows/update.md`) untouched (#712).
3005
+ converted = converted.replace(/(?<=^|[\s`"'([])\/gsd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => {
2806
3006
  return `$gsd-${String(commandName).toLowerCase()}`;
2807
3007
  });
2808
3008
  return converted;
@@ -3003,6 +3203,20 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3003
3203
  const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
3004
3204
  lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
3005
3205
 
3206
+ // #774 — Emit service_tier and model_verbosity for light-tier agents.
3207
+ // Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent
3208
+ // and benefit from Codex's "flex" service tier (lower cost, background processing)
3209
+ // and "low" verbosity (reduced token output). Both fields are validated against the
3210
+ // Codex ConfigProfile schema (codex-rs/config/src/profile_toml.rs):
3211
+ // service_tier: Option<String> — "flex" | "fast" (legacy)
3212
+ // model_verbosity: Option<Verbosity> — "low" | "medium" | "high"
3213
+ const { AGENT_DEFAULT_TIERS: _agentTiers } = _getGsdEffortCatalog();
3214
+ const _agentRoutingTier = _agentTiers?.[resolvedName] || _agentTiers?.[agentName];
3215
+ if (_agentRoutingTier === 'light') {
3216
+ lines.push(`service_tier = "flex"`);
3217
+ lines.push(`model_verbosity = "low"`);
3218
+ }
3219
+
3006
3220
  // Agent prompts contain raw backslashes in regexes and shell snippets.
3007
3221
  // TOML literal multiline strings preserve them without escape parsing.
3008
3222
  lines.push(`developer_instructions = '''`);
@@ -3012,6 +3226,115 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3012
3226
  return lines.join('\n') + '\n';
3013
3227
  }
3014
3228
 
3229
+ /**
3230
+ * Generate the agents/openai.yaml TUI chip metadata content for a Codex skill.
3231
+ *
3232
+ * This file is written alongside SKILL.md as <skill-dir>/agents/openai.yaml.
3233
+ * Codex loads it as a SkillMetadataFile (codex-rs/core-skills/src/loader.rs),
3234
+ * making the skill discoverable in the /skills TUI popup with a display name
3235
+ * and short description. If the file is absent, Codex silently skips it (fails open).
3236
+ *
3237
+ * Schema (interface section):
3238
+ * display_name: short human-readable skill name (strip gsd- prefix)
3239
+ * short_description: 1-2 sentence description for TUI chip, ≤180 chars
3240
+ *
3241
+ * @param {string} skillName - Full skill name e.g. "gsd-plan-phase"
3242
+ * @param {string} shortDescription - Description text (already truncated by caller)
3243
+ * @returns {string} YAML content for agents/openai.yaml
3244
+ */
3245
+ function generateCodexSkillMetadataYaml(skillName, shortDescription) {
3246
+ // Display name: strip "gsd-" prefix and convert hyphens to spaces for readability.
3247
+ const displayName = skillName.replace(/^gsd-/, '').replace(/-/g, ' ');
3248
+ // yamlQuote (= JSON.stringify) handles all YAML-unsafe chars: backslashes,
3249
+ // quotes, newlines, control characters, and Unicode escapes.
3250
+ return [
3251
+ 'interface:',
3252
+ ` display_name: ${yamlQuote(displayName)}`,
3253
+ ` short_description: ${yamlQuote(shortDescription)}`,
3254
+ '',
3255
+ ].join('\n');
3256
+ }
3257
+
3258
+ /**
3259
+ * Write agents/openai.yaml TUI chip metadata for each gsd-* skill directory.
3260
+ *
3261
+ * Called after layout-driven skill install for Codex. Iterates every gsd-*
3262
+ * skill directory in skillsDir, reads the SKILL.md frontmatter to extract the
3263
+ * short-description already emitted by convertClaudeCommandToCodexSkill, then
3264
+ * writes <skill-dir>/agents/openai.yaml using generateCodexSkillMetadataYaml.
3265
+ *
3266
+ * Fails open: individual skill directories that cannot be processed are silently
3267
+ * skipped so a single malformed SKILL.md cannot block the whole install.
3268
+ *
3269
+ * User-owned skill directories (e.g. gsd-dev-preferences) are explicitly
3270
+ * skipped so existing user-authored agents/openai.yaml files are never
3271
+ * overwritten. These dirs are listed in the same USER_OWNED_SKILL_DIRS
3272
+ * constant used by installOpencodeFamilySkills.
3273
+ *
3274
+ * The YAML-quoted description value is unescaped before embedding so that
3275
+ * YAML escape sequences (e.g. \" in a double-quoted scalar) become the
3276
+ * literal characters they represent rather than being double-escaped in the
3277
+ * output.
3278
+ *
3279
+ * @param {string} skillsDir - Path to the skills/ directory (e.g. ~/.codex/skills)
3280
+ */
3281
+ function writeCodexSkillMetadataFiles(skillsDir) {
3282
+ if (!fs.existsSync(skillsDir)) return;
3283
+ // Mirror the user-owned list from installOpencodeFamilySkills (#2973).
3284
+ // We MUST skip these dirs — their contents are user-generated and must
3285
+ // never be overwritten by GSD's install path.
3286
+ const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
3287
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
3288
+ if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
3289
+ if (_userOwnedSkillDirs.has(entry.name)) continue; // preserve user content
3290
+ const skillDir = path.join(skillsDir, entry.name);
3291
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
3292
+ try {
3293
+ const content = fs.readFileSync(skillMdPath, 'utf8');
3294
+ const { frontmatter } = extractFrontmatterAndBody(content);
3295
+ // Prefer the short-description field emitted by convertClaudeCommandToCodexSkill;
3296
+ // fall back to description, then a synthetic label from the skill name.
3297
+ let shortDesc = '';
3298
+ if (frontmatter) {
3299
+ // SKILL.md uses YAML frontmatter with a nested metadata.short-description key.
3300
+ // extractFrontmatterField handles only top-level keys; parse the metadata block
3301
+ // by looking for " short-description:" directly.
3302
+ const metaMatch = frontmatter.match(/^[ \t]*metadata\s*:\s*\n((?:[ \t]+.*\n?)*)/m);
3303
+ if (metaMatch) {
3304
+ const metaBlock = metaMatch[1];
3305
+ const sdMatch = metaBlock.match(/^[ \t]+short-description\s*:\s*(.+)$/m);
3306
+ if (sdMatch) {
3307
+ // Unescape YAML double-quoted scalar escapes before embedding.
3308
+ // convertClaudeCommandToCodexSkill always emits a double-quoted
3309
+ // value (via yamlQuote) so only double-quote unescaping is needed.
3310
+ let raw = sdMatch[1].trim();
3311
+ if (raw.startsWith('"') && raw.endsWith('"')) {
3312
+ // Strip outer double-quotes and decode \" → " and \\ → \
3313
+ raw = raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
3314
+ } else {
3315
+ // Single-quoted or unquoted: strip surrounding quotes/whitespace
3316
+ raw = raw.replace(/^["']|["']$/g, '');
3317
+ }
3318
+ shortDesc = raw;
3319
+ }
3320
+ }
3321
+ if (!shortDesc) {
3322
+ shortDesc = extractFrontmatterField(frontmatter, 'description') || '';
3323
+ }
3324
+ }
3325
+ if (!shortDesc) {
3326
+ shortDesc = `Run GSD workflow ${entry.name}.`;
3327
+ }
3328
+ const yamlContent = generateCodexSkillMetadataYaml(entry.name, shortDesc);
3329
+ const agentsSubdir = path.join(skillDir, 'agents');
3330
+ fs.mkdirSync(agentsSubdir, { recursive: true });
3331
+ fs.writeFileSync(path.join(agentsSubdir, 'openai.yaml'), yamlContent);
3332
+ } catch (_err) {
3333
+ // Fail open — missing or unreadable SKILL.md must not block the install.
3334
+ }
3335
+ }
3336
+ }
3337
+
3015
3338
  /**
3016
3339
  * Generate the GSD config block for Codex config.toml.
3017
3340
  * @param {Array<{name: string, description: string}>} agents
@@ -5221,6 +5544,506 @@ function stripGsdFromCopilotInstructions(content) {
5221
5544
  return content;
5222
5545
  }
5223
5546
 
5547
+ // ── Cline directory-form rules + hooks + AGENTS.md (issue #787) ────────────────
5548
+ //
5549
+ // Cline v3.36 added a hooks system and a `.clinerules/` directory form. Because
5550
+ // `.clinerules` cannot be both a file AND a directory, emitting hooks under
5551
+ // `.clinerules/hooks/` requires migrating the rules content into the directory
5552
+ // form (`.clinerules/gsd.md`). Sources adjudicated:
5553
+ // - https://cline.bot/blog/cline-v3-36-hooks
5554
+ // - https://docs.cline.bot/customization/cline-rules
5555
+
5556
+ const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
5557
+ const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
5558
+
5559
+ /**
5560
+ * The GSD instruction body shared by the Cline directory-form rules file and
5561
+ * the cross-tool AGENTS.md block. Self-contained — references only the gsd-core
5562
+ * engine layout, not the (separate) #782 Cline skills directory.
5563
+ */
5564
+ function buildClineRulesBody() {
5565
+ return [
5566
+ '# GSD Core — Git. Ship. Done.',
5567
+ '',
5568
+ '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
5569
+ ' the user runs a `/gsd-*` command.',
5570
+ '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
5571
+ '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
5572
+ '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
5573
+ '- Do not apply GSD workflows unless the user explicitly asks for them.',
5574
+ '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
5575
+ ' step to the user using Cline\'s ask_user tool after completing it.',
5576
+ ].join('\n') + '\n';
5577
+ }
5578
+
5579
+ /** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
5580
+ function buildClineAgentsMdBody() {
5581
+ return buildClineRulesBody();
5582
+ }
5583
+
5584
+ /**
5585
+ * The Cline PreToolUse hook script (issue #787).
5586
+ *
5587
+ * Cline invokes hooks as executable scripts named exactly after the event with
5588
+ * no extension, passing the operation context as JSON on stdin and reading a
5589
+ * JSON decision from stdout ({ cancel, errorMessage, contextModification }).
5590
+ *
5591
+ * This hook is a self-standing planning-artifact guard: it cancels write-class
5592
+ * tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
5593
+ * allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
5594
+ * hook bug can never wedge the user. No dependency on the #782 skills work.
5595
+ */
5596
+ function buildClinePreToolUseHook() {
5597
+ return `#!/usr/bin/env node
5598
+ 'use strict';
5599
+ /* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
5600
+ * Protocol: JSON on stdin -> JSON decision on stdout.
5601
+ * Honored fields: { cancel, errorMessage, contextModification }.
5602
+ * Fails open: any error allows the operation. */
5603
+ let raw = '';
5604
+ process.stdin.setEncoding('utf8');
5605
+ process.stdin.on('data', (c) => { raw += c; });
5606
+ process.stdin.on('end', () => {
5607
+ const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
5608
+ let input;
5609
+ try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
5610
+ try {
5611
+ const tool = String(
5612
+ input.toolName || input.tool_name || input.tool ||
5613
+ (input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
5614
+ ).toLowerCase();
5615
+ const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
5616
+ // Collect only PATH-bearing field values (not free-form content), so a doc
5617
+ // that merely mentions ".planning/" in its body is never falsely blocked.
5618
+ const paths = [];
5619
+ const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
5620
+ const walk = (v, depth) => {
5621
+ if (depth > 5 || paths.length > 64) return;
5622
+ if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
5623
+ if (v && typeof v === 'object') {
5624
+ for (const k of Object.keys(v)) {
5625
+ const val = v[k];
5626
+ if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
5627
+ else walk(val, depth + 1);
5628
+ }
5629
+ }
5630
+ };
5631
+ walk(input, 0);
5632
+ const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
5633
+ if (isWrite && paths.some(isPlanningPath)) {
5634
+ return process.stdout.write(JSON.stringify({
5635
+ cancel: true,
5636
+ errorMessage:
5637
+ 'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
5638
+ }));
5639
+ }
5640
+ } catch { /* fall through to allow */ }
5641
+ return allow();
5642
+ });
5643
+ `;
5644
+ }
5645
+
5646
+ /**
5647
+ * Merge the GSD AGENTS.md block into an existing file (or create it), preserving
5648
+ * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
5649
+ */
5650
+ function mergeGsdAgentsMd(filePath, gsdContent) {
5651
+ const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
5652
+
5653
+ if (!fs.existsSync(filePath)) {
5654
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
5655
+ fs.writeFileSync(filePath, gsdBlock + '\n');
5656
+ return;
5657
+ }
5658
+
5659
+ const existing = fs.readFileSync(filePath, 'utf8');
5660
+ const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
5661
+ const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
5662
+
5663
+ if (openIndex !== -1 && closeIndex !== -1) {
5664
+ const before = existing.substring(0, openIndex).trimEnd();
5665
+ const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
5666
+ let newContent = '';
5667
+ if (before) newContent += before + '\n\n';
5668
+ newContent += gsdBlock;
5669
+ if (after) newContent += '\n\n' + after;
5670
+ newContent += '\n';
5671
+ fs.writeFileSync(filePath, newContent);
5672
+ return;
5673
+ }
5674
+
5675
+ fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
5676
+ }
5677
+
5678
+ /**
5679
+ * Strip the GSD block from AGENTS.md content. Returns null if the file became
5680
+ * empty (was GSD-only), the unchanged content if no markers were found, or the
5681
+ * cleaned content otherwise.
5682
+ */
5683
+ function stripGsdFromAgentsMd(content) {
5684
+ const openIndex = content.indexOf(GSD_AGENTS_MD_MARKER);
5685
+ const closeIndex = content.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
5686
+ if (openIndex !== -1 && closeIndex !== -1) {
5687
+ const before = content.substring(0, openIndex).trimEnd();
5688
+ const after = content.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
5689
+ const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
5690
+ if (!cleaned) return null;
5691
+ return cleaned + '\n';
5692
+ }
5693
+ return content;
5694
+ }
5695
+
5696
+ /**
5697
+ * Write the full Cline runtime artifact set (directory-form rules + PreToolUse
5698
+ * hook) into targetDir, migrating a legacy single-file `.clinerules` if present.
5699
+ * For global installs, also merge the cross-tool ~/.agents/AGENTS.md target.
5700
+ *
5701
+ * Returns the list of manifest-relative paths written under targetDir (so the
5702
+ * caller can hash-track them).
5703
+ */
5704
+ function writeClineArtifacts(targetDir, isGlobalInstall) {
5705
+ const written = [];
5706
+ const clinerulesDir = path.join(targetDir, '.clinerules');
5707
+
5708
+ // Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a
5709
+ // file and a directory, so the legacy file must be removed first. The legacy
5710
+ // file is GSD-authored (the installer wrote its full contents with no user
5711
+ // merge surface), so replacing it with the newer directory form is the
5712
+ // intended upgrade. Use lstat so a symlink is unlinked in place rather than
5713
+ // followed (which would write GSD files through the link into an external dir).
5714
+ try {
5715
+ if (fs.existsSync(clinerulesDir)) {
5716
+ const st = fs.lstatSync(clinerulesDir);
5717
+ if (st.isFile() || st.isSymbolicLink()) {
5718
+ fs.unlinkSync(clinerulesDir);
5719
+ console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
5720
+ }
5721
+ }
5722
+ } catch { /* best-effort migration */ }
5723
+
5724
+ fs.mkdirSync(clinerulesDir, { recursive: true });
5725
+ fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
5726
+ written.push('.clinerules/gsd.md');
5727
+ console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
5728
+
5729
+ const hooksDir = path.join(clinerulesDir, 'hooks');
5730
+ fs.mkdirSync(hooksDir, { recursive: true });
5731
+ const hookPath = path.join(hooksDir, 'PreToolUse');
5732
+ fs.writeFileSync(hookPath, buildClinePreToolUseHook());
5733
+ try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
5734
+ written.push('.clinerules/hooks/PreToolUse');
5735
+ console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
5736
+
5737
+ // Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md
5738
+ // (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber
5739
+ // a user's or another tool's AGENTS.md. Tracked via markers (like copilot),
5740
+ // not the per-configDir manifest, since it lives outside configDir.
5741
+ if (isGlobalInstall) {
5742
+ try {
5743
+ const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
5744
+ mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
5745
+ console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
5746
+ } catch (err) {
5747
+ console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
5748
+ }
5749
+ }
5750
+
5751
+ return written;
5752
+ }
5753
+
5754
+ // ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
5755
+ //
5756
+ // Cursor v2.4+ supports a hooks.json lifecycle hook system. GSD registers two
5757
+ // managed command hooks:
5758
+ // sessionStart → gsd-cursor-session-start.js (context injection)
5759
+ // postToolUse → gsd-cursor-post-tool.js (STATE.md update monitor)
5760
+ //
5761
+ // hooks.json schema:
5762
+ // { "version": 1, "hooks": { "<event>": [ { "type": "command", "command": "<path>" } ] } }
5763
+ //
5764
+ // Location:
5765
+ // Global: ~/.cursor/hooks.json
5766
+ // Local: <project-root>/.cursor/hooks.json
5767
+ //
5768
+ // GSD entries are identified by a top-level `"gsd-managed": true` field on
5769
+ // each hook entry. Non-GSD entries are preserved. The reconciler is idempotent
5770
+ // (safe to re-run) and preserves user-owned entries in the file.
5771
+ //
5772
+ // References: https://cursor.com/docs/hooks
5773
+
5774
+ /**
5775
+ * Build a managed Cursor hook entry for a given hook script path.
5776
+ *
5777
+ * @param {string} scriptPath - Absolute path to the hook script
5778
+ * @returns {object} Cursor hook entry object
5779
+ */
5780
+ function buildCursorHookEntry(scriptPath) {
5781
+ return {
5782
+ type: 'command',
5783
+ command: scriptPath.replace(/\\/g, '/'),
5784
+ [GSD_CURSOR_HOOK_MARKER]: true,
5785
+ };
5786
+ }
5787
+
5788
+ /**
5789
+ * Return true if a Cursor hook entry is GSD-managed.
5790
+ * Detection: presence of the GSD_CURSOR_HOOK_MARKER sentinel field.
5791
+ *
5792
+ * @param {object} entry - A hooks array element from hooks.json
5793
+ * @returns {boolean}
5794
+ */
5795
+ function isManagedCursorHookEntry(entry) {
5796
+ return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]);
5797
+ }
5798
+
5799
+ /**
5800
+ * Reconcile the GSD-managed entries in a Cursor hooks.json file.
5801
+ *
5802
+ * Supports both known hooks.json shapes:
5803
+ * 1) { "version": 1, "hooks": { "sessionStart": [...], "postToolUse": [...] } }
5804
+ * 2) { "sessionStart": [...], "postToolUse": [...] } (no wrapper object)
5805
+ *
5806
+ * Managed entries (those with GSD_CURSOR_HOOK_MARKER) are removed then
5807
+ * re-added if managedEntries is non-null/non-empty. User-owned entries are
5808
+ * preserved. File is written atomically only when content changes.
5809
+ *
5810
+ * @param {string} hooksJsonPath - Absolute path to the hooks.json file
5811
+ * @param {{ sessionStart?: object|null, postToolUse?: object|null }|null} managedEntries
5812
+ * Map from event name to the new hook entry to register (or null to remove).
5813
+ * Pass null for the whole param to remove all managed entries.
5814
+ * @returns {{ changed: boolean, wrote: boolean, path: string }}
5815
+ */
5816
+ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
5817
+ let parsed = {};
5818
+ let currentContent = null;
5819
+
5820
+ if (fs.existsSync(hooksJsonPath)) {
5821
+ const raw = fs.readFileSync(hooksJsonPath, 'utf8');
5822
+ currentContent = raw;
5823
+ if (raw.trim()) {
5824
+ try {
5825
+ parsed = JSON.parse(raw);
5826
+ } catch (err) {
5827
+ throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
5828
+ }
5829
+ }
5830
+ }
5831
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
5832
+
5833
+ // Cursor's canonical hooks.json schema is { "version": 1, "hooks": { ... } }.
5834
+ // GSD always writes (and migrates to) the nested shape so Cursor reads it correctly.
5835
+ // The flat shape { "sessionStart": [...] } is accepted on read for backwards compat
5836
+ // with manually-written files, but the output always uses the nested form.
5837
+ const hasNestedHooksObject =
5838
+ parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
5839
+ if (!hasNestedHooksObject) {
5840
+ // Migrate flat shape (or empty {}) to nested: lift event keys into hooks:{}.
5841
+ const eventKeys = ['sessionStart', 'postToolUse'];
5842
+ const lifted = {};
5843
+ for (const k of eventKeys) {
5844
+ if (Array.isArray(parsed[k])) {
5845
+ lifted[k] = parsed[k];
5846
+ delete parsed[k];
5847
+ }
5848
+ }
5849
+ parsed.hooks = lifted;
5850
+ }
5851
+ if (!parsed.version) parsed.version = 1;
5852
+ const hookTable = parsed.hooks;
5853
+
5854
+ // Events GSD manages.
5855
+ const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
5856
+ const entries = managedEntries || {};
5857
+
5858
+ for (const event of MANAGED_EVENTS) {
5859
+ const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
5860
+ // Strip all prior GSD-managed entries for this event.
5861
+ const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
5862
+ const newEntry = entries[event] || null;
5863
+ if (newEntry) {
5864
+ hookTable[event] = [...userOwned, newEntry];
5865
+ } else {
5866
+ // Remove-only: keep user entries, or delete the key if it would be empty.
5867
+ if (userOwned.length > 0) {
5868
+ hookTable[event] = userOwned;
5869
+ } else {
5870
+ delete hookTable[event];
5871
+ }
5872
+ }
5873
+ }
5874
+
5875
+ // hookTable is parsed.hooks (always nested now); no reassignment needed.
5876
+ // Write only if content changed or if we're creating the file for the first time.
5877
+ const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
5878
+ const changed = currentContent !== nextContent;
5879
+ const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
5880
+ if (shouldWrite) {
5881
+ atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
5882
+ }
5883
+
5884
+ return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
5885
+ }
5886
+
5887
+ /**
5888
+ * #777 — Write GSD-managed Cursor lifecycle hooks into <targetDir>/hooks.json.
5889
+ *
5890
+ * Both managed hook scripts (gsd-cursor-session-start.js, gsd-cursor-post-tool.js)
5891
+ * are copied from the GSD hooks/ source to <targetDir>/hooks/ first, so the
5892
+ * hooks.json entries never reference a script that wasn't installed.
5893
+ *
5894
+ * @param {string} targetDir - The Cursor config dir (global: ~/.cursor; local: .cursor)
5895
+ * @param {string} src - The GSD install source root (for copying hook scripts)
5896
+ * @param {{ absoluteRunner?: string|null }} opts
5897
+ * @returns {{ hooksJsonPath: string, changed: boolean }}
5898
+ */
5899
+ function writeCursorHooksJson(targetDir, src, opts) {
5900
+ opts = opts || {};
5901
+ const hooksDir = path.join(targetDir, 'hooks');
5902
+ fs.mkdirSync(hooksDir, { recursive: true });
5903
+
5904
+ // Copy the two GSD-managed hook scripts from the GSD source hooks/ directory.
5905
+ // Apply the same /gsd:/gi → gsd- rewrite used by copyWithPathReplacement for Cursor
5906
+ // JS files, so the installed hook scripts contain no /gsd: colon refs (bug-376 2b).
5907
+ // Track which scripts were successfully installed so we never register a hook entry
5908
+ // that references a script that wasn't copied (dangling command guard).
5909
+ const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
5910
+ const srcHooksDir = path.join(src, 'hooks');
5911
+ const installedScripts = new Set();
5912
+ for (const script of hookScripts) {
5913
+ const srcPath = path.join(srcHooksDir, script);
5914
+ const destPath = path.join(hooksDir, script);
5915
+ if (fs.existsSync(srcPath)) {
5916
+ let content = fs.readFileSync(srcPath, 'utf8');
5917
+ // Rewrite /gsd:<cmd> → gsd-<cmd> so installed hook scripts are consistent
5918
+ // with the Cursor convention (no colon-form slash commands in agent context).
5919
+ content = content.replace(/gsd:/gi, 'gsd-');
5920
+ fs.writeFileSync(destPath, content);
5921
+ try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
5922
+ installedScripts.add(script);
5923
+ }
5924
+ }
5925
+
5926
+ // Build command strings using the same buildHookCommand helper used by other runtimes.
5927
+ // buildHookCommand resolves the node runner + emits "<runner>" "<targetDir>/hooks/<name>".
5928
+ const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
5929
+ // buildHookCommand('gsd-cursor-session-start.js', ...): sessionStart → context injection
5930
+ // Only register the hook entry if the script was actually installed (dangling guard).
5931
+ const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
5932
+ ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
5933
+ : null;
5934
+ // buildHookCommand('gsd-cursor-post-tool.js', ...): postToolUse → STATE.md update monitor
5935
+ const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
5936
+ ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
5937
+ : null;
5938
+
5939
+ // Build managed entries; skip events whose command couldn't be resolved (e.g. no node).
5940
+ const managedEntries = {};
5941
+ if (sessionStartCmd) {
5942
+ managedEntries.sessionStart = {
5943
+ type: 'command',
5944
+ command: sessionStartCmd,
5945
+ [GSD_CURSOR_HOOK_MARKER]: true,
5946
+ };
5947
+ }
5948
+ if (postToolCmd) {
5949
+ managedEntries.postToolUse = {
5950
+ type: 'command',
5951
+ command: postToolCmd,
5952
+ [GSD_CURSOR_HOOK_MARKER]: true,
5953
+ };
5954
+ }
5955
+
5956
+ const hooksJsonPath = path.join(targetDir, 'hooks.json');
5957
+ const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
5958
+ return { hooksJsonPath, changed: result.changed };
5959
+ }
5960
+
5961
+ /**
5962
+ * Remove all GSD-managed Cursor lifecycle hook entries from hooks.json.
5963
+ * User-owned entries are preserved. If the file becomes empty, it is removed.
5964
+ *
5965
+ * @param {string} targetDir - The Cursor config dir
5966
+ * @returns {{ changed: boolean }}
5967
+ */
5968
+ function removeCursorHooksJson(targetDir) {
5969
+ const hooksJsonPath = path.join(targetDir, 'hooks.json');
5970
+ if (!fs.existsSync(hooksJsonPath)) return { changed: false };
5971
+ const result = reconcileCursorHooksJson(hooksJsonPath, null);
5972
+ // If the resulting file has no meaningful hook content, remove it.
5973
+ // A file is "empty" if it contains only the scaffolding (version, empty hooks
5974
+ // object, or a bare {}) with no user-authored hook entries.
5975
+ if (result.changed) {
5976
+ try {
5977
+ const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
5978
+ const parsed = JSON.parse(contentRaw);
5979
+ // reconcileCursorHooksJson always writes the nested { version, hooks:{} } shape.
5980
+ // The file is "empty" when there are no remaining hook events with entries.
5981
+ const hookTable = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
5982
+ ? parsed.hooks
5983
+ : {};
5984
+ const hasAnyEvents = Object.keys(hookTable).some(
5985
+ (k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0,
5986
+ );
5987
+ if (!hasAnyEvents) {
5988
+ fs.unlinkSync(hooksJsonPath);
5989
+ return { changed: true };
5990
+ }
5991
+ } catch { /* best-effort: leave the file */ }
5992
+ }
5993
+ return { changed: result.changed };
5994
+ }
5995
+
5996
+ /**
5997
+ * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object.
5998
+ *
5999
+ * Returns the verbatim JSON shape Copilot CLI expects:
6000
+ * { version: 1, hooks: { sessionStart: [ <hook entry> ] } }
6001
+ *
6002
+ * The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies
6003
+ * run inline (no external script file), so the config can never reference a
6004
+ * hook script that the installer did not also install — it is self-contained
6005
+ * by construction. The command is advisory-only (always exits 0) and orients
6006
+ * the agent toward the project's GSD planning state at session start.
6007
+ *
6008
+ * @returns {object} Copilot hooks-configuration object
6009
+ */
6010
+ function buildCopilotHookConfig() {
6011
+ return {
6012
+ version: 1,
6013
+ hooks: {
6014
+ sessionStart: [
6015
+ {
6016
+ type: 'command',
6017
+ bash: GSD_COPILOT_SESSION_HOOK_BASH,
6018
+ powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
6019
+ timeoutSec: 10,
6020
+ },
6021
+ ],
6022
+ },
6023
+ };
6024
+ }
6025
+
6026
+ /**
6027
+ * #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime
6028
+ * config dir (`<targetDir>/hooks/gsd-session.json`). For local installs
6029
+ * targetDir is `.github` (→ `.github/hooks/`); for global installs it is
6030
+ * `~/.copilot` (→ `~/.copilot/hooks/`) — both are valid Copilot hook locations.
6031
+ *
6032
+ * The managed file is fully owned by GSD, so it is overwritten wholesale on
6033
+ * every install (idempotent). User-authored sibling `*.json` hook files in the
6034
+ * same directory are untouched.
6035
+ *
6036
+ * @param {string} targetDir - The Copilot config dir
6037
+ * @returns {string} The path the hook config was written to
6038
+ */
6039
+ function writeCopilotHookConfig(targetDir) {
6040
+ const hooksDir = path.join(targetDir, 'hooks');
6041
+ fs.mkdirSync(hooksDir, { recursive: true });
6042
+ const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
6043
+ fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
6044
+ return hookPath;
6045
+ }
6046
+
5224
6047
  /**
5225
6048
  * Generate config.toml and per-agent .toml files for Codex.
5226
6049
  * Reads agent .md files from source, extracts metadata, writes .toml configs.
@@ -5393,7 +6216,7 @@ function convertSlashCommandsToGeminiMentions(content) {
5393
6216
  });
5394
6217
  }
5395
6218
 
5396
- function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
6219
+ function convertClaudeToGeminiMarkdown(content, { isCommand = false, commandName = null } = {}) {
5397
6220
  // Apply Gemini-specific slash command namespacing
5398
6221
  let converted = convertSlashCommandsToGeminiMentions(content);
5399
6222
  // Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body
@@ -5405,8 +6228,9 @@ function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
5405
6228
  converted = stripSubTags(converted);
5406
6229
 
5407
6230
  if (isCommand) {
5408
- // Convert to Gemini TOML format
5409
- converted = convertClaudeToGeminiToml(converted);
6231
+ // Convert to Gemini TOML format (threads the command name so per-command
6232
+ // enrichment — e.g. the #778 live-state injection — can target a command).
6233
+ converted = convertClaudeToGeminiToml(converted, { commandName });
5410
6234
  }
5411
6235
 
5412
6236
  return converted;
@@ -5841,12 +6665,85 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
5841
6665
  return `---\n${newFrontmatter}\n---${body}`;
5842
6666
  }
5843
6667
 
6668
+ /**
6669
+ * Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo),
6670
+ * which share a config schema (Kilo derives from OpenCode). OpenCode discovers
6671
+ * skills as `skills/<name>/SKILL.md` and Kilo follows the same layout
6672
+ * (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills).
6673
+ *
6674
+ * The skill body reuses the runtime's command-frontmatter converter for tool,
6675
+ * path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill
6676
+ * frontmatter: only `name` (lowercase-hyphen, must match the containing
6677
+ * directory) and `description` (1–1024 chars) are emitted, per the OpenCode
6678
+ * skill spec. The command's `tools:`/`permission:` block is intentionally
6679
+ * dropped — OpenCode skills are loaded on-demand via the native skill tool and
6680
+ * inherit the calling agent's permissions.
6681
+ *
6682
+ * @param {string} content - Claude command markdown (with YAML frontmatter)
6683
+ * @param {string} skillName - Skill directory name (e.g. gsd-help)
6684
+ * @param {(content: string) => string} frontmatterConverter - runtime command converter
6685
+ * @returns {string} SKILL.md content
6686
+ */
6687
+ function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) {
6688
+ const converted = frontmatterConverter(content);
6689
+ const { frontmatter, body } = extractFrontmatterAndBody(converted);
6690
+ let description = `Run GSD workflow ${skillName}.`;
6691
+ if (frontmatter) {
6692
+ const maybeDescription = extractFrontmatterField(frontmatter, 'description');
6693
+ if (maybeDescription) {
6694
+ description = maybeDescription;
6695
+ }
6696
+ }
6697
+ description = toSingleLine(description);
6698
+ // OpenCode skill descriptions must be 1–1024 characters.
6699
+ if (description.length > 1024) {
6700
+ description = `${description.slice(0, 1021)}...`;
6701
+ }
6702
+ // `name` must be lowercase alphanumeric with single-hyphen separators and
6703
+ // match the containing directory name (the staged dir is `${skillName}/`).
6704
+ const name = yamlIdentifier(skillName);
6705
+ return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`;
6706
+ }
6707
+
6708
+ /**
6709
+ * Convert a Claude command (.md) to an OpenCode skill (SKILL.md).
6710
+ * Thin wrapper over the shared OpenCode-family writer.
6711
+ */
6712
+ function convertClaudeCommandToOpencodeSkill(content, skillName) {
6713
+ return convertClaudeCommandToOpencodeFamilySkill(
6714
+ content,
6715
+ skillName,
6716
+ (c) => convertClaudeToOpencodeFrontmatter(c),
6717
+ );
6718
+ }
6719
+
6720
+ /**
6721
+ * Convert a Claude command (.md) to a Kilo skill (SKILL.md).
6722
+ * Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema).
6723
+ */
6724
+ function convertClaudeCommandToKiloSkill(content, skillName) {
6725
+ return convertClaudeCommandToOpencodeFamilySkill(
6726
+ content,
6727
+ skillName,
6728
+ (c) => convertClaudeToKiloFrontmatter(c),
6729
+ );
6730
+ }
6731
+
5844
6732
  /**
5845
6733
  * Convert Claude Code markdown command to Gemini TOML format
5846
6734
  * @param {string} content - Markdown file content with YAML frontmatter
5847
6735
  * @returns {string} - TOML content
5848
6736
  */
5849
- function convertClaudeToGeminiToml(content) {
6737
+ function convertClaudeToGeminiToml(content, { commandName = null } = {}) {
6738
+ // #778 (c) — Gemini {{args}} interpolation. Claude's $ARGUMENTS placeholder
6739
+ // maps to Gemini's {{args}} so inline argument references interpolate into the
6740
+ // command body instead of being emitted as a dead literal. Applied before
6741
+ // frontmatter parsing so every return path benefits (a command's frontmatter
6742
+ // never contains $ARGUMENTS, so this is body-only in practice). Gemini injects
6743
+ // {{args}} as typed outside shell blocks; we never place it inside a !{...}
6744
+ // block, so there is no shell-escaping/injection interaction.
6745
+ content = content.replace(/\$ARGUMENTS\b/g, '{{args}}');
6746
+
5850
6747
  // Check if content has frontmatter
5851
6748
  if (!content.startsWith('---')) {
5852
6749
  return `prompt = ${JSON.stringify(content)}\n`;
@@ -5858,7 +6755,27 @@ function convertClaudeToGeminiToml(content) {
5858
6755
  }
5859
6756
 
5860
6757
  const frontmatter = content.substring(3, endIndex).trim();
5861
- const body = content.substring(endIndex + 3).trim();
6758
+ let body = content.substring(endIndex + 3).trim();
6759
+
6760
+ // #778 (c) — Gemini !{...} dynamic-output injection for the situational
6761
+ // `progress` command (GSD's status/dashboard surface). Inject the live
6762
+ // .planning/STATE.md so the model sees current project state without relying
6763
+ // on session memory.
6764
+ //
6765
+ // SECURITY: the shell command is a FIXED `cat` with NO interpolated user
6766
+ // input — no {{args}} appears inside the block — so there is no
6767
+ // shell-injection vector. Gemini still shows its standard per-invocation
6768
+ // confirmation dialog (verified behavior). `2>/dev/null` keeps an
6769
+ // uninitialized project (missing STATE.md) from injecting stderr noise.
6770
+ // Braces inside the block are balanced (none present), per Gemini's parser
6771
+ // requirement. The append happens AFTER the {{args}} mapping above so the
6772
+ // injected block can never accidentally carry interpolated arguments.
6773
+ if (commandName === 'progress') {
6774
+ body += '\n\n## Live project state\n'
6775
+ + 'Current contents of `.planning/STATE.md` '
6776
+ + '(empty if the project is not yet initialized):\n\n'
6777
+ + '!{cat .planning/STATE.md 2>/dev/null}\n';
6778
+ }
5862
6779
 
5863
6780
  // Extract description from frontmatter
5864
6781
  let description = '';
@@ -5893,6 +6810,31 @@ function convertClaudeToGeminiToml(content) {
5893
6810
  * @param {string} pathPrefix - Path prefix for file references
5894
6811
  * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
5895
6812
  */
6813
+ /**
6814
+ * Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a
6815
+ * RAW Claude command/skill body, BEFORE the frontmatter converter runs.
6816
+ *
6817
+ * This is the single source of truth shared by copyFlattenedCommands (commands)
6818
+ * and installOpencodeFamilySkills (skills) so the two surfaces produce identical
6819
+ * path references. Applying pathPrefix pre-conversion (rather than rewriting an
6820
+ * already-converted body) is what avoids the converter's hardcoded default
6821
+ * config dir leaking into --local / --config-dir installs, and the
6822
+ * prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784)
6823
+ *
6824
+ * @param {string} content - raw Claude command markdown
6825
+ * @param {string} runtime - 'opencode' or 'kilo'
6826
+ * @param {string} pathPrefix - trailing-slash install-target prefix
6827
+ * @returns {string}
6828
+ */
6829
+ function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) {
6830
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
6831
+ content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
6832
+ content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`);
6833
+ content = content.replace(/~\/\.opencode\//g, pathPrefix);
6834
+ content = content.replace(/~\/\.kilo\//g, pathPrefix);
6835
+ return content;
6836
+ }
6837
+
5896
6838
  function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
5897
6839
  if (!fs.existsSync(srcDir)) {
5898
6840
  return;
@@ -5925,16 +6867,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
5925
6867
  const destPath = path.join(destDir, destName);
5926
6868
 
5927
6869
  let content = fs.readFileSync(srcPath, 'utf8');
5928
- const globalClaudeRegex = /~\/\.claude\//g;
5929
- const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
5930
- const localClaudeRegex = /\.\/\.claude\//g;
5931
- const opencodeDirRegex = /~\/\.opencode\//g;
5932
- const kiloDirRegex = /~\/\.kilo\//g;
5933
- content = content.replace(globalClaudeRegex, pathPrefix);
5934
- content = content.replace(globalClaudeHomeRegex, pathPrefix);
5935
- content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
5936
- content = content.replace(opencodeDirRegex, pathPrefix);
5937
- content = content.replace(kiloDirRegex, pathPrefix);
6870
+ content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
5938
6871
  content = processAttribution(content, getCommitAttribution(runtime));
5939
6872
  content = runtime === 'kilo'
5940
6873
  ? convertClaudeToKiloFrontmatter(content)
@@ -6119,7 +7052,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
6119
7052
  if (runtime) {
6120
7053
  const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
6121
7054
  const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
6122
- if (!skillsKindEntry) return false; // runtime has no skills layout (e.g. cline)
7055
+ if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local)
6123
7056
  const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
6124
7057
  skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName);
6125
7058
  } else {
@@ -6176,6 +7109,46 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
6176
7109
  walkAndRewrite(stagedDir);
6177
7110
  }
6178
7111
 
7112
+ /**
7113
+ * Apply per-runtime content rewrites to flat .md files in a staged commands dir.
7114
+ * Used for runtimes that have a commandsKind in their layout and need content rewrites
7115
+ * (e.g. augment — replaces ~/.claude/ paths and applies branding conversions).
7116
+ *
7117
+ * IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged
7118
+ * on a full/default profile (skills === '*'). This function MUST NOT mutate that source
7119
+ * directory. It always copies to a temp dir first, rewrites there, and returns the new
7120
+ * path so the caller installs from the temp copy, not the source.
7121
+ *
7122
+ * @param {string} stagedDir directory of staged flat .md command files (may be source dir)
7123
+ * @param {string} runtime
7124
+ * @param {string} pathPrefix
7125
+ * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
7126
+ */
7127
+ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
7128
+ if (!fs.existsSync(stagedDir)) return stagedDir;
7129
+ // Always copy to a temp dir — stageSkillsForProfile() returns the original source
7130
+ // dir on full/default profile (skills === '*'), so writing in-place would corrupt the
7131
+ // package source. A temp copy is unconditional to keep the code simple and safe.
7132
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cmd-rewrites-'));
7133
+ try {
7134
+ for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
7135
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
7136
+ let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
7137
+ content = _applyRuntimeRewrites(content, runtime, pathPrefix);
7138
+ // For augment commands, apply the markdown conversion so tool references
7139
+ // and skill paths use Augment equivalents.
7140
+ if (runtime === 'augment') {
7141
+ content = convertClaudeToAugmentMarkdown(content);
7142
+ }
7143
+ fs.writeFileSync(path.join(tempDir, entry.name), content);
7144
+ }
7145
+ } catch (err) {
7146
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ }
7147
+ throw err;
7148
+ }
7149
+ return tempDir;
7150
+ }
7151
+
6179
7152
  /**
6180
7153
  * Apply the per-runtime rewrite table to a single content string.
6181
7154
  * Extracted so it can be unit-tested independently of the filesystem walk.
@@ -6198,6 +7171,22 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6198
7171
  content = processAttribution(content, getCommitAttribution(runtime));
6199
7172
  break;
6200
7173
 
7174
+ case 'cline':
7175
+ // Slash forms: both the original ~/.claude/ (safety net) and the stage-time
7176
+ // converted ~/.cline/ (from convertClaudeToCliineMarkdown) → pathPrefix
7177
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
7178
+ content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7179
+ content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
7180
+ content = content.replace(/~\/\.cline\//g, pathPrefix);
7181
+ content = content.replace(/\$HOME\/\.cline\//g, pathPrefix);
7182
+ // Bare forms (no trailing slash)
7183
+ content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
7184
+ content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
7185
+ content = content.replace(/~\/\.cline\b/g, normalizedPathPrefix);
7186
+ content = content.replace(/\$HOME\/\.cline\b/g, normalizedPathPrefix);
7187
+ content = processAttribution(content, getCommitAttribution(runtime));
7188
+ break;
7189
+
6201
7190
  case 'cursor':
6202
7191
  content = content.replace(/~\/\.claude\//g, pathPrefix);
6203
7192
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
@@ -6240,7 +7229,14 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6240
7229
  content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
6241
7230
  content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
6242
7231
  content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
7232
+ // The codebuddy converter rewrites `.claude/` → `.codebuddy/` at stage
7233
+ // time, so `$HOME/.claude/...` arrives here as `$HOME/.codebuddy/...`.
7234
+ // Normalize BOTH the `~/` and `$HOME/` forms (slash + bare) to the install
7235
+ // target so `--config-dir`/local installs don't leak the default home.
6243
7236
  content = content.replace(/~\/\.codebuddy\//g, pathPrefix);
7237
+ content = content.replace(/\$HOME\/\.codebuddy\//g, pathPrefix);
7238
+ content = content.replace(/~\/\.codebuddy\b/g, normalizedPathPrefix);
7239
+ content = content.replace(/\$HOME\/\.codebuddy\b/g, normalizedPathPrefix);
6244
7240
  content = processAttribution(content, getCommitAttribution(runtime));
6245
7241
  break;
6246
7242
 
@@ -6295,7 +7291,11 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6295
7291
  break;
6296
7292
 
6297
7293
  default:
6298
- // Unknown runtime — no rewrites
7294
+ // Unknown runtime — no rewrites.
7295
+ // OpenCode/Kilo are intentionally absent: their skills are written by
7296
+ // installOpencodeFamilySkills, which applies pathPrefix BEFORE the
7297
+ // command→skill conversion (mirroring copyFlattenedCommands) rather than
7298
+ // rewriting already-converted SKILL.md bodies. See #784.
6299
7299
  break;
6300
7300
  }
6301
7301
 
@@ -6558,8 +7558,14 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6558
7558
 
6559
7559
  for (const kind of layout.kinds) {
6560
7560
  const staged = kind.stage(resolvedProfile);
7561
+ // stagedForCopy: the directory to copy from (may differ from staged if rewrites
7562
+ // produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
7563
+ let stagedForCopy = staged;
6561
7564
  if (kind.kind === 'skills') {
6562
7565
  applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
7566
+ } else if (kind.kind === 'commands') {
7567
+ // Returns a temp dir with rewritten content so source files are never mutated.
7568
+ stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
6563
7569
  }
6564
7570
  const dest = path.join(layout.configDir, kind.destSubpath);
6565
7571
  fs.mkdirSync(dest, { recursive: true });
@@ -6581,8 +7587,8 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6581
7587
 
6582
7588
  if (kind.prefix === '') {
6583
7589
  // Hermes: wipes entire dest dir — preserve anything not in staged.
6584
- const stagedNames = fs.existsSync(staged)
6585
- ? new Set(fs.readdirSync(staged, { withFileTypes: true })
7590
+ const stagedNames = fs.existsSync(stagedForCopy)
7591
+ ? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true })
6586
7592
  .filter(e => e.isDirectory()).map(e => e.name))
6587
7593
  : new Set();
6588
7594
  for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
@@ -6603,7 +7609,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6603
7609
  }
6604
7610
 
6605
7611
  _removeGsdEntries(dest, kind);
6606
- _copyStaged(staged, dest, kind);
7612
+ _copyStaged(stagedForCopy, dest, kind);
6607
7613
 
6608
7614
  // Restore user-owned dirs after the prune+copy
6609
7615
  for (const [dirName, snap] of toPreserve) {
@@ -6613,11 +7619,92 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6613
7619
  // For non-skills kinds (commands, agents): no user content to preserve;
6614
7620
  // just prune stale gsd-* entries and copy new ones.
6615
7621
  _removeGsdEntries(dest, kind);
6616
- _copyStaged(staged, dest, kind);
7622
+ _copyStaged(stagedForCopy, dest, kind);
6617
7623
  }
6618
7624
  }
6619
7625
  }
6620
7626
 
7627
+ /**
7628
+ * Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo).
7629
+ *
7630
+ * These runtimes do NOT go through installRuntimeArtifacts (their commands use a
7631
+ * bespoke flattened-command writer), so this writes ONLY the skills kind
7632
+ * alongside their existing command/ + agents/ surfaces. Uninstall is already
7633
+ * layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the
7634
+ * skills/ dir is cleaned up automatically once the layout declares it.
7635
+ *
7636
+ * `rawCommandsDir` MUST be the SAME staged command directory the flattened
7637
+ * command writer consumes (the caller passes its `_stageSkills()` output) so the
7638
+ * command/ and skills/ surfaces always cover the identical, profile-resolved set
7639
+ * — including the `--minimal`/`--core-only` alias path, which stages differently
7640
+ * from a plain `--profile=core`.
7641
+ *
7642
+ * Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite →
7643
+ * attribution → command→skill conversion — guaranteeing command/ and skills/
7644
+ * bodies match byte-for-byte for global, --local, and --config-dir installs.
7645
+ * We deliberately do NOT use skillsKindEntry.stage(): that converts before any
7646
+ * pathPrefix is known, so its bodies would carry the converter's hardcoded
7647
+ * default config dir. (#784)
7648
+ *
7649
+ * @param {string} runtime - 'opencode' or 'kilo'
7650
+ * @param {string} targetDir - resolved runtime config directory
7651
+ * @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
7652
+ * @param {string} pathPrefix - computed config-path prefix for body rewrites
7653
+ * @returns {number} number of gsd-* skill directories written
7654
+ */
7655
+ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) {
7656
+ const layout = resolveRuntimeArtifactLayout(runtime, targetDir);
7657
+ const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
7658
+ if (!skillsKindEntry) return 0;
7659
+ const rawDir = rawCommandsDir;
7660
+ if (!rawDir || !fs.existsSync(rawDir)) return 0;
7661
+
7662
+ const converter = runtime === 'kilo'
7663
+ ? convertClaudeCommandToKiloSkill
7664
+ : convertClaudeCommandToOpencodeSkill;
7665
+
7666
+ const dest = path.join(targetDir, skillsKindEntry.destSubpath);
7667
+ fs.mkdirSync(dest, { recursive: true });
7668
+
7669
+ // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune.
7670
+ // gsd-dev-preferences is generated by the user (via generate-dev-preferences)
7671
+ // and lives at <configDir>/skills/gsd-dev-preferences — _removeGsdEntries
7672
+ // would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts
7673
+ // (#2973).
7674
+ const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
7675
+ const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
7676
+ for (const dirName of USER_OWNED_SKILL_DIRS) {
7677
+ const skillDir = path.join(dest, dirName);
7678
+ if (!fs.existsSync(skillDir)) continue;
7679
+ const snap = _snapshotDir(skillDir);
7680
+ if (snap.size > 0) toPreserve.set(dirName, snap);
7681
+ }
7682
+
7683
+ _removeGsdEntries(dest, skillsKindEntry);
7684
+
7685
+ let count = 0;
7686
+ for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
7687
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
7688
+ const stem = entry.name.slice(0, -3);
7689
+ const skillName = `${skillsKindEntry.prefix}${stem}`;
7690
+ let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
7691
+ content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
7692
+ content = processAttribution(content, getCommitAttribution(runtime));
7693
+ content = converter(content, skillName);
7694
+ const skillDir = path.join(dest, skillName);
7695
+ fs.mkdirSync(skillDir, { recursive: true });
7696
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
7697
+ count++;
7698
+ }
7699
+
7700
+ // Restore user-owned dirs after the prune+copy.
7701
+ for (const [dirName, snap] of toPreserve) {
7702
+ _restoreDir(path.join(dest, dirName), snap);
7703
+ }
7704
+
7705
+ return count;
7706
+ }
7707
+
6621
7708
  /**
6622
7709
  * Layout-driven uninstall orchestrator.
6623
7710
  * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
@@ -6722,8 +7809,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
6722
7809
  : convertClaudeToOpencodeFrontmatter(content);
6723
7810
  fs.writeFileSync(destPath, content);
6724
7811
  } else if (isGemini) {
6725
- // Apply Gemini-specific Markdown transformations (slash commands, TOML)
6726
- const processed = convertClaudeToGeminiMarkdown(content, { isCommand });
7812
+ // Apply Gemini-specific Markdown transformations (slash commands, TOML).
7813
+ // #778: thread the command name (file stem) so per-command TOML
7814
+ // enrichment (live-state injection) can target a specific command.
7815
+ const geminiCommandName = isCommand ? entry.name.replace(/\.md$/, '') : null;
7816
+ const processed = convertClaudeToGeminiMarkdown(content, { isCommand, commandName: geminiCommandName });
6727
7817
  const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
6728
7818
  fs.writeFileSync(finalPath, processed);
6729
7819
  } else if (isCodex) {
@@ -6968,7 +8058,10 @@ const GSD_UNINSTALL_HOOKS = [
6968
8058
  'gsd-statusline.js',
6969
8059
  'gsd-check-update.js',
6970
8060
  'gsd-check-update.cmd',
8061
+ 'gsd-config-reload.js',
6971
8062
  'gsd-context-monitor.js',
8063
+ 'gsd-cursor-session-start.js',
8064
+ 'gsd-cursor-post-tool.js',
6972
8065
  'gsd-prompt-guard.js',
6973
8066
  'gsd-read-guard.js',
6974
8067
  'gsd-read-injection-scanner.js',
@@ -7002,10 +8095,14 @@ function uninstall(isGlobal, runtime = 'claude') {
7002
8095
  const isCodebuddy = runtime === 'codebuddy';
7003
8096
  const dirName = getDirName(runtime);
7004
8097
 
7005
- // Get the target directory based on runtime and install type
8098
+ // Get the target directory based on runtime and install type. Cline local
8099
+ // installs write to the project root (.clinerules/ lives at the root, not in
8100
+ // a .cline/ subdir), mirroring the install() path resolution (#787).
7006
8101
  const targetDir = isGlobal
7007
- ? getGlobalDir(runtime, explicitConfigDir)
7008
- : path.join(process.cwd(), dirName);
8102
+ ? getGlobalConfigDir(runtime, explicitConfigDir)
8103
+ : runtime === 'cline'
8104
+ ? process.cwd()
8105
+ : path.join(process.cwd(), dirName);
7009
8106
 
7010
8107
  const locationLabel = isGlobal
7011
8108
  ? targetDir.replace(os.homedir(), '~')
@@ -7028,6 +8125,24 @@ function uninstall(isGlobal, runtime = 'claude') {
7028
8125
 
7029
8126
  console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
7030
8127
 
8128
+ // #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot
8129
+ // installs, so its cleanup must run even when .github (targetDir) was already
8130
+ // removed — i.e. BEFORE the "target directory missing" early-return below.
8131
+ if (isCopilot && !isGlobal) {
8132
+ const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
8133
+ if (fs.existsSync(agentsMdPath)) {
8134
+ const content = fs.readFileSync(agentsMdPath, 'utf8');
8135
+ const cleaned = stripGsdFromCopilotInstructions(content);
8136
+ if (cleaned === null) {
8137
+ fs.unlinkSync(agentsMdPath);
8138
+ console.log(` ${green}✓${reset} Removed AGENTS.md (was GSD-only)`);
8139
+ } else if (cleaned !== content) {
8140
+ fs.writeFileSync(agentsMdPath, cleaned);
8141
+ console.log(` ${green}✓${reset} Cleaned GSD section from AGENTS.md`);
8142
+ }
8143
+ }
8144
+ }
8145
+
7031
8146
  // Check if target directory exists
7032
8147
  if (!fs.existsSync(targetDir)) {
7033
8148
  console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
@@ -7087,6 +8202,15 @@ function uninstall(isGlobal, runtime = 'claude') {
7087
8202
  removedCount++;
7088
8203
  console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
7089
8204
  }
8205
+
8206
+ // #772: remove new Codex hook event registrations added by this enhancement.
8207
+ for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
8208
+ const eventCleanup = removeCodexHooksJsonEvent(targetDir, eventName);
8209
+ if (eventCleanup.changed) {
8210
+ removedCount++;
8211
+ console.log(` ${green}✓${reset} Removed managed Codex ${eventName} hook from hooks.json`);
8212
+ }
8213
+ }
7090
8214
  }
7091
8215
 
7092
8216
  // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
@@ -7105,6 +8229,99 @@ function uninstall(isGlobal, runtime = 'claude') {
7105
8229
  console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`);
7106
8230
  }
7107
8231
  }
8232
+
8233
+ // #786: remove the GSD-managed Copilot lifecycle hook config and prune the
8234
+ // hooks dir if we left it empty.
8235
+ const hookPath = path.join(targetDir, 'hooks', GSD_COPILOT_HOOK_FILE);
8236
+ if (fs.existsSync(hookPath)) {
8237
+ fs.unlinkSync(hookPath);
8238
+ removedCount++;
8239
+ console.log(` ${green}✓${reset} Removed Copilot lifecycle hook (${GSD_COPILOT_HOOK_FILE})`);
8240
+ try {
8241
+ const hooksDir = path.join(targetDir, 'hooks');
8242
+ if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
8243
+ fs.rmdirSync(hooksDir);
8244
+ }
8245
+ } catch { /* non-fatal: leave a non-empty/locked hooks dir in place */ }
8246
+ }
8247
+ // Note: AGENTS.md (repo root) is cleaned earlier, before the targetDir
8248
+ // existence early-return, since it lives outside targetDir (#786).
8249
+ }
8250
+
8251
+ // 1b-cline. Non-layout Cline side-effects (issue #787): remove the
8252
+ // directory-form rules + PreToolUse hook, and strip the GSD block from the
8253
+ // global cross-tool ~/.agents/AGENTS.md target.
8254
+ if (runtime === 'cline') {
8255
+ const clinerulesDir = path.join(targetDir, '.clinerules');
8256
+ for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) {
8257
+ const p = path.join(clinerulesDir, rel);
8258
+ try {
8259
+ if (fs.existsSync(p)) {
8260
+ fs.unlinkSync(p);
8261
+ removedCount++;
8262
+ }
8263
+ } catch { /* best-effort */ }
8264
+ }
8265
+ // Also remove a legacy single-file .clinerules left by pre-#787 installs.
8266
+ try {
8267
+ if (fs.existsSync(clinerulesDir) && fs.statSync(clinerulesDir).isFile()) {
8268
+ fs.unlinkSync(clinerulesDir);
8269
+ removedCount++;
8270
+ }
8271
+ } catch { /* best-effort */ }
8272
+ // Prune now-empty GSD-created directories (leave any user-added rule files).
8273
+ for (const dir of [path.join(clinerulesDir, 'hooks'), clinerulesDir]) {
8274
+ try {
8275
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) {
8276
+ fs.rmdirSync(dir);
8277
+ }
8278
+ } catch { /* best-effort */ }
8279
+ }
8280
+ if (isGlobal) {
8281
+ const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
8282
+ try {
8283
+ if (fs.existsSync(agentsPath)) {
8284
+ const content = fs.readFileSync(agentsPath, 'utf8');
8285
+ const cleaned = stripGsdFromAgentsMd(content);
8286
+ if (cleaned === null) {
8287
+ fs.unlinkSync(agentsPath);
8288
+ removedCount++;
8289
+ console.log(` ${green}✓${reset} Removed ~/.agents/AGENTS.md (was GSD-only)`);
8290
+ } else if (cleaned !== content) {
8291
+ fs.writeFileSync(agentsPath, cleaned);
8292
+ removedCount++;
8293
+ console.log(` ${green}✓${reset} Cleaned GSD section from ~/.agents/AGENTS.md`);
8294
+ }
8295
+ }
8296
+ } catch { /* best-effort */ }
8297
+ }
8298
+ }
8299
+
8300
+ // 1b-cursor. Non-layout Cursor side-effects (issue #777): remove GSD-managed
8301
+ // hook entries from hooks.json and clean up the managed hook scripts.
8302
+ if (isCursor) {
8303
+ const hooksJsonCleanup = removeCursorHooksJson(targetDir);
8304
+ if (hooksJsonCleanup.changed) {
8305
+ removedCount++;
8306
+ console.log(` ${green}✓${reset} Removed GSD-managed Cursor hooks from hooks.json`);
8307
+ }
8308
+ // Remove the managed hook scripts (session-start + post-tool).
8309
+ const hooksDir = path.join(targetDir, 'hooks');
8310
+ for (const script of [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT]) {
8311
+ const p = path.join(hooksDir, script);
8312
+ try {
8313
+ if (fs.existsSync(p)) {
8314
+ fs.unlinkSync(p);
8315
+ removedCount++;
8316
+ }
8317
+ } catch { /* best-effort */ }
8318
+ }
8319
+ // Prune hooks/ if empty.
8320
+ try {
8321
+ if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) {
8322
+ fs.rmdirSync(hooksDir);
8323
+ }
8324
+ } catch { /* best-effort */ }
7108
8325
  }
7109
8326
 
7110
8327
  // 1c. Claude local: remove commands/gsd/ (primary local install location).
@@ -7305,8 +8522,14 @@ function uninstall(isGlobal, runtime = 'claude') {
7305
8522
  }
7306
8523
 
7307
8524
  // Remove GSD hooks from settings — per-hook granularity to preserve
7308
- // user hooks that share an entry with a GSD hook (#1755 followup)
7309
- for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) {
8525
+ // user hooks that share an entry with a GSD hook (#1755 followup).
8526
+ // Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop,
8527
+ // PreCompact, also registered for Claude in #770), the 3 Gemini-only
8528
+ // events added in #776 (BeforeAgent, AfterAgent, BeforeModel), and the
8529
+ // Claude-only FileChanged event added in #770 — safe to iterate for all
8530
+ // runtimes; installs that don't register these events simply find no
8531
+ // entries and skip.
8532
+ for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'FileChanged']) {
7310
8533
  if (settings.hooks && settings.hooks[eventName]) {
7311
8534
  const before = JSON.stringify(settings.hooks[eventName]);
7312
8535
  settings.hooks[eventName] = settings.hooks[eventName]
@@ -7339,6 +8562,37 @@ function uninstall(isGlobal, runtime = 'claude') {
7339
8562
  delete settings.hooks;
7340
8563
  }
7341
8564
 
8565
+ // #768 — Remove GSD-owned Claude permissions from settings.json.
8566
+ // Applies only to Claude uninstalls. Filter only the exact GSD-owned entries
8567
+ // to preserve any user-added allow/deny entries.
8568
+ // Uses a local flag to avoid the shared `settingsModified` producing a false
8569
+ // "Removed GSD permissions" message when only hooks/statusline changed.
8570
+ if (runtime === 'claude' && settings.permissions) {
8571
+ let permissionsModified = false;
8572
+ if (Array.isArray(settings.permissions.allow)) {
8573
+ const before = settings.permissions.allow.length;
8574
+ settings.permissions.allow = settings.permissions.allow.filter(
8575
+ (e) => !GSD_CLAUDE_ALLOW_PERMISSIONS.includes(e)
8576
+ );
8577
+ if (settings.permissions.allow.length !== before) {
8578
+ permissionsModified = true;
8579
+ }
8580
+ }
8581
+ if (Array.isArray(settings.permissions.deny)) {
8582
+ const before = settings.permissions.deny.length;
8583
+ settings.permissions.deny = settings.permissions.deny.filter(
8584
+ (e) => !GSD_CLAUDE_DENY_PERMISSIONS.includes(e)
8585
+ );
8586
+ if (settings.permissions.deny.length !== before) {
8587
+ permissionsModified = true;
8588
+ }
8589
+ }
8590
+ if (permissionsModified) {
8591
+ settingsModified = true;
8592
+ console.log(` ${green}✓${reset} Removed GSD permissions from settings.json`);
8593
+ }
8594
+ }
8595
+
7342
8596
  if (settingsModified) {
7343
8597
  writeSettings(settingsPath, settings);
7344
8598
  removedCount++;
@@ -7517,7 +8771,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
7517
8771
  // For local installs, use ./.opencode/
7518
8772
  // For global installs, use ~/.config/opencode/
7519
8773
  const opencodeConfigDir = configDir || (isGlobal
7520
- ? getGlobalDir('opencode', explicitConfigDir)
8774
+ ? getGlobalConfigDir('opencode', explicitConfigDir)
7521
8775
  : path.join(process.cwd(), '.opencode'));
7522
8776
  // Ensure config directory exists
7523
8777
  fs.mkdirSync(opencodeConfigDir, { recursive: true });
@@ -7597,7 +8851,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
7597
8851
  // For local installs, use ./.kilo/
7598
8852
  // For global installs, use ~/.config/kilo/
7599
8853
  const kiloConfigDir = configDir || (isGlobal
7600
- ? getGlobalDir('kilo', explicitConfigDir)
8854
+ ? getGlobalConfigDir('kilo', explicitConfigDir)
7601
8855
  : path.join(process.cwd(), '.kilo'));
7602
8856
  // Ensure config directory exists
7603
8857
  fs.mkdirSync(kiloConfigDir, { recursive: true });
@@ -7871,11 +9125,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7871
9125
  }
7872
9126
  }
7873
9127
  }
7874
- // Track .clinerules file in manifest for Cline installs
9128
+ // Track Cline directory-form artifacts in the manifest (issue #787): the
9129
+ // rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its
9130
+ // marker block, not the per-configDir manifest, since it lives outside it.)
7875
9131
  if (isCline) {
7876
- const clinerulesDest = path.join(configDir, '.clinerules');
7877
- if (fs.existsSync(clinerulesDest)) {
7878
- manifest.files['.clinerules'] = fileHash(clinerulesDest);
9132
+ for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) {
9133
+ const dest = path.join(configDir, rel);
9134
+ if (fs.existsSync(dest)) {
9135
+ manifest.files[rel] = fileHash(dest);
9136
+ }
7879
9137
  }
7880
9138
  }
7881
9139
 
@@ -8240,6 +9498,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8240
9498
  const isHermes = runtime === 'hermes';
8241
9499
  const isCodebuddy = runtime === 'codebuddy';
8242
9500
  const isCline = runtime === 'cline';
9501
+ const configIntent = resolveRuntimeConfigIntent(runtime);
8243
9502
  const dirName = getDirName(runtime);
8244
9503
  const src = path.join(__dirname, '..');
8245
9504
 
@@ -8277,7 +9536,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8277
9536
  // Cline local installs write to the project root (like Claude Code) — .clinerules
8278
9537
  // lives at the root, not inside a .cline/ subdirectory.
8279
9538
  const targetDir = isGlobal
8280
- ? getGlobalDir(runtime, explicitConfigDir)
9539
+ ? getGlobalConfigDir(runtime, explicitConfigDir)
8281
9540
  : isCline
8282
9541
  ? process.cwd()
8283
9542
  : path.join(process.cwd(), dirName);
@@ -8575,6 +9834,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8575
9834
  // agentsSrc is declared here (let, not const) because installCodexConfig() inside the
8576
9835
  // Codex config block below also references it, and that block is outside the try scope.
8577
9836
  let agentsSrc = path.join(src, 'agents');
9837
+ // Capture upgrade signal BEFORE files are written (#683). Must be declared at function
9838
+ // scope (outside the try block below) so it is accessible in the settings section later.
9839
+ // Absent VERSION = fresh install; present VERSION = upgrade/re-install.
9840
+ const priorInstallExisted = fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'));
8578
9841
  try {
8579
9842
  installerMigrationResult = runInstallerMigrations({
8580
9843
  configDir: targetDir,
@@ -8642,7 +9905,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8642
9905
  //
8643
9906
  // Non-layout side-effects preserved inline:
8644
9907
  // Hermes: writeHermesCategoryDescription (not a layout kind)
8645
- // Cline: no-op (cline layout has empty kinds[])
9908
+ // Cline global: skills emitted via layout; .clinerules still written below (#782)
9909
+ // Cline local: no skills (only .clinerules) — falls through to cline-rules surface
8646
9910
  // Gemini: conflict-detection logic (not expressible in layout)
8647
9911
  // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
8648
9912
  // Claude local: copyWithPathReplacement + stale-skills cleanup
@@ -8650,15 +9914,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8650
9914
  // Layout-driven path for all skills-based runtimes (full and minimal modes).
8651
9915
  // applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts)
8652
9916
  // handles per-runtime path + branding rewrites, including Qwen/Hermes.
9917
+ // Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
8653
9918
  const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
8654
9919
  isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
8655
- (runtime === 'claude' && isGlobal);
9920
+ (runtime === 'claude' && isGlobal) ||
9921
+ (isCline && isGlobal);
8656
9922
 
8657
9923
  if (_isSkillsRuntime) {
8658
9924
  // Layout-driven install for skills-based runtimes (full and minimal modes)
8659
9925
  const scope = isGlobal ? 'global' : 'local';
8660
9926
  installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
8661
9927
 
9928
+ // #774 — Codex only: write agents/openai.yaml TUI chip metadata alongside each
9929
+ // installed skill so the /skills popup shows name + description for each gsd-* skill.
9930
+ // The SkillMetadataFile is loaded by codex-rs/core-skills/src/loader.rs from
9931
+ // <skill-dir>/agents/openai.yaml; absence is silently tolerated (fails open).
9932
+ // We parse the SKILL.md frontmatter to extract short-description already emitted
9933
+ // by convertClaudeCommandToCodexSkill and use it as the TUI chip description.
9934
+ if (isCodex) {
9935
+ writeCodexSkillMetadataFiles(path.join(targetDir, 'skills'));
9936
+ }
9937
+
8662
9938
  // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
8663
9939
  if (isHermes) {
8664
9940
  writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
@@ -8692,6 +9968,53 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8692
9968
  } else {
8693
9969
  failures.push('skills/gsd-*');
8694
9970
  }
9971
+ // Augment: also verify commands/ (emitted alongside skills/)
9972
+ if (isAugment) {
9973
+ const commandsDir = path.join(targetDir, 'commands');
9974
+ if (fs.existsSync(commandsDir)) {
9975
+ const cmdCount = fs.readdirSync(commandsDir)
9976
+ .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
9977
+ if (cmdCount > 0) {
9978
+ console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/`);
9979
+ } else {
9980
+ failures.push('commands/gsd-*');
9981
+ }
9982
+ } else {
9983
+ failures.push('commands/gsd-*');
9984
+ }
9985
+ }
9986
+
9987
+ // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands)
9988
+ if (isCursor) {
9989
+ const commandsDir = path.join(targetDir, 'commands');
9990
+ if (fs.existsSync(commandsDir)) {
9991
+ const cmdCount = fs.readdirSync(commandsDir)
9992
+ .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
9993
+ if (cmdCount > 0) {
9994
+ console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
9995
+ } else {
9996
+ failures.push('commands/gsd-*');
9997
+ }
9998
+ } else {
9999
+ failures.push('commands/gsd-*');
10000
+ }
10001
+ }
10002
+
10003
+ // CodeBuddy only: also report the commands/ output (#789 — slash commands)
10004
+ if (isCodebuddy) {
10005
+ const commandsDir = path.join(targetDir, 'commands');
10006
+ if (fs.existsSync(commandsDir)) {
10007
+ const cmdCount = fs.readdirSync(commandsDir)
10008
+ .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
10009
+ if (cmdCount > 0) {
10010
+ console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`);
10011
+ } else {
10012
+ failures.push('commands/gsd-*');
10013
+ }
10014
+ } else {
10015
+ failures.push('commands/gsd-*');
10016
+ }
10017
+ }
8695
10018
  }
8696
10019
  } else if (isOpencode || isKilo) {
8697
10020
  // OpenCode/Kilo: flat structure in command/ directory
@@ -8707,9 +10030,21 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8707
10030
  } else {
8708
10031
  failures.push('command/gsd-*');
8709
10032
  }
10033
+
10034
+ // Also emit OpenCode-family skills (skills/<name>/SKILL.md). OpenCode and
10035
+ // Kilo support native, on-demand skills in addition to flat commands — see
10036
+ // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from
10037
+ // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784)
10038
+ const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix);
10039
+ if (_skillCount > 0) {
10040
+ console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`);
10041
+ } else {
10042
+ failures.push('skills/gsd-*');
10043
+ }
8710
10044
  } else if (isCline) {
8711
- // Cline is rules-based — commands are embedded in .clinerules (generated below).
8712
- // No skills/commands directory needed. Engine is installed via copyWithPathReplacement.
10045
+ // Cline local install: rules-based only — commands are embedded in .clinerules (generated below).
10046
+ // No skills/commands directory needed for local installs.
10047
+ // Global installs are handled above by _isSkillsRuntime (#782).
8713
10048
  console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
8714
10049
  } else if (isGemini) {
8715
10050
  // #3037: when running --local --gemini and a GSD-managed user-scope
@@ -9089,8 +10424,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9089
10424
  }
9090
10425
 
9091
10426
  // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
9092
- // Codex/Copilot/Cursor/Windsurf/Trae/Cline skip hooks entirely, so they must not
9093
- // receive the hooks/lib/ helpers either — otherwise the Codex comment downstream
10427
+ // Codex/Copilot/Cursor/Windsurf/Trae/Cline do not use the shared hooks/lib/ helpers
10428
+ // (Cursor uses standalone .js hook scripts registered via hooks.json; Codex uses
10429
+ // hooks.json directly; the others skip hooks entirely), so they must not receive
10430
+ // the hooks/lib/ helpers — otherwise the Codex comment downstream
9094
10431
  // ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
9095
10432
  const hooksLibSrc = path.join(src, 'hooks', 'lib');
9096
10433
  if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
@@ -9196,7 +10533,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9196
10533
  throw _earlyInstallErr;
9197
10534
  }
9198
10535
 
9199
- if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
10536
+ if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
9200
10537
  // Capture pre-install snapshots before ANY GSD mutation
9201
10538
  // (#2760 fix 3). On post-write schema-validation failure OR any throw
9202
10539
  // during the mutation sequence (write failure, merge throw, etc.) we
@@ -9378,10 +10715,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9378
10715
  }
9379
10716
 
9380
10717
  // Copy only the hook files that Codex actually registers via its hook configuration (#2153).
9381
- // Codex primarily needs gsd-check-update.js for the SessionStart update-check hook.
10718
+ // #772: added gsd-context-monitor.js for the new SubagentStart/Stop/PostToolUse events.
9382
10719
  // We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
9383
10720
  // in this change (graphify auto-update support for Codex is out of scope for #3579).
9384
- const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js'];
10721
+ const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js'];
9385
10722
  const codexHooksSrc = path.join(src, 'hooks', 'dist');
9386
10723
  if (fs.existsSync(codexHooksSrc)) {
9387
10724
  const codexHooksDest = path.join(targetDir, 'hooks');
@@ -9511,6 +10848,39 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9511
10848
  console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`);
9512
10849
  }
9513
10850
  }
10851
+
10852
+ // ── Codex extended hook events (#772) ────────────────────────────────
10853
+ // Codex CLI stabilised a full hook-event set in rust-v0.137.0. Register
10854
+ // three new high-value lifecycle events — all routed through
10855
+ // gsd-context-monitor.js so context-headroom warnings surface at:
10856
+ // SubagentStart — subagent session open (environment / agent-name aware)
10857
+ // Stop — model stop / session final-response moment
10858
+ // PostToolUse — after each tool invocation (mirrors Claude baseline)
10859
+ //
10860
+ // Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
10861
+ // tool_name is Write|Edit (PreToolUse payload shape), so it would be a
10862
+ // silent no-op for the UserPromptSubmit payload. Registration deferred
10863
+ // to a follow-on issue.
10864
+ //
10865
+ // Guard: only register when the context-monitor file exists and the node
10866
+ // runner is available — same guards as the SessionStart path above.
10867
+ const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
10868
+ if (codexNodeRunner && fs.existsSync(contextMonitorFile)) {
10869
+ for (const codexEvent of ['SubagentStart', 'Stop', 'PostToolUse']) {
10870
+ const eventWrite = ensureCodexHooksJsonEvent(targetDir, codexEvent, {
10871
+ absoluteRunner: codexNodeRunner,
10872
+ platform: process.platform,
10873
+ });
10874
+ if (eventWrite.wrote) {
10875
+ console.log(` ${green}✓${reset} Configured Codex hooks (${codexEvent} via hooks.json)`);
10876
+ } else if (eventWrite.changed) {
10877
+ console.log(` ${green}✓${reset} Verified Codex hooks (${codexEvent} via hooks.json)`);
10878
+ }
10879
+ }
10880
+ } else if (!codexNodeRunner) {
10881
+ console.warn(` ${yellow}⚠${reset} Skipped Codex SubagentStart/Stop/PostToolUse hook registration — Node runner unavailable.`);
10882
+ }
10883
+ // ── end Codex extended hook events ────────────────────────────────────
9514
10884
  }
9515
10885
  } catch (e) {
9516
10886
  // #2760 — schema-validation and write failures must be loud and fatal
@@ -9542,7 +10912,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9542
10912
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9543
10913
  }
9544
10914
 
9545
- if (isCopilot) {
10915
+ if (configIntent.installSurface === 'copilot-instructions') {
9546
10916
  // Generate copilot-instructions.md
9547
10917
  const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
9548
10918
  const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -9550,47 +10920,57 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9550
10920
  const template = fs.readFileSync(templatePath, 'utf8');
9551
10921
  mergeCopilotInstructions(instructionsPath, template);
9552
10922
  console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
9553
- }
9554
- // Copilot: no settings.json, no hooks, no statusline (like Codex)
9555
- persistActiveProfileMarker();
9556
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9557
- }
9558
-
9559
- if (isCursor) {
9560
- // Cursor uses skills — no config.toml, no settings.json hooks needed
10923
+ // #786: also emit AGENTS.md, which Copilot CLI reads as primary
10924
+ // instructions from the repository root. AGENTS.md is a repo-root concept
10925
+ // (no documented user-scope home), so emit it only for local installs;
10926
+ // global scope is already covered by ~/.copilot/copilot-instructions.md.
10927
+ if (!isGlobal) {
10928
+ const agentsMdPath = path.join(process.cwd(), 'AGENTS.md');
10929
+ mergeCopilotInstructions(agentsMdPath, template);
10930
+ console.log(` ${green}✓${reset} Generated AGENTS.md`);
10931
+ }
10932
+ }
10933
+ // #786: emit a self-contained Copilot lifecycle hook (sessionStart). Copilot
10934
+ // command hooks run inline bash/powershell, so this needs no separate hook
10935
+ // script and cannot dangle. Repo scope → .github/hooks/, user → ~/.copilot/hooks/.
10936
+ // The hook is a required install artifact, so a write failure is fatal (it
10937
+ // propagates) rather than silently producing a "successful" install missing
10938
+ // the feature.
10939
+ writeCopilotHookConfig(targetDir);
10940
+ console.log(` ${green}✓${reset} Configured Copilot lifecycle hook (sessionStart)`);
9561
10941
  persistActiveProfileMarker();
9562
10942
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9563
10943
  }
9564
10944
 
9565
- if (isWindsurf) {
9566
- // Windsurf uses skills — no config.toml, no settings.json hooks needed
10945
+ if (configIntent.installSurface === 'cursor-hooks-json') {
10946
+ // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
10947
+ // Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
10948
+ const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
10949
+ if (cursorHookResult.changed) {
10950
+ console.log(` ${green}✓${reset} Configured Cursor lifecycle hooks (sessionStart, postToolUse)`);
10951
+ } else {
10952
+ console.log(` ${green}✓${reset} Cursor lifecycle hooks already up to date`);
10953
+ }
10954
+ // Re-run the manifest pass so the hook scripts + hooks.json are hash-tracked.
10955
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
9567
10956
  persistActiveProfileMarker();
9568
10957
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9569
10958
  }
9570
10959
 
9571
- if (isTrae) {
9572
- // Trae uses skills — no settings.json hooks needed
10960
+ if (configIntent.installSurface === 'profile-marker-only') {
10961
+ // Windsurf/Trae use skills — no config.toml, no settings.json hooks needed
9573
10962
  persistActiveProfileMarker();
9574
10963
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9575
10964
  }
9576
10965
 
9577
- if (isCline) {
9578
- // Cline uses .clinerules — generate a rules file with GSD system instructions
9579
- const clinerulesDest = path.join(targetDir, '.clinerules');
9580
- const clinerules = [
9581
- '# GSD Core — Git. Ship. Done.',
9582
- '',
9583
- '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
9584
- ' the user runs a `/gsd-*` command.',
9585
- '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
9586
- '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
9587
- '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
9588
- '- Do not apply GSD workflows unless the user explicitly asks for them.',
9589
- '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
9590
- ' step to the user using Cline\'s ask_user tool after completing it.',
9591
- ].join('\n') + '\n';
9592
- fs.writeFileSync(clinerulesDest, clinerules);
9593
- console.log(` ${green}✓${reset} Wrote .clinerules`);
10966
+ if (configIntent.installSurface === 'cline-rules') {
10967
+ // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
10968
+ // at .clinerules/gsd.md and a PreToolUse lifecycle hook at
10969
+ // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
10970
+ writeClineArtifacts(targetDir, isGlobal);
10971
+ // Re-run the manifest pass: these artifacts are written *after* the earlier
10972
+ // writeManifest() call, so a second pass is needed to hash-track them.
10973
+ writeManifest(targetDir, runtime, { mode: _effectiveInstallMode });
9594
10974
  persistActiveProfileMarker();
9595
10975
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9596
10976
  }
@@ -9741,6 +11121,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9741
11121
  const readInjectionScannerCommand = isGlobal
9742
11122
  ? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts)
9743
11123
  : localCmd('gsd-read-injection-scanner.js');
11124
+ const configReloadCommand = isGlobal
11125
+ ? buildHookCommand(targetDir, 'gsd-config-reload.js', hookOpts)
11126
+ : localCmd('gsd-config-reload.js');
9744
11127
 
9745
11128
  // #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook
9746
11129
  // command is null too. Emit one warning here so the operator sees the cause
@@ -10094,7 +11477,155 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10094
11477
  } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
10095
11478
  console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
10096
11479
  }
11480
+
11481
+ // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
11482
+ // Claude Code (since #770) and Qwen Code (since #788) both support these
11483
+ // three lifecycle events. Wire gsd-context-monitor so agents get context-
11484
+ // headroom warnings at subagent completion, model stop, and pre-compaction
11485
+ // (the most critical moment to surface headroom info).
11486
+ //
11487
+ // SubagentStop — subagent lifecycle completion (context headroom tracking)
11488
+ // Stop — model stop / final-response moment (context headroom)
11489
+ // PreCompact — fires before conversation compaction (most critical
11490
+ // moment to surface context headroom warnings)
11491
+ //
11492
+ // Note: UserPromptSubmit is NOT wired here. That event carries the raw
11493
+ // user prompt text, not a tool invocation, so gsd-prompt-guard (which
11494
+ // exits unless tool_name is Write/Edit) would be a silent no-op. A
11495
+ // dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
11496
+ if (isQwen || runtime === 'claude') {
11497
+ const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code';
11498
+ // SubagentStop, Stop, PreCompact — route through the context monitor.
11499
+ for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
11500
+ if (!settings.hooks[event]) {
11501
+ settings.hooks[event] = [];
11502
+ }
11503
+ const alreadyHasContextMonitor = settings.hooks[event].some(entry =>
11504
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
11505
+ );
11506
+ if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
11507
+ settings.hooks[event].push({
11508
+ hooks: [
11509
+ {
11510
+ type: 'command',
11511
+ command: contextMonitorCommand,
11512
+ timeout: 10
11513
+ }
11514
+ ]
11515
+ });
11516
+ console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
11517
+ } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
11518
+ console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
11519
+ }
11520
+ }
11521
+ }
11522
+ // ── end SubagentStop / Stop / PreCompact events ────────────────────────────
11523
+
11524
+ // ── Gemini-only extended hook events (#776) ───────────────────────────────
11525
+ // Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
11526
+ // gsd previously did not register. Three high-value events are added here:
11527
+ //
11528
+ // BeforeAgent — fires after user submits a prompt, before the agent
11529
+ // plans. Wire gsd-context-monitor for context headroom
11530
+ // awareness at prompt time.
11531
+ // AfterAgent — fires once per turn after the model generates its final
11532
+ // response. Wire gsd-context-monitor to track headroom
11533
+ // after each agent turn completes.
11534
+ // BeforeModel — fires before each LLM call (per-turn, not per-session).
11535
+ // Wire gsd-context-monitor for per-turn context injection
11536
+ // — more precise than session-start-only injection.
11537
+ //
11538
+ // All three reuse gsd-context-monitor.js — no new hook files needed.
11539
+ // The `decision:"deny"` retry capability of AfterAgent is intentionally
11540
+ // left to the hook script to implement when triggered (gsd-context-monitor
11541
+ // exits 0 / advisory-only today; an active quality gate is a follow-on).
11542
+ //
11543
+ // Note: BeforeToolSelection is NOT wired. That event does not map to a
11544
+ // gsd hook use case at this time; deferred to a follow-on issue.
11545
+ //
11546
+ // Guard: isGemini is defined at the top of install() (line ~8696).
11547
+ if (isGemini) {
11548
+ for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
11549
+ if (!Array.isArray(settings.hooks[geminiEvent])) {
11550
+ settings.hooks[geminiEvent] = [];
11551
+ }
11552
+ const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
11553
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
11554
+ );
11555
+ if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
11556
+ settings.hooks[geminiEvent].push({
11557
+ hooks: [
11558
+ {
11559
+ type: 'command',
11560
+ command: contextMonitorCommand,
11561
+ timeout: 10
11562
+ }
11563
+ ]
11564
+ });
11565
+ console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
11566
+ } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
11567
+ console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
11568
+ }
11569
+ }
11570
+ }
11571
+ // ── end Gemini-only extended hook events ──────────────────────────────────
11572
+
11573
+ // ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
11574
+ // Claude Code fires FileChanged when a watched file changes on disk. Wire
11575
+ // gsd-config-reload.js to reload the gsd config context whenever the user
11576
+ // edits .planning/config.json mid-session, eliminating the need to restart.
11577
+ //
11578
+ // The matcher "config.json" watches for changes to any file named config.json
11579
+ // (Claude Code matches by filename, not full path). The hook exits silently
11580
+ // when the changed file is not the gsd config.
11581
+ //
11582
+ // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
11583
+ // verified; extend in a follow-on if empirically confirmed.
11584
+ if (runtime === 'claude') {
11585
+ if (!settings.hooks.FileChanged) {
11586
+ settings.hooks.FileChanged = [];
11587
+ }
11588
+ const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js');
11589
+ const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry =>
11590
+ entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-config-reload'))
11591
+ );
11592
+ if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) {
11593
+ settings.hooks.FileChanged.push({
11594
+ matcher: 'config.json',
11595
+ hooks: [
11596
+ {
11597
+ type: 'command',
11598
+ command: configReloadCommand,
11599
+ timeout: 8
11600
+ }
11601
+ ]
11602
+ });
11603
+ console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
11604
+ } else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) {
11605
+ console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
11606
+ } else if (!alreadyHasConfigReload && !configReloadCommand) {
11607
+ console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
11608
+ }
11609
+ }
11610
+ // ── end FileChanged hook ────────────────────────────────────────────────────
11611
+ }
11612
+
11613
+ // ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
11614
+ // Detect `hooksConfig.enabled: false` in the already-loaded settings object
11615
+ // and emit a clear warning. When this field is false the Gemini CLI silently
11616
+ // disables ALL hook execution — gsd hooks are registered but will never run.
11617
+ // The check is read-only (warning only; we do not mutate hooksConfig).
11618
+ // Note: we use the in-memory `settings` object (already read from disk and
11619
+ // cleaned up by validateHookFields/cleanupOrphanedHooks above) rather than
11620
+ // re-reading settings.json, avoiding a TOCTOU window between the two reads.
11621
+ if (isGemini && settings && settings.hooksConfig && settings.hooksConfig.enabled === false) {
11622
+ console.warn(
11623
+ ` ${yellow}⚠${reset} Warning: hooksConfig.enabled is false in your Gemini settings.json.\n` +
11624
+ ` gsd-core hooks are registered but will NOT run until you set\n` +
11625
+ ` hooksConfig.enabled: true in ${path.join(targetDir, 'settings.json')}.`
11626
+ );
10097
11627
  }
11628
+ // ── end hooksConfig.enabled check ────────────────────────────────────────
10098
11629
 
10099
11630
  // Compute the update-banner hook command alongside the others so
10100
11631
  // installAllRuntimes can register it at finalize time when the user opts
@@ -10106,6 +11637,68 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10106
11637
  ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
10107
11638
  : localCmd('gsd-update-banner.js'));
10108
11639
 
11640
+ // #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
11641
+ // Both fresh and upgrade paths apply only when worktrees are enabled for the project.
11642
+ // Never applies to global installs, non-Claude runtimes, or when the user already
11643
+ // has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber).
11644
+ // Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive)
11645
+ // to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes.
11646
+ if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) {
11647
+ // Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1).
11648
+ // shared settings.json no-clobber is checked here; settings.local.json no-clobber
11649
+ // is enforced inside applyWorktreeBaseRef itself.
11650
+ const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {};
11651
+ const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef);
11652
+
11653
+ // Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency).
11654
+ // Read workflow.use_worktrees from .planning/config.json by walking up from
11655
+ // targetDir (same walk-up pattern as readGsdRuntimeProfileResolver). Defaults
11656
+ // to enabled (true) when the file is missing, unreadable, or the key is absent;
11657
+ // only boolean false disables (string "false" stays enabled).
11658
+ let worktreesEnabled = true; // default: enabled
11659
+ try {
11660
+ let probeDir = path.resolve(targetDir);
11661
+ for (let depth = 0; depth < 8; depth += 1) {
11662
+ const candidate = path.join(probeDir, '.planning', 'config.json');
11663
+ if (fs.existsSync(candidate)) {
11664
+ try {
11665
+ const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8')));
11666
+ if (parsed && typeof parsed === 'object' &&
11667
+ parsed.workflow && parsed.workflow.use_worktrees === false) {
11668
+ worktreesEnabled = false;
11669
+ }
11670
+ } catch {
11671
+ // Malformed config.json — treat as enabled (safe fallback).
11672
+ }
11673
+ break;
11674
+ }
11675
+ const parent = path.dirname(probeDir);
11676
+ if (parent === probeDir) break;
11677
+ probeDir = parent;
11678
+ }
11679
+ } catch {
11680
+ // Any unexpected error reading .planning — default to enabled.
11681
+ }
11682
+
11683
+ if (worktreesEnabled && sharedBaseRef === null) {
11684
+ if (!priorInstallExisted) {
11685
+ // Fresh install — apply no-clobber baseRef set.
11686
+ // canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683)
11687
+ const { changed } = applyWorktreeBaseRef(settings);
11688
+ if (changed) {
11689
+ console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
11690
+ }
11691
+ } else {
11692
+ // Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled.
11693
+ const { changed } = applyWorktreeBaseRef(settings);
11694
+ if (changed) {
11695
+ console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
11696
+ }
11697
+ }
11698
+ }
11699
+ // When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade).
11700
+ }
11701
+
10109
11702
  persistActiveProfileMarker();
10110
11703
  return {
10111
11704
  settingsPath,
@@ -10130,6 +11723,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10130
11723
  const isWindsurf = runtime === 'windsurf';
10131
11724
  const isTrae = runtime === 'trae';
10132
11725
  const isCline = runtime === 'cline';
11726
+ const configIntent = resolveRuntimeConfigIntent(runtime);
10133
11727
 
10134
11728
  if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
10135
11729
  if (!isGlobal && !forceStatusline) {
@@ -10182,6 +11776,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10182
11776
  }
10183
11777
  }
10184
11778
 
11779
+ // #768 — Pre-populate permissions.allow/deny for Claude Code installs.
11780
+ // Merges GSD-owned entries non-destructively (preserves existing user permissions).
11781
+ // Scoped to Claude only: gemini/antigravity/qwen/hermes/codebuddy also write
11782
+ // settings.json but use different runtimes and do not use these permission strings.
11783
+ if (runtime === 'claude') {
11784
+ mergeClaudePermissions(settings);
11785
+ }
11786
+
10185
11787
  // Write settings when runtime supports settings.json.
10186
11788
  // #3002 CR: defense-in-depth — re-run validateHookFields right before
10187
11789
  // serialization. The push-site guards above already skip null-command
@@ -10189,17 +11791,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10189
11791
  // {type: 'command', command: null} items that the runtime hook schema
10190
11792
  // rejects at parse time. validateHookFields filters those out so the file
10191
11793
  // we write is always schema-valid.
10192
- if (!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline) {
11794
+ if (configIntent.writesSharedSettings) {
10193
11795
  writeSettings(settingsPath, validateHookFields(settings));
10194
11796
  }
10195
11797
 
10196
11798
  // Configure OpenCode permissions
10197
- if (isOpencode && !process.env.GSD_TEST_MODE) {
11799
+ if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
10198
11800
  configureOpencodePermissions(isGlobal, configDir);
10199
11801
  }
10200
11802
 
10201
11803
  // Configure Kilo permissions
10202
- if (isKilo) {
11804
+ if (configIntent.finishPermissionWriter === 'kilo') {
10203
11805
  configureKiloPermissions(isGlobal, configDir);
10204
11806
  }
10205
11807
 
@@ -10543,7 +12145,7 @@ function promptLocation(runtimes) {
10543
12145
  });
10544
12146
 
10545
12147
  const pathExamples = runtimes.map(r => {
10546
- const globalPath = getGlobalDir(r, explicitConfigDir);
12148
+ const globalPath = getGlobalConfigDir(r, explicitConfigDir);
10547
12149
  return globalPath.replace(os.homedir(), '~');
10548
12150
  }).join(', ');
10549
12151
 
@@ -10915,8 +12517,10 @@ module.exports = {
10915
12517
  normalizeAgentBodyForRuntime,
10916
12518
  yamlIdentifier,
10917
12519
  computePathPrefix,
12520
+ applyRuntimeContentRewritesInPlace,
10918
12521
  getCodexSkillAdapterHeader,
10919
12522
  convertClaudeCommandToCursorSkill,
12523
+ convertClaudeCommandToCursorCommand,
10920
12524
  convertClaudeAgentToCursorAgent,
10921
12525
  convertClaudeToGeminiMarkdown,
10922
12526
  convertSlashCommandsToGeminiMentions,
@@ -10924,6 +12528,8 @@ module.exports = {
10924
12528
  convertClaudeToGeminiAgent,
10925
12529
  convertClaudeAgentToCodexAgent,
10926
12530
  generateCodexAgentToml,
12531
+ generateCodexSkillMetadataYaml,
12532
+ writeCodexSkillMetadataFiles,
10927
12533
  generateCodexConfigBlock,
10928
12534
  stripGsdFromCodexConfig,
10929
12535
  migrateCodexHooksMapFormat,
@@ -10943,15 +12549,21 @@ module.exports = {
10943
12549
  install,
10944
12550
  installAllRuntimes,
10945
12551
  uninstall,
12552
+ convertSlashCommandsToCodexSkillMentions,
10946
12553
  convertClaudeCommandToCodexSkill,
10947
12554
  convertClaudeToOpencodeFrontmatter,
10948
12555
  convertClaudeToKiloFrontmatter,
12556
+ convertClaudeCommandToOpencodeSkill,
12557
+ convertClaudeCommandToKiloSkill,
10949
12558
  configureOpencodePermissions,
10950
12559
  neutralizeAgentReferences,
12560
+ // #768 — Claude Code permissions pre-population
12561
+ mergeClaudePermissions,
12562
+ GSD_CLAUDE_ALLOW_PERMISSIONS,
12563
+ GSD_CLAUDE_DENY_PERMISSIONS,
10951
12564
  GSD_CODEX_MARKER,
10952
12565
  CODEX_AGENT_SANDBOX,
10953
12566
  getDirName,
10954
- getGlobalDir,
10955
12567
  getConfigDirFromHome,
10956
12568
  resolveKiloConfigPath,
10957
12569
  configureKiloPermissions,
@@ -10964,6 +12576,9 @@ module.exports = {
10964
12576
  GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
10965
12577
  mergeCopilotInstructions,
10966
12578
  stripGsdFromCopilotInstructions,
12579
+ GSD_COPILOT_HOOK_FILE,
12580
+ buildCopilotHookConfig,
12581
+ writeCopilotHookConfig,
10967
12582
  convertClaudeToAntigravityContent,
10968
12583
  convertClaudeCommandToAntigravitySkill,
10969
12584
  convertClaudeAgentToAntigravityAgent,
@@ -10980,9 +12595,27 @@ module.exports = {
10980
12595
  convertClaudeAgentToTraeAgent,
10981
12596
  convertClaudeToCodebuddyMarkdown,
10982
12597
  convertClaudeCommandToCodebuddySkill,
12598
+ convertClaudeCommandToCodebuddyCommand,
10983
12599
  convertClaudeAgentToCodebuddyAgent,
10984
12600
  convertClaudeToCliineMarkdown,
12601
+ convertClaudeCommandToClineSkill,
10985
12602
  convertClaudeAgentToClineAgent,
12603
+ buildClineRulesBody,
12604
+ buildClineAgentsMdBody,
12605
+ buildClinePreToolUseHook,
12606
+ writeClineArtifacts,
12607
+ mergeGsdAgentsMd,
12608
+ GSD_CURSOR_SESSION_HOOK_SCRIPT,
12609
+ GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
12610
+ GSD_CURSOR_HOOK_MARKER,
12611
+ buildCursorHookEntry,
12612
+ isManagedCursorHookEntry,
12613
+ reconcileCursorHooksJson,
12614
+ writeCursorHooksJson,
12615
+ removeCursorHooksJson,
12616
+ stripGsdFromAgentsMd,
12617
+ GSD_AGENTS_MD_MARKER,
12618
+ GSD_AGENTS_MD_CLOSE_MARKER,
10986
12619
  writeManifest,
10987
12620
  saveLocalPatches,
10988
12621
  reportLocalPatches,
@@ -11011,11 +12644,16 @@ module.exports = {
11011
12644
  rewriteLegacyCodexHookBlock,
11012
12645
  buildCodexHookWindowsShimIR,
11013
12646
  ensureCodexHooksJsonSessionStart,
12647
+ ensureCodexHooksJsonEvent,
12648
+ removeCodexHooksJsonEvent,
12649
+ reconcileCodexHooksJsonEvent,
11014
12650
  readGsdCommandNames,
11015
12651
  installRuntimeArtifacts,
12652
+ installOpencodeFamilySkills,
11016
12653
  uninstallRuntimeArtifacts,
11017
12654
  parseConfigDirFromArgs,
11018
12655
  cleanupLegacyGsdCc,
12656
+ _applyRuntimeRewrites,
11019
12657
  };
11020
12658
 
11021
12659
  // Main logic — only run when not loaded as a module for testing
@@ -11042,7 +12680,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11042
12680
  console.error('Usage: node install.js --skills-root <runtime>');
11043
12681
  process.exit(1);
11044
12682
  }
11045
- const globalDir = getGlobalDir(runtimeArg, null);
12683
+ const globalDir = getGlobalConfigDir(runtimeArg, null);
11046
12684
  // Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
11047
12685
  // Other runtimes use a flat skills/ root.
11048
12686
  const skillsRoot = runtimeArg === 'hermes'