@opengsd/gsd-core 1.4.0-rc.1 → 1.4.0

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 (54) hide show
  1. package/.claude-plugin/plugin.json +23 -0
  2. package/GEMINI.md +53 -0
  3. package/agents/gsd-ai-researcher.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-domain-researcher.md +1 -1
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-eval-planner.md +1 -1
  9. package/agents/gsd-framework-selector.md +1 -1
  10. package/agents/gsd-nyquist-auditor.md +1 -1
  11. package/agents/gsd-pattern-mapper.md +1 -1
  12. package/agents/gsd-security-auditor.md +1 -1
  13. package/agents/gsd-ui-auditor.md +1 -1
  14. package/agents/gsd-ui-checker.md +1 -1
  15. package/agents/gsd-ui-researcher.md +1 -1
  16. package/agents/gsd-user-profiler.md +1 -1
  17. package/bin/install.js +1911 -354
  18. package/commands/gsd/autonomous.md +2 -0
  19. package/commands/gsd/execute-phase.md +2 -0
  20. package/commands/gsd/plan-phase.md +2 -0
  21. package/commands/gsd/progress.md +1 -0
  22. package/commands/gsd/stats.md +1 -0
  23. package/commands/gsd/update.md +3 -2
  24. package/gemini-extension.json +6 -0
  25. package/gsd-core/bin/check-latest-version.cjs +58 -4
  26. package/gsd-core/bin/lib/install-profiles.cjs +58 -0
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  28. package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +1 -1
  29. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -9
  30. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +56 -0
  31. package/gsd-core/bin/lib/runtime-homes.cjs +32 -11
  32. package/gsd-core/bin/lib/shell-command-projection.cjs +6 -0
  33. package/gsd-core/bin/lib/surface.cjs +54 -11
  34. package/gsd-core/workflows/help/modes/full.md +2 -1
  35. package/gsd-core/workflows/review.md +2 -2
  36. package/gsd-core/workflows/update.md +32 -5
  37. package/hooks/dist/gsd-config-reload.js +133 -0
  38. package/hooks/dist/gsd-cursor-post-tool.js +75 -0
  39. package/hooks/dist/gsd-cursor-session-start.js +52 -0
  40. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  41. package/hooks/gsd-config-reload.js +133 -0
  42. package/hooks/gsd-cursor-post-tool.js +75 -0
  43. package/hooks/gsd-cursor-session-start.js +52 -0
  44. package/hooks/hooks.json +69 -0
  45. package/hooks/managed-hooks-registry.cjs +3 -0
  46. package/package.json +5 -1
  47. package/scripts/build-hooks.js +7 -0
  48. package/scripts/changeset/cli.cjs +53 -10
  49. package/scripts/ci-test-scope.cjs +120 -18
  50. package/scripts/issue-dedupe.cjs +278 -0
  51. package/scripts/lint-test-file-count.allowlist.json +1 -0
  52. package/scripts/release-notes/discord-release-summary.cjs +373 -0
  53. package/scripts/research-profiles.cjs +3 -3
  54. package/scripts/sync-manifest-versions.cjs +119 -0
package/bin/install.js CHANGED
@@ -30,11 +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');
34
35
  const {
35
36
  applyWorktreeBaseRef,
36
37
  readBaseRefFromSettings,
37
38
  } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
39
+ const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
38
40
 
39
41
  /**
40
42
  * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
@@ -95,10 +97,112 @@ function isCodexHooksFeatureKey(key) {
95
97
  return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key);
96
98
  }
97
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
+
98
163
  // Copilot instructions marker constants
99
164
  const GSD_COPILOT_INSTRUCTIONS_MARKER = '<!-- GSD Configuration \u2014 managed by gsd-core installer -->';
100
165
  const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = '<!-- /GSD Configuration -->';
101
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
+
102
206
  // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks).
103
207
  // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does.
104
208
  const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh'];
@@ -407,218 +511,6 @@ function getConfigDirFromHome(runtime, isGlobal) {
407
511
  return "'.claude'";
408
512
  }
409
513
 
410
- /**
411
- * Get the global config directory for OpenCode
412
- * OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
413
- * Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
414
- */
415
- function getOpencodeGlobalDir() {
416
- // 1. Explicit OPENCODE_CONFIG_DIR env var
417
- if (process.env.OPENCODE_CONFIG_DIR) {
418
- return expandTilde(process.env.OPENCODE_CONFIG_DIR);
419
- }
420
-
421
- // 2. OPENCODE_CONFIG env var (use its directory)
422
- if (process.env.OPENCODE_CONFIG) {
423
- return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
424
- }
425
-
426
- // 3. XDG_CONFIG_HOME/opencode
427
- if (process.env.XDG_CONFIG_HOME) {
428
- return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
429
- }
430
-
431
- // 4. Default: ~/.config/opencode (XDG default)
432
- return path.join(os.homedir(), '.config', 'opencode');
433
- }
434
-
435
- /**
436
- * Get the global config directory for Kilo
437
- * Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
438
- * Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
439
- */
440
- function getKiloGlobalDir() {
441
- // 1. Explicit KILO_CONFIG_DIR env var
442
- if (process.env.KILO_CONFIG_DIR) {
443
- return expandTilde(process.env.KILO_CONFIG_DIR);
444
- }
445
-
446
- // 2. KILO_CONFIG env var (use its directory)
447
- if (process.env.KILO_CONFIG) {
448
- return path.dirname(expandTilde(process.env.KILO_CONFIG));
449
- }
450
-
451
- // 3. XDG_CONFIG_HOME/kilo
452
- if (process.env.XDG_CONFIG_HOME) {
453
- return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
454
- }
455
-
456
- // 4. Default: ~/.config/kilo (XDG default)
457
- return path.join(os.homedir(), '.config', 'kilo');
458
- }
459
-
460
- /**
461
- * Get the global config directory for a runtime
462
- * @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
463
- * @param {string|null} explicitDir - Explicit directory from --config-dir flag
464
- */
465
- function getGlobalDir(runtime, explicitDir = null) {
466
- if (runtime === 'opencode') {
467
- // For OpenCode, --config-dir overrides env vars
468
- if (explicitDir) {
469
- return expandTilde(explicitDir);
470
- }
471
- return getOpencodeGlobalDir();
472
- }
473
-
474
- if (runtime === 'kilo') {
475
- // For Kilo, --config-dir overrides env vars
476
- if (explicitDir) {
477
- return expandTilde(explicitDir);
478
- }
479
- return getKiloGlobalDir();
480
- }
481
-
482
- if (runtime === 'gemini') {
483
- // Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
484
- if (explicitDir) {
485
- return expandTilde(explicitDir);
486
- }
487
- if (process.env.GEMINI_CONFIG_DIR) {
488
- return expandTilde(process.env.GEMINI_CONFIG_DIR);
489
- }
490
- return path.join(os.homedir(), '.gemini');
491
- }
492
-
493
- if (runtime === 'codex') {
494
- // Codex: --config-dir > CODEX_HOME > ~/.codex
495
- if (explicitDir) {
496
- return expandTilde(explicitDir);
497
- }
498
- if (process.env.CODEX_HOME) {
499
- return expandTilde(process.env.CODEX_HOME);
500
- }
501
- return path.join(os.homedir(), '.codex');
502
- }
503
-
504
- if (runtime === 'copilot') {
505
- // Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
506
- if (explicitDir) {
507
- return expandTilde(explicitDir);
508
- }
509
- if (process.env.COPILOT_CONFIG_DIR) {
510
- return expandTilde(process.env.COPILOT_CONFIG_DIR);
511
- }
512
- return path.join(os.homedir(), '.copilot');
513
- }
514
-
515
- if (runtime === 'antigravity') {
516
- // Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
517
- // ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
518
- if (explicitDir) {
519
- return expandTilde(explicitDir);
520
- }
521
- return resolveAntigravityGlobalDir();
522
- }
523
-
524
- if (runtime === 'cursor') {
525
- // Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
526
- if (explicitDir) {
527
- return expandTilde(explicitDir);
528
- }
529
- if (process.env.CURSOR_CONFIG_DIR) {
530
- return expandTilde(process.env.CURSOR_CONFIG_DIR);
531
- }
532
- return path.join(os.homedir(), '.cursor');
533
- }
534
-
535
- if (runtime === 'windsurf') {
536
- // Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
537
- if (explicitDir) {
538
- return expandTilde(explicitDir);
539
- }
540
- if (process.env.WINDSURF_CONFIG_DIR) {
541
- return expandTilde(process.env.WINDSURF_CONFIG_DIR);
542
- }
543
- return path.join(os.homedir(), '.codeium', 'windsurf');
544
- }
545
-
546
- if (runtime === 'augment') {
547
- // Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
548
- if (explicitDir) {
549
- return expandTilde(explicitDir);
550
- }
551
- if (process.env.AUGMENT_CONFIG_DIR) {
552
- return expandTilde(process.env.AUGMENT_CONFIG_DIR);
553
- }
554
- return path.join(os.homedir(), '.augment');
555
- }
556
- if (runtime === 'trae') {
557
- // Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
558
- if (explicitDir) {
559
- return expandTilde(explicitDir);
560
- }
561
- if (process.env.TRAE_CONFIG_DIR) {
562
- return expandTilde(process.env.TRAE_CONFIG_DIR);
563
- }
564
- return path.join(os.homedir(), '.trae');
565
- }
566
-
567
- if (runtime === 'qwen') {
568
- if (explicitDir) {
569
- return expandTilde(explicitDir);
570
- }
571
- if (process.env.QWEN_CONFIG_DIR) {
572
- return expandTilde(process.env.QWEN_CONFIG_DIR);
573
- }
574
- return path.join(os.homedir(), '.qwen');
575
- }
576
-
577
- if (runtime === 'hermes') {
578
- // Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
579
- // Honors HERMES_HOME which Hermes users set for profile mode / Docker
580
- // deploys (docs: https://hermes-agent.nousresearch.com/docs).
581
- if (explicitDir) {
582
- return expandTilde(explicitDir);
583
- }
584
- if (process.env.HERMES_HOME) {
585
- return expandTilde(process.env.HERMES_HOME);
586
- }
587
- return path.join(os.homedir(), '.hermes');
588
- }
589
-
590
- if (runtime === 'codebuddy') {
591
- // CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
592
- if (explicitDir) {
593
- return expandTilde(explicitDir);
594
- }
595
- if (process.env.CODEBUDDY_CONFIG_DIR) {
596
- return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
597
- }
598
- return path.join(os.homedir(), '.codebuddy');
599
- }
600
-
601
- if (runtime === 'cline') {
602
- // Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
603
- if (explicitDir) {
604
- return expandTilde(explicitDir);
605
- }
606
- if (process.env.CLINE_CONFIG_DIR) {
607
- return expandTilde(process.env.CLINE_CONFIG_DIR);
608
- }
609
- return path.join(os.homedir(), '.cline');
610
- }
611
-
612
- // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude
613
- if (explicitDir) {
614
- return expandTilde(explicitDir);
615
- }
616
- if (process.env.CLAUDE_CONFIG_DIR) {
617
- return expandTilde(process.env.CLAUDE_CONFIG_DIR);
618
- }
619
- return path.join(os.homedir(), '.claude');
620
- }
621
-
622
514
  const banner = '\n' +
623
515
  cyan + ' ██████╗ ███████╗██████╗\n' +
624
516
  ' ██╔════╝ ██╔════╝██╔══██╗\n' +
@@ -687,20 +579,10 @@ if (hasUninstall) {
687
579
 
688
580
  // Show help if requested
689
581
  if (hasHelp) {
690
- 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`);
691
583
  process.exit(0);
692
584
  }
693
585
 
694
- /**
695
- * Expand ~ to home directory (shell doesn't expand in env vars passed to node)
696
- */
697
- function expandTilde(filePath) {
698
- if (filePath && filePath.startsWith('~/')) {
699
- return path.join(os.homedir(), filePath.slice(2));
700
- }
701
- return filePath;
702
- }
703
-
704
586
  /**
705
587
  * Compute the path prefix used for `@file` references in installed command/skill
706
588
  * markdown. For global installs into a runtime config dir under $HOME, we
@@ -995,9 +877,31 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
995
877
  return { content: updated, changed };
996
878
  }
997
879
 
998
- 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 = {}) {
999
900
  const hooksJsonPath = path.join(targetDir, 'hooks.json');
1000
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;
1001
905
  let parsed = {};
1002
906
  let currentContent = null;
1003
907
  if (fs.existsSync(hooksJsonPath)) {
@@ -1016,22 +920,22 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1016
920
  const usesNestedHooksObject =
1017
921
  parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
1018
922
  const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
1019
- const sessionStart = Array.isArray(hookTable.SessionStart) ? hookTable.SessionStart : [];
923
+ const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
1020
924
 
1021
925
  let removedLegacy = false;
1022
- const sanitizedSessionStart = [];
1023
- for (const entry of sessionStart) {
926
+ const sanitizedEntries = [];
927
+ for (const entry of eventEntries) {
1024
928
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
1025
929
  const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
1026
930
  if (originalHooks.length === 0) {
1027
- sanitizedSessionStart.push(entry);
931
+ sanitizedEntries.push(entry);
1028
932
  continue;
1029
933
  }
1030
- const keptHooks = originalHooks.filter((hook) => {
1031
- const cmd = hook && typeof hook === 'object' ? hook.command : null;
1032
- const managed = isManagedHookCommand(cmd, {
1033
- surface: 'codex-hooks-json',
1034
- 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,
1035
939
  configDir: targetDir,
1036
940
  });
1037
941
  if (managed) removedLegacy = true;
@@ -1039,24 +943,26 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1039
943
  });
1040
944
  if (keptHooks.length === 0) continue;
1041
945
  const nextEntry = { ...entry, hooks: keptHooks };
1042
- sanitizedSessionStart.push(nextEntry);
946
+ sanitizedEntries.push(nextEntry);
1043
947
  }
1044
948
 
1045
949
  if (managedCommand) {
1046
- sanitizedSessionStart.push({
1047
- hooks: [
1048
- {
1049
- type: 'command',
1050
- command: managedCommand,
1051
- },
1052
- ],
1053
- });
1054
- }
1055
-
1056
- if (sanitizedSessionStart.length > 0) {
1057
- 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;
1058
964
  } else {
1059
- delete hookTable.SessionStart;
965
+ delete hookTable[eventName];
1060
966
  }
1061
967
  if (usesNestedHooksObject) parsed.hooks = hookTable;
1062
968
 
@@ -1070,6 +976,18 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1070
976
  return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
1071
977
  }
1072
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
+
1073
991
  /**
1074
992
  * Build a typed IR for the Codex hook .cmd shim used on Windows (#3426).
1075
993
  *
@@ -1151,8 +1069,14 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1151
1069
  * 2) { "hooks": { "SessionStart": [...] } }
1152
1070
  *
1153
1071
  * On Windows, writes a .cmd shim alongside the .js hook file and uses the
1154
- * .cmd path as the hook command to avoid the `bash.exe: cannot execute binary
1155
- * 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).
1156
1080
  *
1157
1081
  * @param {string} targetDir
1158
1082
  * @param {{ absoluteRunner: string|null, platform?: NodeJS.Platform }} opts
@@ -1164,7 +1088,18 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1164
1088
  const hooksJsonPath = path.join(targetDir, 'hooks.json');
1165
1089
  if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
1166
1090
 
1167
- 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');
1168
1103
 
1169
1104
  let managedCommand;
1170
1105
  if (platform === 'win32') {
@@ -1201,7 +1136,96 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1201
1136
  }
1202
1137
 
1203
1138
  if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
1204
- 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 });
1205
1229
  }
1206
1230
 
1207
1231
  function removeCodexHooksJsonSessionStart(targetDir) {
@@ -1773,11 +1797,11 @@ function getCommitAttribution(runtime) {
1773
1797
  const resolveConfigPath = runtime === 'opencode'
1774
1798
  ? resolveOpencodeConfigPath
1775
1799
  : resolveKiloConfigPath;
1776
- const config = readSettings(resolveConfigPath(getGlobalDir(runtime, null)));
1800
+ const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
1777
1801
  result = (config && config.disable_ai_attribution === true) ? null : undefined;
1778
1802
  } else if (runtime === 'gemini') {
1779
1803
  // Gemini: check gemini settings.json for attribution config
1780
- const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
1804
+ const settings = readSettings(path.join(getGlobalConfigDir('gemini', explicitConfigDir), 'settings.json'));
1781
1805
  if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
1782
1806
  result = undefined;
1783
1807
  } else if (settings.attribution.commit === '') {
@@ -1787,7 +1811,7 @@ function getCommitAttribution(runtime) {
1787
1811
  }
1788
1812
  } else if (runtime === 'claude') {
1789
1813
  // Claude Code
1790
- const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
1814
+ const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
1791
1815
  if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
1792
1816
  result = undefined;
1793
1817
  } else if (settings.attribution.commit === '') {
@@ -2097,6 +2121,40 @@ function skillFrontmatterName(skillDirName) {
2097
2121
  return skillDirName;
2098
2122
  }
2099
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
+
2100
2158
  /**
2101
2159
  * Convert a Claude command (.md) to a Claude skill (SKILL.md).
2102
2160
  * Claude Code is the native format, so minimal conversion needed —
@@ -2119,6 +2177,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2119
2177
  const description = extractFrontmatterField(frontmatter, 'description') || '';
2120
2178
  const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint');
2121
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');
2122
2184
 
2123
2185
  // Preserve allowed-tools as YAML multiline list (Claude native format)
2124
2186
  const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m);
@@ -2136,8 +2198,26 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2136
2198
  // Track GSD's package version so Hermes' skill_view() reports a stable
2137
2199
  // identifier per install.
2138
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
+ }
2139
2213
  if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`;
2140
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`;
2141
2221
  if (toolsBlock) fm += toolsBlock;
2142
2222
  fm += '---';
2143
2223
 
@@ -2390,6 +2470,29 @@ function convertClaudeCommandToCursorSkill(content, skillName) {
2390
2470
  return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
2391
2471
  }
2392
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
+
2393
2496
  /**
2394
2497
  * Convert Claude Code agent markdown to Cursor agent format.
2395
2498
  * Strips frontmatter fields Cursor doesn't support (color, skills),
@@ -2753,7 +2856,47 @@ function convertClaudeCommandToCodebuddySkill(content, skillName) {
2753
2856
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
2754
2857
  // #2876: quote so YAML flow indicators (`[BETA] …`) don't break
2755
2858
  // CodeBuddy's frontmatter parser.
2756
- 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');
2757
2900
  }
2758
2901
 
2759
2902
  function convertClaudeAgentToCodebuddyAgent(content) {
@@ -2779,9 +2922,15 @@ function convertClaudeToCliineMarkdown(content) {
2779
2922
  converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules');
2780
2923
  converted = converted.replace(/`CLAUDE\.md`/g, '`.clinerules`');
2781
2924
  converted = converted.replace(/\bCLAUDE\.md\b/g, '.clinerules');
2925
+ // Slash forms first (most specific — superset of bare forms)
2782
2926
  converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/');
2783
2927
  converted = converted.replace(/\.\/\.claude\//g, './.cline/');
2784
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');
2785
2934
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2786
2935
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2787
2936
  converted = converted.replace(/\bClaude Code\b/g, 'Cline');
@@ -2798,6 +2947,43 @@ function convertClaudeAgentToClineAgent(content) {
2798
2947
  return `${cleanFrontmatter}\n${body}`;
2799
2948
  }
2800
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
+
2801
2987
  // ── End Cline converters ─────────────────────────────────────────────────────
2802
2988
 
2803
2989
  function convertSlashCommandsToCodexSkillMentions(content) {
@@ -3017,6 +3203,20 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3017
3203
  const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
3018
3204
  lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
3019
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
+
3020
3220
  // Agent prompts contain raw backslashes in regexes and shell snippets.
3021
3221
  // TOML literal multiline strings preserve them without escape parsing.
3022
3222
  lines.push(`developer_instructions = '''`);
@@ -3026,6 +3226,115 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3026
3226
  return lines.join('\n') + '\n';
3027
3227
  }
3028
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
+
3029
3338
  /**
3030
3339
  * Generate the GSD config block for Codex config.toml.
3031
3340
  * @param {Array<{name: string, description: string}>} agents
@@ -5208,31 +5517,531 @@ function mergeCopilotInstructions(filePath, gsdContent) {
5208
5517
  return;
5209
5518
  }
5210
5519
 
5211
- // Case 3: No markers — append at end
5212
- const content = existing.trimEnd() + '\n\n' + gsdBlock + '\n';
5213
- fs.writeFileSync(filePath, content);
5520
+ // Case 3: No markers — append at end
5521
+ const content = existing.trimEnd() + '\n\n' + gsdBlock + '\n';
5522
+ fs.writeFileSync(filePath, content);
5523
+ }
5524
+
5525
+ /**
5526
+ * Strip GSD section from copilot-instructions.md content.
5527
+ * Returns cleaned content, or null if file should be deleted (was GSD-only).
5528
+ * @param {string} content - File content
5529
+ * @returns {string|null} - Cleaned content or null if empty
5530
+ */
5531
+ function stripGsdFromCopilotInstructions(content) {
5532
+ const openIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER);
5533
+ const closeIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER);
5534
+
5535
+ if (openIndex !== -1 && closeIndex !== -1) {
5536
+ const before = content.substring(0, openIndex).trimEnd();
5537
+ const after = content.substring(closeIndex + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart();
5538
+ const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
5539
+ if (!cleaned) return null;
5540
+ return cleaned + '\n';
5541
+ }
5542
+
5543
+ // No markers found — nothing to strip
5544
+ return content;
5545
+ }
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 };
5214
5959
  }
5215
5960
 
5216
5961
  /**
5217
- * Strip GSD section from copilot-instructions.md content.
5218
- * Returns cleaned content, or null if file should be deleted (was GSD-only).
5219
- * @param {string} content - File content
5220
- * @returns {string|null} - Cleaned content or null if empty
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 }}
5221
5967
  */
5222
- function stripGsdFromCopilotInstructions(content) {
5223
- const openIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_MARKER);
5224
- const closeIndex = content.indexOf(GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER);
5225
-
5226
- if (openIndex !== -1 && closeIndex !== -1) {
5227
- const before = content.substring(0, openIndex).trimEnd();
5228
- const after = content.substring(closeIndex + GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER.length).trimStart();
5229
- const cleaned = (before + (before && after ? '\n\n' : '') + after).trim();
5230
- if (!cleaned) return null;
5231
- return cleaned + '\n';
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 */ }
5232
5992
  }
5993
+ return { changed: result.changed };
5994
+ }
5233
5995
 
5234
- // No markers found — nothing to strip
5235
- return content;
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;
5236
6045
  }
5237
6046
 
5238
6047
  /**
@@ -5407,7 +6216,7 @@ function convertSlashCommandsToGeminiMentions(content) {
5407
6216
  });
5408
6217
  }
5409
6218
 
5410
- function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
6219
+ function convertClaudeToGeminiMarkdown(content, { isCommand = false, commandName = null } = {}) {
5411
6220
  // Apply Gemini-specific slash command namespacing
5412
6221
  let converted = convertSlashCommandsToGeminiMentions(content);
5413
6222
  // Gemini CLI does not expose Claude's AskUserQuestion tool. Convert body
@@ -5419,8 +6228,9 @@ function convertClaudeToGeminiMarkdown(content, { isCommand = false } = {}) {
5419
6228
  converted = stripSubTags(converted);
5420
6229
 
5421
6230
  if (isCommand) {
5422
- // Convert to Gemini TOML format
5423
- 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 });
5424
6234
  }
5425
6235
 
5426
6236
  return converted;
@@ -5855,12 +6665,85 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) {
5855
6665
  return `---\n${newFrontmatter}\n---${body}`;
5856
6666
  }
5857
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
+
5858
6732
  /**
5859
6733
  * Convert Claude Code markdown command to Gemini TOML format
5860
6734
  * @param {string} content - Markdown file content with YAML frontmatter
5861
6735
  * @returns {string} - TOML content
5862
6736
  */
5863
- 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
+
5864
6747
  // Check if content has frontmatter
5865
6748
  if (!content.startsWith('---')) {
5866
6749
  return `prompt = ${JSON.stringify(content)}\n`;
@@ -5872,7 +6755,27 @@ function convertClaudeToGeminiToml(content) {
5872
6755
  }
5873
6756
 
5874
6757
  const frontmatter = content.substring(3, endIndex).trim();
5875
- 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
+ }
5876
6779
 
5877
6780
  // Extract description from frontmatter
5878
6781
  let description = '';
@@ -5907,6 +6810,31 @@ function convertClaudeToGeminiToml(content) {
5907
6810
  * @param {string} pathPrefix - Path prefix for file references
5908
6811
  * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo')
5909
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
+
5910
6838
  function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
5911
6839
  if (!fs.existsSync(srcDir)) {
5912
6840
  return;
@@ -5939,16 +6867,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) {
5939
6867
  const destPath = path.join(destDir, destName);
5940
6868
 
5941
6869
  let content = fs.readFileSync(srcPath, 'utf8');
5942
- const globalClaudeRegex = /~\/\.claude\//g;
5943
- const globalClaudeHomeRegex = /\$HOME\/\.claude\//g;
5944
- const localClaudeRegex = /\.\/\.claude\//g;
5945
- const opencodeDirRegex = /~\/\.opencode\//g;
5946
- const kiloDirRegex = /~\/\.kilo\//g;
5947
- content = content.replace(globalClaudeRegex, pathPrefix);
5948
- content = content.replace(globalClaudeHomeRegex, pathPrefix);
5949
- content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`);
5950
- content = content.replace(opencodeDirRegex, pathPrefix);
5951
- content = content.replace(kiloDirRegex, pathPrefix);
6870
+ content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
5952
6871
  content = processAttribution(content, getCommitAttribution(runtime));
5953
6872
  content = runtime === 'kilo'
5954
6873
  ? convertClaudeToKiloFrontmatter(content)
@@ -6133,7 +7052,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
6133
7052
  if (runtime) {
6134
7053
  const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope);
6135
7054
  const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills');
6136
- 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)
6137
7056
  const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences';
6138
7057
  skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName);
6139
7058
  } else {
@@ -6190,6 +7109,46 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
6190
7109
  walkAndRewrite(stagedDir);
6191
7110
  }
6192
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
+
6193
7152
  /**
6194
7153
  * Apply the per-runtime rewrite table to a single content string.
6195
7154
  * Extracted so it can be unit-tested independently of the filesystem walk.
@@ -6212,6 +7171,22 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6212
7171
  content = processAttribution(content, getCommitAttribution(runtime));
6213
7172
  break;
6214
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
+
6215
7190
  case 'cursor':
6216
7191
  content = content.replace(/~\/\.claude\//g, pathPrefix);
6217
7192
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
@@ -6254,7 +7229,14 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6254
7229
  content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
6255
7230
  content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
6256
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.
6257
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);
6258
7240
  content = processAttribution(content, getCommitAttribution(runtime));
6259
7241
  break;
6260
7242
 
@@ -6309,7 +7291,11 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6309
7291
  break;
6310
7292
 
6311
7293
  default:
6312
- // 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.
6313
7299
  break;
6314
7300
  }
6315
7301
 
@@ -6572,8 +7558,14 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6572
7558
 
6573
7559
  for (const kind of layout.kinds) {
6574
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;
6575
7564
  if (kind.kind === 'skills') {
6576
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);
6577
7569
  }
6578
7570
  const dest = path.join(layout.configDir, kind.destSubpath);
6579
7571
  fs.mkdirSync(dest, { recursive: true });
@@ -6595,8 +7587,8 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6595
7587
 
6596
7588
  if (kind.prefix === '') {
6597
7589
  // Hermes: wipes entire dest dir — preserve anything not in staged.
6598
- const stagedNames = fs.existsSync(staged)
6599
- ? new Set(fs.readdirSync(staged, { withFileTypes: true })
7590
+ const stagedNames = fs.existsSync(stagedForCopy)
7591
+ ? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true })
6600
7592
  .filter(e => e.isDirectory()).map(e => e.name))
6601
7593
  : new Set();
6602
7594
  for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
@@ -6617,7 +7609,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6617
7609
  }
6618
7610
 
6619
7611
  _removeGsdEntries(dest, kind);
6620
- _copyStaged(staged, dest, kind);
7612
+ _copyStaged(stagedForCopy, dest, kind);
6621
7613
 
6622
7614
  // Restore user-owned dirs after the prune+copy
6623
7615
  for (const [dirName, snap] of toPreserve) {
@@ -6627,11 +7619,92 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
6627
7619
  // For non-skills kinds (commands, agents): no user content to preserve;
6628
7620
  // just prune stale gsd-* entries and copy new ones.
6629
7621
  _removeGsdEntries(dest, kind);
6630
- _copyStaged(staged, dest, kind);
7622
+ _copyStaged(stagedForCopy, dest, kind);
6631
7623
  }
6632
7624
  }
6633
7625
  }
6634
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
+
6635
7708
  /**
6636
7709
  * Layout-driven uninstall orchestrator.
6637
7710
  * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to
@@ -6736,8 +7809,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
6736
7809
  : convertClaudeToOpencodeFrontmatter(content);
6737
7810
  fs.writeFileSync(destPath, content);
6738
7811
  } else if (isGemini) {
6739
- // Apply Gemini-specific Markdown transformations (slash commands, TOML)
6740
- 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 });
6741
7817
  const finalPath = isCommand ? destPath.replace(/\.md$/, '.toml') : destPath;
6742
7818
  fs.writeFileSync(finalPath, processed);
6743
7819
  } else if (isCodex) {
@@ -6982,7 +8058,10 @@ const GSD_UNINSTALL_HOOKS = [
6982
8058
  'gsd-statusline.js',
6983
8059
  'gsd-check-update.js',
6984
8060
  'gsd-check-update.cmd',
8061
+ 'gsd-config-reload.js',
6985
8062
  'gsd-context-monitor.js',
8063
+ 'gsd-cursor-session-start.js',
8064
+ 'gsd-cursor-post-tool.js',
6986
8065
  'gsd-prompt-guard.js',
6987
8066
  'gsd-read-guard.js',
6988
8067
  'gsd-read-injection-scanner.js',
@@ -7016,10 +8095,14 @@ function uninstall(isGlobal, runtime = 'claude') {
7016
8095
  const isCodebuddy = runtime === 'codebuddy';
7017
8096
  const dirName = getDirName(runtime);
7018
8097
 
7019
- // 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).
7020
8101
  const targetDir = isGlobal
7021
- ? getGlobalDir(runtime, explicitConfigDir)
7022
- : path.join(process.cwd(), dirName);
8102
+ ? getGlobalConfigDir(runtime, explicitConfigDir)
8103
+ : runtime === 'cline'
8104
+ ? process.cwd()
8105
+ : path.join(process.cwd(), dirName);
7023
8106
 
7024
8107
  const locationLabel = isGlobal
7025
8108
  ? targetDir.replace(os.homedir(), '~')
@@ -7042,6 +8125,24 @@ function uninstall(isGlobal, runtime = 'claude') {
7042
8125
 
7043
8126
  console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
7044
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
+
7045
8146
  // Check if target directory exists
7046
8147
  if (!fs.existsSync(targetDir)) {
7047
8148
  console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`);
@@ -7101,6 +8202,15 @@ function uninstall(isGlobal, runtime = 'claude') {
7101
8202
  removedCount++;
7102
8203
  console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`);
7103
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
+ }
7104
8214
  }
7105
8215
 
7106
8216
  // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup
@@ -7119,6 +8229,99 @@ function uninstall(isGlobal, runtime = 'claude') {
7119
8229
  console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`);
7120
8230
  }
7121
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 */ }
7122
8325
  }
7123
8326
 
7124
8327
  // 1c. Claude local: remove commands/gsd/ (primary local install location).
@@ -7319,8 +8522,14 @@ function uninstall(isGlobal, runtime = 'claude') {
7319
8522
  }
7320
8523
 
7321
8524
  // Remove GSD hooks from settings — per-hook granularity to preserve
7322
- // user hooks that share an entry with a GSD hook (#1755 followup)
7323
- 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']) {
7324
8533
  if (settings.hooks && settings.hooks[eventName]) {
7325
8534
  const before = JSON.stringify(settings.hooks[eventName]);
7326
8535
  settings.hooks[eventName] = settings.hooks[eventName]
@@ -7353,6 +8562,37 @@ function uninstall(isGlobal, runtime = 'claude') {
7353
8562
  delete settings.hooks;
7354
8563
  }
7355
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
+
7356
8596
  if (settingsModified) {
7357
8597
  writeSettings(settingsPath, settings);
7358
8598
  removedCount++;
@@ -7531,7 +8771,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
7531
8771
  // For local installs, use ./.opencode/
7532
8772
  // For global installs, use ~/.config/opencode/
7533
8773
  const opencodeConfigDir = configDir || (isGlobal
7534
- ? getGlobalDir('opencode', explicitConfigDir)
8774
+ ? getGlobalConfigDir('opencode', explicitConfigDir)
7535
8775
  : path.join(process.cwd(), '.opencode'));
7536
8776
  // Ensure config directory exists
7537
8777
  fs.mkdirSync(opencodeConfigDir, { recursive: true });
@@ -7611,7 +8851,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
7611
8851
  // For local installs, use ./.kilo/
7612
8852
  // For global installs, use ~/.config/kilo/
7613
8853
  const kiloConfigDir = configDir || (isGlobal
7614
- ? getGlobalDir('kilo', explicitConfigDir)
8854
+ ? getGlobalConfigDir('kilo', explicitConfigDir)
7615
8855
  : path.join(process.cwd(), '.kilo'));
7616
8856
  // Ensure config directory exists
7617
8857
  fs.mkdirSync(kiloConfigDir, { recursive: true });
@@ -7885,11 +9125,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
7885
9125
  }
7886
9126
  }
7887
9127
  }
7888
- // 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.)
7889
9131
  if (isCline) {
7890
- const clinerulesDest = path.join(configDir, '.clinerules');
7891
- if (fs.existsSync(clinerulesDest)) {
7892
- 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
+ }
7893
9137
  }
7894
9138
  }
7895
9139
 
@@ -8254,6 +9498,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8254
9498
  const isHermes = runtime === 'hermes';
8255
9499
  const isCodebuddy = runtime === 'codebuddy';
8256
9500
  const isCline = runtime === 'cline';
9501
+ const configIntent = resolveRuntimeConfigIntent(runtime);
8257
9502
  const dirName = getDirName(runtime);
8258
9503
  const src = path.join(__dirname, '..');
8259
9504
 
@@ -8291,7 +9536,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8291
9536
  // Cline local installs write to the project root (like Claude Code) — .clinerules
8292
9537
  // lives at the root, not inside a .cline/ subdirectory.
8293
9538
  const targetDir = isGlobal
8294
- ? getGlobalDir(runtime, explicitConfigDir)
9539
+ ? getGlobalConfigDir(runtime, explicitConfigDir)
8295
9540
  : isCline
8296
9541
  ? process.cwd()
8297
9542
  : path.join(process.cwd(), dirName);
@@ -8660,7 +9905,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8660
9905
  //
8661
9906
  // Non-layout side-effects preserved inline:
8662
9907
  // Hermes: writeHermesCategoryDescription (not a layout kind)
8663
- // 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
8664
9910
  // Gemini: conflict-detection logic (not expressible in layout)
8665
9911
  // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind)
8666
9912
  // Claude local: copyWithPathReplacement + stale-skills cleanup
@@ -8668,15 +9914,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8668
9914
  // Layout-driven path for all skills-based runtimes (full and minimal modes).
8669
9915
  // applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts)
8670
9916
  // handles per-runtime path + branding rewrites, including Qwen/Hermes.
9917
+ // Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
8671
9918
  const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
8672
9919
  isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
8673
- (runtime === 'claude' && isGlobal);
9920
+ (runtime === 'claude' && isGlobal) ||
9921
+ (isCline && isGlobal);
8674
9922
 
8675
9923
  if (_isSkillsRuntime) {
8676
9924
  // Layout-driven install for skills-based runtimes (full and minimal modes)
8677
9925
  const scope = isGlobal ? 'global' : 'local';
8678
9926
  installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile);
8679
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
+
8680
9938
  // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
8681
9939
  if (isHermes) {
8682
9940
  writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
@@ -8710,6 +9968,53 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8710
9968
  } else {
8711
9969
  failures.push('skills/gsd-*');
8712
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
+ }
8713
10018
  }
8714
10019
  } else if (isOpencode || isKilo) {
8715
10020
  // OpenCode/Kilo: flat structure in command/ directory
@@ -8725,9 +10030,21 @@ function install(isGlobal, runtime = 'claude', options = {}) {
8725
10030
  } else {
8726
10031
  failures.push('command/gsd-*');
8727
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
+ }
8728
10044
  } else if (isCline) {
8729
- // Cline is rules-based — commands are embedded in .clinerules (generated below).
8730
- // 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).
8731
10048
  console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
8732
10049
  } else if (isGemini) {
8733
10050
  // #3037: when running --local --gemini and a GSD-managed user-scope
@@ -9107,8 +10424,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9107
10424
  }
9108
10425
 
9109
10426
  // Gate hooks/lib/ install on the same runtimes that receive hooks (see line ~8702).
9110
- // Codex/Copilot/Cursor/Windsurf/Trae/Cline skip hooks entirely, so they must not
9111
- // 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
9112
10431
  // ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
9113
10432
  const hooksLibSrc = path.join(src, 'hooks', 'lib');
9114
10433
  if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
@@ -9214,7 +10533,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9214
10533
  throw _earlyInstallErr;
9215
10534
  }
9216
10535
 
9217
- if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
10536
+ if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
9218
10537
  // Capture pre-install snapshots before ANY GSD mutation
9219
10538
  // (#2760 fix 3). On post-write schema-validation failure OR any throw
9220
10539
  // during the mutation sequence (write failure, merge throw, etc.) we
@@ -9396,10 +10715,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9396
10715
  }
9397
10716
 
9398
10717
  // Copy only the hook files that Codex actually registers via its hook configuration (#2153).
9399
- // 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.
9400
10719
  // We deliberately do *not* copy gsd-graphify-update.sh or hooks/lib/ for Codex
9401
10720
  // in this change (graphify auto-update support for Codex is out of scope for #3579).
9402
- const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js'];
10721
+ const CODEX_HOOKS_TO_COPY = ['gsd-check-update.js', 'gsd-context-monitor.js'];
9403
10722
  const codexHooksSrc = path.join(src, 'hooks', 'dist');
9404
10723
  if (fs.existsSync(codexHooksSrc)) {
9405
10724
  const codexHooksDest = path.join(targetDir, 'hooks');
@@ -9529,6 +10848,39 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9529
10848
  console.log(` ${green}✓${reset} Verified Codex hooks (SessionStart via hooks.json)`);
9530
10849
  }
9531
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 ────────────────────────────────────
9532
10884
  }
9533
10885
  } catch (e) {
9534
10886
  // #2760 — schema-validation and write failures must be loud and fatal
@@ -9560,7 +10912,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9560
10912
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9561
10913
  }
9562
10914
 
9563
- if (isCopilot) {
10915
+ if (configIntent.installSurface === 'copilot-instructions') {
9564
10916
  // Generate copilot-instructions.md
9565
10917
  const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
9566
10918
  const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -9568,47 +10920,57 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9568
10920
  const template = fs.readFileSync(templatePath, 'utf8');
9569
10921
  mergeCopilotInstructions(instructionsPath, template);
9570
10922
  console.log(` ${green}✓${reset} Generated copilot-instructions.md`);
9571
- }
9572
- // Copilot: no settings.json, no hooks, no statusline (like Codex)
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)`);
9573
10941
  persistActiveProfileMarker();
9574
10942
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9575
10943
  }
9576
10944
 
9577
- if (isCursor) {
9578
- // Cursor uses skills — no config.toml, no settings.json hooks needed
9579
- persistActiveProfileMarker();
9580
- return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9581
- }
9582
-
9583
- if (isWindsurf) {
9584
- // 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 });
9585
10956
  persistActiveProfileMarker();
9586
10957
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9587
10958
  }
9588
10959
 
9589
- if (isTrae) {
9590
- // 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
9591
10962
  persistActiveProfileMarker();
9592
10963
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9593
10964
  }
9594
10965
 
9595
- if (isCline) {
9596
- // Cline uses .clinerules — generate a rules file with GSD system instructions
9597
- const clinerulesDest = path.join(targetDir, '.clinerules');
9598
- const clinerules = [
9599
- '# GSD Core — Git. Ship. Done.',
9600
- '',
9601
- '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
9602
- ' the user runs a `/gsd-*` command.',
9603
- '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
9604
- '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
9605
- '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
9606
- '- Do not apply GSD workflows unless the user explicitly asks for them.',
9607
- '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
9608
- ' step to the user using Cline\'s ask_user tool after completing it.',
9609
- ].join('\n') + '\n';
9610
- fs.writeFileSync(clinerulesDest, clinerules);
9611
- 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 });
9612
10974
  persistActiveProfileMarker();
9613
10975
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
9614
10976
  }
@@ -9759,6 +11121,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9759
11121
  const readInjectionScannerCommand = isGlobal
9760
11122
  ? buildHookCommand(targetDir, 'gsd-read-injection-scanner.js', hookOpts)
9761
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');
9762
11127
 
9763
11128
  // #3002 CR: when resolveNodeRunner() returns null, every dependent JS-hook
9764
11129
  // command is null too. Emit one warning here so the operator sees the cause
@@ -10112,7 +11477,155 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10112
11477
  } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
10113
11478
  console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
10114
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
+ );
10115
11627
  }
11628
+ // ── end hooksConfig.enabled check ────────────────────────────────────────
10116
11629
 
10117
11630
  // Compute the update-banner hook command alongside the others so
10118
11631
  // installAllRuntimes can register it at finalize time when the user opts
@@ -10210,6 +11723,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10210
11723
  const isWindsurf = runtime === 'windsurf';
10211
11724
  const isTrae = runtime === 'trae';
10212
11725
  const isCline = runtime === 'cline';
11726
+ const configIntent = resolveRuntimeConfigIntent(runtime);
10213
11727
 
10214
11728
  if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
10215
11729
  if (!isGlobal && !forceStatusline) {
@@ -10262,6 +11776,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10262
11776
  }
10263
11777
  }
10264
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
+
10265
11787
  // Write settings when runtime supports settings.json.
10266
11788
  // #3002 CR: defense-in-depth — re-run validateHookFields right before
10267
11789
  // serialization. The push-site guards above already skip null-command
@@ -10269,17 +11791,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
10269
11791
  // {type: 'command', command: null} items that the runtime hook schema
10270
11792
  // rejects at parse time. validateHookFields filters those out so the file
10271
11793
  // we write is always schema-valid.
10272
- if (!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline) {
11794
+ if (configIntent.writesSharedSettings) {
10273
11795
  writeSettings(settingsPath, validateHookFields(settings));
10274
11796
  }
10275
11797
 
10276
11798
  // Configure OpenCode permissions
10277
- if (isOpencode && !process.env.GSD_TEST_MODE) {
11799
+ if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
10278
11800
  configureOpencodePermissions(isGlobal, configDir);
10279
11801
  }
10280
11802
 
10281
11803
  // Configure Kilo permissions
10282
- if (isKilo) {
11804
+ if (configIntent.finishPermissionWriter === 'kilo') {
10283
11805
  configureKiloPermissions(isGlobal, configDir);
10284
11806
  }
10285
11807
 
@@ -10623,7 +12145,7 @@ function promptLocation(runtimes) {
10623
12145
  });
10624
12146
 
10625
12147
  const pathExamples = runtimes.map(r => {
10626
- const globalPath = getGlobalDir(r, explicitConfigDir);
12148
+ const globalPath = getGlobalConfigDir(r, explicitConfigDir);
10627
12149
  return globalPath.replace(os.homedir(), '~');
10628
12150
  }).join(', ');
10629
12151
 
@@ -10995,8 +12517,10 @@ module.exports = {
10995
12517
  normalizeAgentBodyForRuntime,
10996
12518
  yamlIdentifier,
10997
12519
  computePathPrefix,
12520
+ applyRuntimeContentRewritesInPlace,
10998
12521
  getCodexSkillAdapterHeader,
10999
12522
  convertClaudeCommandToCursorSkill,
12523
+ convertClaudeCommandToCursorCommand,
11000
12524
  convertClaudeAgentToCursorAgent,
11001
12525
  convertClaudeToGeminiMarkdown,
11002
12526
  convertSlashCommandsToGeminiMentions,
@@ -11004,6 +12528,8 @@ module.exports = {
11004
12528
  convertClaudeToGeminiAgent,
11005
12529
  convertClaudeAgentToCodexAgent,
11006
12530
  generateCodexAgentToml,
12531
+ generateCodexSkillMetadataYaml,
12532
+ writeCodexSkillMetadataFiles,
11007
12533
  generateCodexConfigBlock,
11008
12534
  stripGsdFromCodexConfig,
11009
12535
  migrateCodexHooksMapFormat,
@@ -11027,12 +12553,17 @@ module.exports = {
11027
12553
  convertClaudeCommandToCodexSkill,
11028
12554
  convertClaudeToOpencodeFrontmatter,
11029
12555
  convertClaudeToKiloFrontmatter,
12556
+ convertClaudeCommandToOpencodeSkill,
12557
+ convertClaudeCommandToKiloSkill,
11030
12558
  configureOpencodePermissions,
11031
12559
  neutralizeAgentReferences,
12560
+ // #768 — Claude Code permissions pre-population
12561
+ mergeClaudePermissions,
12562
+ GSD_CLAUDE_ALLOW_PERMISSIONS,
12563
+ GSD_CLAUDE_DENY_PERMISSIONS,
11032
12564
  GSD_CODEX_MARKER,
11033
12565
  CODEX_AGENT_SANDBOX,
11034
12566
  getDirName,
11035
- getGlobalDir,
11036
12567
  getConfigDirFromHome,
11037
12568
  resolveKiloConfigPath,
11038
12569
  configureKiloPermissions,
@@ -11045,6 +12576,9 @@ module.exports = {
11045
12576
  GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
11046
12577
  mergeCopilotInstructions,
11047
12578
  stripGsdFromCopilotInstructions,
12579
+ GSD_COPILOT_HOOK_FILE,
12580
+ buildCopilotHookConfig,
12581
+ writeCopilotHookConfig,
11048
12582
  convertClaudeToAntigravityContent,
11049
12583
  convertClaudeCommandToAntigravitySkill,
11050
12584
  convertClaudeAgentToAntigravityAgent,
@@ -11061,9 +12595,27 @@ module.exports = {
11061
12595
  convertClaudeAgentToTraeAgent,
11062
12596
  convertClaudeToCodebuddyMarkdown,
11063
12597
  convertClaudeCommandToCodebuddySkill,
12598
+ convertClaudeCommandToCodebuddyCommand,
11064
12599
  convertClaudeAgentToCodebuddyAgent,
11065
12600
  convertClaudeToCliineMarkdown,
12601
+ convertClaudeCommandToClineSkill,
11066
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,
11067
12619
  writeManifest,
11068
12620
  saveLocalPatches,
11069
12621
  reportLocalPatches,
@@ -11092,11 +12644,16 @@ module.exports = {
11092
12644
  rewriteLegacyCodexHookBlock,
11093
12645
  buildCodexHookWindowsShimIR,
11094
12646
  ensureCodexHooksJsonSessionStart,
12647
+ ensureCodexHooksJsonEvent,
12648
+ removeCodexHooksJsonEvent,
12649
+ reconcileCodexHooksJsonEvent,
11095
12650
  readGsdCommandNames,
11096
12651
  installRuntimeArtifacts,
12652
+ installOpencodeFamilySkills,
11097
12653
  uninstallRuntimeArtifacts,
11098
12654
  parseConfigDirFromArgs,
11099
12655
  cleanupLegacyGsdCc,
12656
+ _applyRuntimeRewrites,
11100
12657
  };
11101
12658
 
11102
12659
  // Main logic — only run when not loaded as a module for testing
@@ -11123,7 +12680,7 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
11123
12680
  console.error('Usage: node install.js --skills-root <runtime>');
11124
12681
  process.exit(1);
11125
12682
  }
11126
- const globalDir = getGlobalDir(runtimeArg, null);
12683
+ const globalDir = getGlobalConfigDir(runtimeArg, null);
11127
12684
  // Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
11128
12685
  // Other runtimes use a flat skills/ root.
11129
12686
  const skillsRoot = runtimeArg === 'hermes'