@opengsd/gsd-core 1.4.4 → 1.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/agents/gsd-code-fixer.md +3 -2
  4. package/agents/gsd-debug-session-manager.md +2 -1
  5. package/agents/gsd-debugger.md +4 -3
  6. package/agents/gsd-executor.md +17 -16
  7. package/agents/gsd-intel-updater.md +38 -41
  8. package/agents/gsd-phase-researcher.md +8 -8
  9. package/agents/gsd-plan-checker.md +23 -13
  10. package/agents/gsd-planner.md +32 -188
  11. package/agents/gsd-project-researcher.md +5 -4
  12. package/agents/gsd-research-synthesizer.md +2 -1
  13. package/agents/gsd-ui-researcher.md +2 -1
  14. package/agents/gsd-verifier.md +12 -11
  15. package/bin/install.js +965 -1486
  16. package/commands/gsd/autonomous.md +5 -1
  17. package/commands/gsd/ns-manage.md +8 -1
  18. package/commands/gsd/ns-project.md +5 -0
  19. package/commands/gsd/ns-review.md +4 -1
  20. package/commands/gsd/ns-workflow.md +7 -1
  21. package/commands/gsd/plan-review-convergence.md +5 -4
  22. package/commands/gsd/surface.md +12 -5
  23. package/gemini-extension.json +1 -1
  24. package/gsd-core/bin/gsd-tools.cjs +198 -101
  25. package/gsd-core/bin/gsd_run +20 -0
  26. package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
  27. package/gsd-core/bin/lib/capability-registry.cjs +2234 -0
  28. package/gsd-core/bin/lib/capability-state.cjs +336 -0
  29. package/gsd-core/bin/lib/check-command-router.cjs +133 -2
  30. package/gsd-core/bin/lib/cli-exit.cjs +22 -3
  31. package/gsd-core/bin/lib/config-loader.cjs +716 -0
  32. package/gsd-core/bin/lib/configuration.cjs +4 -34
  33. package/gsd-core/bin/lib/core-utils.cjs +198 -0
  34. package/gsd-core/bin/lib/core.cjs +107 -1817
  35. package/gsd-core/bin/lib/edge-probe.cjs +173 -0
  36. package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
  37. package/gsd-core/bin/lib/federated-config.cjs +182 -0
  38. package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
  39. package/gsd-core/bin/lib/init.cjs +58 -12
  40. package/gsd-core/bin/lib/install-profiles.cjs +157 -3
  41. package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
  42. package/gsd-core/bin/lib/intel.cjs +3 -3
  43. package/gsd-core/bin/lib/io.cjs +222 -0
  44. package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
  45. package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
  46. package/gsd-core/bin/lib/model-resolver.cjs +426 -0
  47. package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
  48. package/gsd-core/bin/lib/phase-id.cjs +215 -0
  49. package/gsd-core/bin/lib/phase-locator.cjs +148 -0
  50. package/gsd-core/bin/lib/phase.cjs +17 -0
  51. package/gsd-core/bin/lib/probe-core.cjs +257 -0
  52. package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
  54. package/gsd-core/bin/lib/roadmap.cjs +44 -1
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +92 -95
  56. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +68 -29
  57. package/gsd-core/bin/lib/runtime-homes.cjs +163 -87
  58. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
  59. package/gsd-core/bin/lib/runtime-name-policy.cjs +2 -1
  60. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  61. package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
  62. package/gsd-core/bin/lib/state-document.cjs +8 -0
  63. package/gsd-core/bin/lib/state.cjs +114 -2
  64. package/gsd-core/bin/lib/surface.cjs +66 -14
  65. package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
  66. package/gsd-core/bin/lib/update-context.cjs +4 -1
  67. package/gsd-core/bin/lib/verify.cjs +104 -1
  68. package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
  69. package/gsd-core/bin/shared/model-catalog.json +11 -6
  70. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -1
  71. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
  72. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
  73. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
  74. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
  75. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
  76. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
  77. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
  78. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
  79. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
  80. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
  81. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
  82. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
  83. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
  84. package/gsd-core/references/edge-probe.md +261 -0
  85. package/gsd-core/references/planner-antipatterns.md +41 -0
  86. package/gsd-core/references/planner-guidance.md +186 -0
  87. package/gsd-core/references/planner-reviews.md +5 -2
  88. package/gsd-core/templates/phase-prompt.md +7 -7
  89. package/gsd-core/templates/project.md +19 -2
  90. package/gsd-core/templates/spec.md +12 -0
  91. package/gsd-core/templates/summary-complex.md +1 -0
  92. package/gsd-core/templates/summary-minimal.md +1 -0
  93. package/gsd-core/templates/summary-standard.md +1 -0
  94. package/gsd-core/templates/summary.md +1 -0
  95. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  96. package/gsd-core/workflows/add-backlog.md +1 -1
  97. package/gsd-core/workflows/add-phase.md +1 -1
  98. package/gsd-core/workflows/add-tests.md +1 -1
  99. package/gsd-core/workflows/add-todo.md +1 -1
  100. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  101. package/gsd-core/workflows/audit-fix.md +1 -1
  102. package/gsd-core/workflows/audit-milestone.md +1 -1
  103. package/gsd-core/workflows/audit-uat.md +1 -1
  104. package/gsd-core/workflows/autonomous.md +111 -51
  105. package/gsd-core/workflows/check-todos.md +1 -1
  106. package/gsd-core/workflows/cleanup.md +1 -1
  107. package/gsd-core/workflows/code-review-fix.md +6 -4
  108. package/gsd-core/workflows/code-review.md +53 -17
  109. package/gsd-core/workflows/complete-milestone.md +11 -5
  110. package/gsd-core/workflows/debug.md +1 -1
  111. package/gsd-core/workflows/diagnose-issues.md +1 -1
  112. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  113. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  114. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  115. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  116. package/gsd-core/workflows/discuss-phase.md +8 -1
  117. package/gsd-core/workflows/do.md +1 -1
  118. package/gsd-core/workflows/docs-update.md +1 -1
  119. package/gsd-core/workflows/edit-phase.md +1 -1
  120. package/gsd-core/workflows/eval-review.md +4 -1
  121. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  122. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  123. package/gsd-core/workflows/execute-phase.md +8 -1
  124. package/gsd-core/workflows/execute-plan.md +1 -1
  125. package/gsd-core/workflows/explore.md +1 -1
  126. package/gsd-core/workflows/extract-learnings.md +1 -1
  127. package/gsd-core/workflows/forensics.md +1 -1
  128. package/gsd-core/workflows/graduation.md +1 -1
  129. package/gsd-core/workflows/health.md +1 -1
  130. package/gsd-core/workflows/help/modes/full.md +1 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/ingest-docs.md +1 -1
  133. package/gsd-core/workflows/insert-phase.md +1 -1
  134. package/gsd-core/workflows/list-workspaces.md +1 -1
  135. package/gsd-core/workflows/manager.md +1 -1
  136. package/gsd-core/workflows/map-codebase.md +1 -1
  137. package/gsd-core/workflows/milestone-summary.md +1 -1
  138. package/gsd-core/workflows/mvp-phase.md +1 -1
  139. package/gsd-core/workflows/new-milestone.md +9 -1
  140. package/gsd-core/workflows/new-project.md +9 -1
  141. package/gsd-core/workflows/new-workspace.md +1 -1
  142. package/gsd-core/workflows/next.md +1 -1
  143. package/gsd-core/workflows/pause-work.md +1 -1
  144. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  145. package/gsd-core/workflows/plan-phase.md +65 -28
  146. package/gsd-core/workflows/plan-review-convergence.md +60 -33
  147. package/gsd-core/workflows/plant-seed.md +1 -1
  148. package/gsd-core/workflows/profile-user.md +1 -1
  149. package/gsd-core/workflows/progress.md +1 -1
  150. package/gsd-core/workflows/quick.md +2 -2
  151. package/gsd-core/workflows/remove-phase.md +1 -1
  152. package/gsd-core/workflows/remove-workspace.md +1 -1
  153. package/gsd-core/workflows/resume-project.md +1 -1
  154. package/gsd-core/workflows/review.md +1 -1
  155. package/gsd-core/workflows/scan.md +1 -1
  156. package/gsd-core/workflows/secure-phase.md +1 -1
  157. package/gsd-core/workflows/settings-advanced.md +7 -7
  158. package/gsd-core/workflows/settings-integrations.md +1 -1
  159. package/gsd-core/workflows/settings.md +2 -2
  160. package/gsd-core/workflows/ship.md +8 -1
  161. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  162. package/gsd-core/workflows/sketch.md +1 -1
  163. package/gsd-core/workflows/spec-phase.md +130 -1
  164. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  165. package/gsd-core/workflows/spike.md +1 -1
  166. package/gsd-core/workflows/stats.md +1 -1
  167. package/gsd-core/workflows/thread.md +1 -1
  168. package/gsd-core/workflows/transition.md +1 -1
  169. package/gsd-core/workflows/ui-phase.md +1 -1
  170. package/gsd-core/workflows/ui-review.md +1 -1
  171. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  172. package/gsd-core/workflows/update.md +2 -2
  173. package/gsd-core/workflows/validate-phase.md +1 -1
  174. package/gsd-core/workflows/verify-phase.md +1 -1
  175. package/gsd-core/workflows/verify-work.md +8 -1
  176. package/package.json +11 -3
  177. package/scripts/base64-scan.sh +1 -1
  178. package/scripts/changeset/cli.cjs +8 -1
  179. package/scripts/changeset/lint.cjs +38 -2
  180. package/scripts/ci-test-scope.cjs +21 -10
  181. package/scripts/gen-capability-registry.cjs +2293 -0
  182. package/scripts/gen-loop-host-contract.cjs +471 -0
  183. package/scripts/lib/allowlist-ratchet.cjs +101 -1
  184. package/scripts/lint-regression-test-names.allowlist.json +269 -0
  185. package/scripts/lint-regression-test-names.cjs +117 -0
  186. package/scripts/lint-test-file-count.allowlist.json +25 -4
  187. package/scripts/lint-windows-test-portability.cjs +178 -0
  188. package/scripts/prompt-injection-scan.sh +4 -4
  189. package/scripts/research-profiles.cjs +10 -10
  190. package/scripts/run-tests.cjs +133 -29
  191. package/scripts/secret-scan.sh +3 -3
  192. package/scripts/sync-next-version.cjs +133 -0
  193. package/scripts/sync-runtime-launcher.cjs +21 -5
  194. package/scripts/update-size-baseline.cjs +68 -0
  195. package/scripts/workflow-policy.cjs +42 -9
  196. package/scripts/workflow-size.cjs +90 -0
  197. package/scripts/run-cross-platform-tests.cjs +0 -67
package/bin/install.js CHANGED
@@ -31,12 +31,24 @@ const {
31
31
  const {
32
32
  resolveAntigravityGlobalDir,
33
33
  getGlobalConfigDir,
34
+ getGlobalSkillsBase,
34
35
  } = require('../gsd-core/bin/lib/runtime-homes.cjs');
35
36
  const {
36
37
  applyWorktreeBaseRef,
37
38
  readBaseRefFromSettings,
38
39
  } = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
39
- const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
40
+ const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
41
+ // Canonical set of hook files shipped to users. Imported here so writeManifest()
42
+ // records exactly the same set that build-hooks.js copies to hooks/dist/, making
43
+ // the manifest and the installed hooks/ dir structurally identical. Avoids the
44
+ // prefix/extension-regex approach that missed managed-hooks-registry.cjs (#941).
45
+ const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
46
+ const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
47
+
48
+ // ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated module.
49
+ // bin/install.js re-exports everything from hooksSurface so existing callers
50
+ // (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
51
+ const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
40
52
 
41
53
  /**
42
54
  * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
@@ -323,6 +335,13 @@ const {
323
335
  stageAgentsForProfile,
324
336
  stageSkillsForRuntimeAsSkills,
325
337
  } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
338
+ // ADR-857 phase 4c: load capability registry (optional; missing → falls back to undefined)
339
+ let _capabilityRegistry;
340
+ try {
341
+ _capabilityRegistry = require(path.join(_gsdLibDir, 'capability-registry.cjs'));
342
+ } catch (_) {
343
+ _capabilityRegistry = undefined;
344
+ }
326
345
  const {
327
346
  applyInstallerMigrationPlan,
328
347
  discoverInstallerMigrations,
@@ -348,23 +367,6 @@ const {
348
367
  const args = process.argv.slice(2);
349
368
  const hasGlobal = args.includes('--global') || args.includes('-g');
350
369
  const hasLocal = args.includes('--local') || args.includes('-l');
351
- const hasOpencode = args.includes('--opencode');
352
- const hasClaude = args.includes('--claude');
353
- const hasGemini = args.includes('--gemini');
354
- const hasKilo = args.includes('--kilo');
355
- const hasCodex = args.includes('--codex');
356
- const hasCopilot = args.includes('--copilot');
357
- const hasAntigravity = args.includes('--antigravity');
358
- const hasCursor = args.includes('--cursor');
359
- const hasWindsurf = args.includes('--windsurf');
360
- const hasAugment = args.includes('--augment');
361
- const hasTrae = args.includes('--trae');
362
- const hasQwen = args.includes('--qwen');
363
- const hasHermes = args.includes('--hermes');
364
- const hasCodebuddy = args.includes('--codebuddy');
365
- const hasCline = args.includes('--cline');
366
- const hasBoth = args.includes('--both'); // Legacy flag, keeps working
367
- const hasAll = args.includes('--all');
368
370
  const hasUninstall = args.includes('--uninstall') || args.includes('-u');
369
371
  const hasSkillsRoot = args.includes('--skills-root');
370
372
  const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1';
@@ -391,30 +393,37 @@ if (hasMinimal && _profileArgRaw) {
391
393
  process.exit(1);
392
394
  }
393
395
 
394
- // Runtime selection - can be set by flags or interactive prompt
395
- let selectedRuntimes = [];
396
- if (hasAll) {
397
- selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline'];
398
- } else if (hasBoth) {
399
- selectedRuntimes = ['claude', 'opencode'];
400
- } else {
401
- if (hasClaude) selectedRuntimes.push('claude');
402
- if (hasOpencode) selectedRuntimes.push('opencode');
403
- if (hasGemini) selectedRuntimes.push('gemini');
404
- if (hasKilo) selectedRuntimes.push('kilo');
405
- if (hasCodex) selectedRuntimes.push('codex');
406
- if (hasCopilot) selectedRuntimes.push('copilot');
407
- if (hasAntigravity) selectedRuntimes.push('antigravity');
408
- if (hasCursor) selectedRuntimes.push('cursor');
409
- if (hasWindsurf) selectedRuntimes.push('windsurf');
410
- if (hasAugment) selectedRuntimes.push('augment');
411
- if (hasTrae) selectedRuntimes.push('trae');
412
- if (hasQwen) selectedRuntimes.push('qwen');
413
- if (hasHermes) selectedRuntimes.push('hermes');
414
- if (hasCodebuddy) selectedRuntimes.push('codebuddy');
415
- if (hasCline) selectedRuntimes.push('cline');
396
+ function selectRuntimesFromArgs(runtimeArgs) {
397
+ if (runtimeArgs.includes('--all')) {
398
+ return ['claude', 'kimi', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline'];
399
+ }
400
+ if (runtimeArgs.includes('--both')) {
401
+ return ['claude', 'opencode'];
402
+ }
403
+
404
+ const selected = [];
405
+ if (runtimeArgs.includes('--claude')) selected.push('claude');
406
+ if (runtimeArgs.includes('--opencode')) selected.push('opencode');
407
+ if (runtimeArgs.includes('--gemini')) selected.push('gemini');
408
+ if (runtimeArgs.includes('--kilo')) selected.push('kilo');
409
+ if (runtimeArgs.includes('--codex')) selected.push('codex');
410
+ if (runtimeArgs.includes('--copilot')) selected.push('copilot');
411
+ if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
412
+ if (runtimeArgs.includes('--cursor')) selected.push('cursor');
413
+ if (runtimeArgs.includes('--windsurf') || runtimeArgs.includes('--devin-desktop')) selected.push('windsurf');
414
+ if (runtimeArgs.includes('--augment')) selected.push('augment');
415
+ if (runtimeArgs.includes('--trae')) selected.push('trae');
416
+ if (runtimeArgs.includes('--qwen')) selected.push('qwen');
417
+ if (runtimeArgs.includes('--hermes')) selected.push('hermes');
418
+ if (runtimeArgs.includes('--kimi')) selected.push('kimi');
419
+ if (runtimeArgs.includes('--codebuddy')) selected.push('codebuddy');
420
+ if (runtimeArgs.includes('--cline')) selected.push('cline');
421
+ return selected;
416
422
  }
417
423
 
424
+ // Runtime selection - can be set by flags or interactive prompt
425
+ let selectedRuntimes = selectRuntimesFromArgs(args);
426
+
418
427
  // WSL + Windows Node.js detection
419
428
  // When Windows-native Node runs on WSL, os.homedir() and path.join() produce
420
429
  // backslash paths that don't resolve correctly on the Linux filesystem.
@@ -456,13 +465,14 @@ function getDirName(runtime) {
456
465
  if (runtime === 'gemini') return '.gemini';
457
466
  if (runtime === 'kilo') return '.kilo';
458
467
  if (runtime === 'codex') return '.codex';
459
- if (runtime === 'antigravity') return '.agent';
468
+ if (runtime === 'antigravity') return '.agents';
460
469
  if (runtime === 'cursor') return '.cursor';
461
- if (runtime === 'windsurf') return '.windsurf';
470
+ if (runtime === 'windsurf') return '.devin';
462
471
  if (runtime === 'augment') return '.augment';
463
472
  if (runtime === 'trae') return '.trae';
464
473
  if (runtime === 'qwen') return '.qwen';
465
474
  if (runtime === 'hermes') return '.hermes';
475
+ if (runtime === 'kimi') return '.kimi-code';
466
476
  if (runtime === 'codebuddy') return '.codebuddy';
467
477
  if (runtime === 'cline') return '.cline';
468
478
  return '.claude';
@@ -490,7 +500,7 @@ function getConfigDirFromHome(runtime, isGlobal) {
490
500
  if (runtime === 'kilo') return "'.config', 'kilo'";
491
501
  if (runtime === 'codex') return "'.codex'";
492
502
  if (runtime === 'antigravity') {
493
- if (!isGlobal) return "'.agent'";
503
+ if (!isGlobal) return "'.agents'";
494
504
  const antigravityDir = resolveAntigravityGlobalDir();
495
505
  const rel = path.relative(os.homedir(), antigravityDir);
496
506
  const segments = rel.split(path.sep).filter(Boolean);
@@ -509,9 +519,17 @@ function getConfigDirFromHome(runtime, isGlobal) {
509
519
  if (runtime === 'hermes') return "'.hermes'";
510
520
  if (runtime === 'codebuddy') return "'.codebuddy'";
511
521
  if (runtime === 'cline') return "'.cline'";
522
+ if (runtime === 'kimi') return "'.config', 'agents'";
512
523
  return "'.claude'";
513
524
  }
514
525
 
526
+ /**
527
+ * Compatibility seam for tests and older installer consumers.
528
+ * Runtime home resolution now lives in runtime-homes.cjs.
529
+ */
530
+ function getGlobalDir(runtime, explicitDir = null) {
531
+ return getGlobalConfigDir(runtime, explicitDir);
532
+ }
515
533
  const banner = '\n' +
516
534
  cyan + ' ██████╗ ███████╗██████╗\n' +
517
535
  ' ██╔════╝ ██╔════╝██╔══██╗\n' +
@@ -523,7 +541,7 @@ const banner = '\n' +
523
541
  ' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' +
524
542
  ' Git. Ship. Done.\n' +
525
543
  ' A meta-prompting, context engineering and spec-driven\n' +
526
- ' development workflows for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n';
544
+ ' development workflows for Claude Code, OpenCode, Gemini, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n';
527
545
 
528
546
  // Pure seam: parse --config-dir / -c from an arbitrary args array.
529
547
  // Returns the path string, '' for an empty equals-form value, or null when the
@@ -580,7 +598,7 @@ if (hasUninstall) {
580
598
 
581
599
  // Show help if requested
582
600
  if (hasHelp) {
583
- 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 — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for 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`);
601
+ 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}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir <path>${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=<name>${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for 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 Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`);
584
602
  process.exit(0);
585
603
  }
586
604
 
@@ -609,177 +627,22 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
609
627
  return `${resolvedTarget}/`;
610
628
  }
611
629
 
612
- /**
613
- * Normalize a raw `process.execPath` to a stable, upgrade-safe node binary
614
- * path. On Homebrew installs, `process.execPath` resolves symlinks and returns
615
- * the versioned Cellar path (e.g.
616
- * `/usr/local/Cellar/node/25.8.1/bin/node`). Baking that path into hook
617
- * commands causes `dyld: Library not loaded` errors after `brew upgrade node`
618
- * because the shared libraries referenced by the Cellar binary have changed
619
- * SOVERSION. (#3181)
620
- *
621
- * The stable Homebrew symlinks (`/usr/local/bin/node` for Intel,
622
- * `/opt/homebrew/bin/node` for Apple Silicon) survive upgrades — Homebrew
623
- * re-points them atomically. We prefer those when a Cellar path is detected.
624
- *
625
- * Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is.
626
- */
627
- function normalizeNodePath(execPath) {
628
- if (!execPath) return execPath;
629
- // Intel Homebrew: /usr/local/Cellar/node/<version>/bin/node
630
- // or /usr/local/Cellar/node@20/<version>/bin/node
631
- if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
632
- return '/usr/local/bin/node';
633
- }
634
- // Apple Silicon Homebrew: /opt/homebrew/Cellar/node/<version>/bin/node
635
- // or /opt/homebrew/Cellar/node@18/<version>/bin/node
636
- if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
637
- return '/opt/homebrew/bin/node';
638
- }
639
- return execPath;
640
- }
641
-
642
- /**
643
- * Resolve the absolute path to the node binary running the installer.
644
- * Used as the runner for .js hooks so they execute in GUI/minimal-PATH
645
- * runtimes (Gemini, Antigravity, Codex CLIs launched from a Finder
646
- * shortcut etc.) where bare `node` is not on `/usr/bin:/bin:/usr/sbin:/sbin`
647
- * and the hook would fail with `node: command not found` (#2979).
648
- *
649
- * Returns a forward-slash-normalized, double-quoted path so the emitted
650
- * command is shell-safe across POSIX and Windows. `process.execPath`
651
- * gives the absolute path of the node binary actively running the
652
- * installer — that is the version the user just installed under, and
653
- * the right default runtime for hooks invoked under the same install.
654
- *
655
- * When `process.execPath` is a versioned Homebrew Cellar path, the stable
656
- * Homebrew symlink is returned instead to survive `brew upgrade node` (#3181).
657
- */
658
- function resolveNodeRunner() {
659
- const execPath = typeof process.execPath === 'string' ? process.execPath : '';
660
- if (!execPath) return null;
661
- const stablePath = normalizeNodePath(execPath);
662
- // JSON.stringify produces a properly escaped double-quoted shell token,
663
- // safe for paths containing spaces or unusual characters.
664
- return JSON.stringify(stablePath.replace(/\\/g, '/'));
665
- }
666
-
667
- /**
668
- * Rewrite legacy `node .../gsd-*.js` command strings in settings.hooks to use
669
- * the absolute Node binary path (#2979 follow-up: CR feedback on #3002).
670
- *
671
- * The original #2979 fix only emitted absolute paths for *newly registered*
672
- * hooks. Pre-existing entries kept their bare `node ` prefix on reinstall,
673
- * which left them broken under minimal-PATH GUI runtimes — exactly the
674
- * failure mode the original fix was meant to close. This walker normalizes
675
- * any managed-hook entry whose command starts with bare `node ` to
676
- * `<absoluteRunner> <script>` while leaving non-managed and non-bare-node
677
- * entries (user-authored hooks, shell scripts, etc.) untouched.
678
- *
679
- * Returns true if any entry was rewritten.
680
- */
681
- function resolveBashRunner(opts) {
682
- const platform = (opts && opts.platform) || process.platform;
683
- if (platform !== 'win32') return 'bash';
684
-
685
- const env = (opts && opts.env) || process.env;
686
- const exists = (opts && opts.existsSync) || fs.existsSync;
687
- const candidates = [];
688
- if (env.GSD_BASH_PATH) candidates.push(env.GSD_BASH_PATH);
689
- if (env.ProgramFiles) candidates.push(path.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'));
690
- if (env['ProgramFiles(x86)']) candidates.push(path.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'));
691
- if (env.SystemDrive) {
692
- candidates.push(path.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe'));
693
- candidates.push(path.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe'));
694
- }
695
-
696
- for (const candidate of candidates) {
697
- if (candidate && exists(candidate)) {
698
- return JSON.stringify(candidate.replace(/\\/g, '/'));
699
- }
700
- }
701
- return null;
702
- }
630
+ // normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are
631
+ // now owned by the runtime-hooks-surface module. Import them here so
632
+ // install.js callers continue to work and so there is a single implementation
633
+ // of these helpers.
634
+ const normalizeNodePath = hooksSurface.normalizeNodePath;
635
+ const resolveNodeRunner = hooksSurface.resolveNodeRunner;
636
+ const resolveBashRunner = hooksSurface.resolveBashRunner;
637
+ // referencesHook: pure predicate over hook entry objects, shared between
638
+ // install() and finishInstall() (ADR-857 phase 5f-1b).
639
+ const referencesHook = hooksSurface.referencesHook;
640
+ // applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
641
+ // hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
642
+ const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
703
643
 
704
644
  function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
705
- if (!settings || !settings.hooks || !absoluteRunner) return false;
706
- if (!opts) opts = {};
707
- const platform = opts.platform || process.platform;
708
- let changed = false;
709
- for (const entries of Object.values(settings.hooks)) {
710
- if (!Array.isArray(entries)) continue;
711
- for (const entry of entries) {
712
- if (!entry || !Array.isArray(entry.hooks)) continue;
713
- for (const h of entry.hooks) {
714
- if (!h || typeof h.command !== 'string') continue;
715
- let trimmed = h.command.trim();
716
- const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
717
- if (hadPowerShellCallOperator) {
718
- trimmed = trimmed.replace(/^&\s+/, '').trim();
719
- }
720
- // Match two runner forms:
721
- // 1. Legacy bare-node form: `node <script>` (#2979/#3002)
722
- // 2. Cellar-path form: `"/usr/local/Cellar/node/<v>/bin/node" <script>`
723
- // or `"/opt/homebrew/Cellar/node/<v>/bin/node" <script>` (#3181)
724
- //
725
- // Both patterns use the same script-token capture group so the rewrite
726
- // is uniform. We detect the Cellar form by extracting the runner token
727
- // and running it through normalizeNodePath.
728
- //
729
- // The previous shape used `trimmed.includes(<filename>)` which would
730
- // false-positive on user-authored hooks whose path merely contained
731
- // a managed filename as a substring (e.g.
732
- // /home/me/scripts/wraps-gsd-check-update.js-and-more.js). #3002 CR.
733
- const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
734
- trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
735
- if (!m) continue;
736
-
737
- let runnerToken, scriptToken, scriptPath;
738
- if (/^node\s+/.test(trimmed)) {
739
- // bare-node form
740
- runnerToken = 'node';
741
- scriptToken = m[1];
742
- scriptPath = m[2] || m[3] || m[4] || '';
743
- } else {
744
- // quoted/unquoted runner form — check whether runner is a Cellar path
745
- runnerToken = m[1];
746
- const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
747
- const stableRunner = normalizeNodePath(runnerPath);
748
- // Process Cellar paths so they normalize to a stable symlink. On
749
- // Windows, already-absolute runners still flow through the projection
750
- // seam because some runtimes need additional wrapper policy while
751
- // others must stay shell-neutral (#3362, #3413).
752
- if (stableRunner === runnerPath && platform !== 'win32') continue;
753
- scriptToken = m[5];
754
- scriptPath = m[6] || m[7] || m[8] || '';
755
- }
756
-
757
- // Take the basename — match against MANAGED_HOOK_FILES by exact
758
- // equality, not substring containment. Handles both forward and
759
- // backslash separators (Windows).
760
- if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue;
761
-
762
- const projectedCommand = projectLegacySettingsHookCommand({
763
- absoluteRunner,
764
- scriptPath,
765
- scriptToken,
766
- runtime: opts.runtime || 'generic',
767
- platform,
768
- });
769
- if (!projectedCommand) continue;
770
-
771
- // Skip only when the existing managed command already matches the
772
- // desired runtime-aware projected shape. This preserves Gemini's
773
- // required PowerShell prefix while still letting Claude strip stale
774
- // prefixes on reinstall (#3413).
775
- if (h.command === projectedCommand) continue;
776
-
777
- h.command = projectedCommand;
778
- changed = true;
779
- }
780
- }
781
- }
782
- return changed;
645
+ return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
783
646
  }
784
647
 
785
648
  /**
@@ -805,22 +668,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
805
668
  * @returns {string|null} The toml block to append, or null on missing runner.
806
669
  */
807
670
  function buildCodexHookBlock(targetDir, opts) {
808
- const absoluteRunner = opts && opts.absoluteRunner;
809
- if (!absoluteRunner) return null;
810
- const eol = (opts && opts.eol) || '\n';
811
- const platform = (opts && opts.platform) || process.platform;
812
- const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
813
- const commandValue = projectCodexHookTomlCommand({
814
- absoluteRunner,
815
- scriptPath: updateCheckScript,
816
- platform,
817
- });
818
- return `${eol}# GSD Hooks${eol}` +
819
- `[[hooks.SessionStart]]${eol}` +
820
- `${eol}` +
821
- `[[hooks.SessionStart.hooks]]${eol}` +
822
- `type = "command"${eol}` +
823
- `command = "${commandValue}"${eol}`;
671
+ return hooksSurface.buildCodexHookBlock(targetDir, opts);
824
672
  }
825
673
 
826
674
  /**
@@ -837,45 +685,7 @@ function buildCodexHookBlock(targetDir, opts) {
837
685
  * @returns {{ content: string, changed: boolean }}
838
686
  */
839
687
  function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
840
- if (!content || !absoluteRunner) return { content, changed: false };
841
- const platform = (opts && opts.platform) || process.platform;
842
- let changed = false;
843
- // Match `command = "node <scriptToken>"` lines where scriptToken is
844
- // either an unquoted path (no spaces) or a toml-escaped quoted path.
845
- // The whole RHS is a toml-double-quoted string; interior quotes are \".
846
- // Examples we want to migrate:
847
- // command = "node /Users/x/.codex/hooks/gsd-check-update.js"
848
- // command = "node \"/Users/x/.codex/hooks/gsd-check-update.js\""
849
- // Examples we must leave alone:
850
- // command = "\"/usr/local/bin/node\" \"/path/to/gsd-check-update.js\"" ← already absolute
851
- // command = "node /home/me/my-custom.js" ← user-owned filename
852
- const updated = content.replace(
853
- /^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm,
854
- (full, prefix, scriptToken, suffix) => {
855
- // Extract the underlying script path from the captured token —
856
- // either the bare token or the decoded inner content of \"...\".
857
- const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
858
- let scriptPath = scriptToken;
859
- if (quoted) {
860
- try {
861
- scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
862
- } catch {
863
- scriptPath = quoted[1];
864
- }
865
- }
866
- if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full;
867
- const desiredCommand = projectCodexHookTomlCommand({
868
- absoluteRunner,
869
- scriptPath,
870
- platform,
871
- });
872
- const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
873
- if (currentCommand === desiredCommand) return full;
874
- changed = true;
875
- return `${prefix}${desiredCommand}${suffix}`;
876
- },
877
- );
878
- return { content: updated, changed };
688
+ return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
879
689
  }
880
690
 
881
691
  /**
@@ -898,83 +708,7 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
898
708
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
899
709
  */
900
710
  function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
901
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
902
- const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
903
- const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
904
- const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
905
- const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
906
- let parsed = {};
907
- let currentContent = null;
908
- if (fs.existsSync(hooksJsonPath)) {
909
- const raw = fs.readFileSync(hooksJsonPath, 'utf8');
910
- currentContent = raw;
911
- if (raw.trim()) {
912
- try {
913
- parsed = JSON.parse(raw);
914
- } catch (err) {
915
- throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
916
- }
917
- }
918
- }
919
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
920
-
921
- const usesNestedHooksObject =
922
- parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
923
- const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
924
- const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
925
-
926
- let removedLegacy = false;
927
- const sanitizedEntries = [];
928
- for (const entry of eventEntries) {
929
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
930
- const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
931
- if (originalHooks.length === 0) {
932
- sanitizedEntries.push(entry);
933
- continue;
934
- }
935
- const keptHooks = originalHooks.filter((hook) => {
936
- const cmd = hook && typeof hook === 'object' ? hook.command : null;
937
- const managed = isManagedHookCommand(cmd, {
938
- surface: 'codex-hooks-json',
939
- includeLegacyAliases: true,
940
- configDir: targetDir,
941
- });
942
- if (managed) removedLegacy = true;
943
- return !managed;
944
- });
945
- if (keptHooks.length === 0) continue;
946
- const nextEntry = { ...entry, hooks: keptHooks };
947
- sanitizedEntries.push(nextEntry);
948
- }
949
-
950
- if (managedCommand) {
951
- const hookEntry = { type: 'command', command: managedCommand };
952
- // #772: emit commandWindows so Codex picks the .cmd shim on Windows and
953
- // the POSIX command on other platforms — without requiring per-OS config
954
- // regeneration. Sourced from HookHandlerConfig.command_windows field in
955
- // codex-rs/config/src/hook_config.rs (alias: commandWindows).
956
- if (commandWindows) hookEntry.commandWindows = commandWindows;
957
- if (timeout !== undefined) hookEntry.timeout = timeout;
958
- const newEntry = { hooks: [hookEntry] };
959
- if (matcher !== undefined) newEntry.matcher = matcher;
960
- sanitizedEntries.push(newEntry);
961
- }
962
-
963
- if (sanitizedEntries.length > 0) {
964
- hookTable[eventName] = sanitizedEntries;
965
- } else {
966
- delete hookTable[eventName];
967
- }
968
- if (usesNestedHooksObject) parsed.hooks = hookTable;
969
-
970
- const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
971
- const changed = currentContent !== nextContent;
972
- const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
973
- if (shouldWrite) {
974
- atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
975
- }
976
-
977
- return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
711
+ return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
978
712
  }
979
713
 
980
714
  /**
@@ -986,7 +720,7 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
986
720
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
987
721
  */
988
722
  function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
989
- return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
723
+ return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
990
724
  }
991
725
 
992
726
  /**
@@ -1019,41 +753,7 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
1019
753
  * @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
1020
754
  */
1021
755
  function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1022
- if (!absoluteRunnerToken) return null;
1023
- // absoluteRunnerToken is JSON-quoted (e.g. '"C:/path/node.exe"'). Unwrap to
1024
- // get the raw interpreter path for the invocation record and render output.
1025
- let interpreter;
1026
- try {
1027
- interpreter = JSON.parse(absoluteRunnerToken);
1028
- } catch {
1029
- interpreter = absoluteRunnerToken;
1030
- }
1031
- // Normalise to forward slashes for cross-shell safety (same as other Windows
1032
- // hook path normalisations in this codebase).
1033
- const targetAbs = scriptAbsPath.replace(/\\/g, '/');
1034
- const scriptQuoted = JSON.stringify(targetAbs);
1035
- // .cmd shim lives alongside the .js file, replacing the extension.
1036
- const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
1037
- // The hook command written to hooks.json is just the .cmd path (double-quoted
1038
- // for spaces-in-path safety). cmd.exe executes .cmd files natively via
1039
- // CreateProcess — no runner prefix required.
1040
- const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
1041
- const runnerQuoted = JSON.stringify(interpreter);
1042
- return {
1043
- invocation: { interpreter, target: scriptAbsPath },
1044
- cmdPath,
1045
- hookCommand,
1046
- // Typed fields for IR-level assertions (CONTRIBUTING.md L558-L565).
1047
- // These describe the render semantics in a structured way so tests can
1048
- // assert on the generator contract without coupling to rendered text.
1049
- eol: { cmd: '\r\n' }, // CRLF — canonical for cmd.exe .cmd files
1050
- passthroughArgs: true, // the shim forwards all args via %*
1051
- render: {
1052
- // Use CRLF line endings for strict cmd.exe compatibility (LF-only
1053
- // .cmd files work in modern Windows but CRLF is the canonical format).
1054
- cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
1055
- },
1056
- };
756
+ return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
1057
757
  }
1058
758
 
1059
759
  /**
@@ -1084,69 +784,7 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1084
784
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1085
785
  */
1086
786
  function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1087
- const platform = opts.platform || process.platform;
1088
- const absoluteRunner = opts.absoluteRunner || null;
1089
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
1090
- if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
1091
-
1092
- // Normalize backslashes to forward slashes so isManagedHookCommand can
1093
- // match stored commands against configDir on Windows CI runners where
1094
- // path.resolve returns backslash paths but the stored command may use
1095
- // forward slashes (or vice versa). Forward-slash paths are always valid on
1096
- // Windows for both Node.js and Codex, so this normalization is safe for all
1097
- // platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.)
1098
- const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
1099
-
1100
- // #772: compute the Windows .cmd shim path cross-platform so that
1101
- // `commandWindows` can be emitted in hooks.json regardless of the host OS.
1102
- // The .cmd path is always the .js script path with extension replaced.
1103
- const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
1104
-
1105
- let managedCommand;
1106
- if (platform === 'win32') {
1107
- // #3426 fix: on Windows, write a .cmd shim and use its path as the hook
1108
- // command. This avoids the MSYS bash.exe POSIX-exec failure when Codex's
1109
- // hook dispatcher tries to run node.exe through the Git Bash exec layer.
1110
- const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
1111
- if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
1112
- try {
1113
- atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
1114
- } catch (shimWriteErr) {
1115
- // Shim write failed — do NOT fall back to the old "node.exe script.js"
1116
- // command. That form triggers the `bash.exe: cannot execute binary file`
1117
- // failure that #3426 exists to fix, so a silent fallback would silently
1118
- // restore the original bug. Instead: warn loudly and skip the registration
1119
- // for this runtime so the user sees an actionable message rather than a
1120
- // successful install that fails at hook-dispatch time.
1121
- const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
1122
- console.warn(
1123
- ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
1124
- `Fix the write error (permissions? disk full?) and re-run the installer. ` +
1125
- `Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`,
1126
- );
1127
- return { changed: false, wrote: false, path: hooksJsonPath };
1128
- }
1129
- managedCommand = shimIR.hookCommand;
1130
- } else {
1131
- managedCommand = projectManagedHookCommand({
1132
- absoluteRunner,
1133
- scriptPath,
1134
- runtime: 'codex',
1135
- platform,
1136
- });
1137
- }
1138
-
1139
- if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
1140
-
1141
- // #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where
1142
- // the shim was actually written. On POSIX, commandWindows is omitted to avoid
1143
- // pointing Windows Codex at a non-existent .cmd file (the shim is only present
1144
- // when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR).
1145
- const commandWindows = platform === 'win32'
1146
- ? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
1147
- : undefined;
1148
-
1149
- return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
787
+ return hooksSurface.ensureCodexHooksJsonSessionStart(targetDir, opts);
1150
788
  }
1151
789
 
1152
790
  /**
@@ -1173,50 +811,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
1173
811
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
1174
812
  */
1175
813
  function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1176
- const platform = opts.platform || process.platform;
1177
- const absoluteRunner = opts.absoluteRunner || null;
1178
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
1179
- if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
1180
-
1181
- // Normalize backslashes to forward slashes so that isManagedHookCommand can
1182
- // match the stored command against configDir on Windows. path.resolve on
1183
- // Windows returns backslash paths, but when platform is not 'win32'
1184
- // (e.g. platform: 'linux' in a test running on a Windows CI runner),
1185
- // projectManagedHookCommand does not normalize them — producing a mismatch
1186
- // between the stored command and the configDir-based hook-dir prefix used
1187
- // for deduplication. Forward-slash paths are always valid on Windows (Node.js
1188
- // and Codex both accept them), so normalizing here is safe for all platforms.
1189
- const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
1190
-
1191
- let managedCommand;
1192
- if (platform === 'win32') {
1193
- // #3426 fix pattern: on Windows, write a .cmd shim and use its path as the
1194
- // hook command. The same bash.exe POSIX-exec failure that affects
1195
- // gsd-check-update.js also affects gsd-context-monitor.js.
1196
- const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
1197
- if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
1198
- try {
1199
- atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
1200
- } catch (shimWriteErr) {
1201
- const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
1202
- console.warn(
1203
- ` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
1204
- `Fix the write error (permissions? disk full?) and re-run the installer.`,
1205
- );
1206
- return { changed: false, wrote: false, path: hooksJsonPath };
1207
- }
1208
- managedCommand = shimIR.hookCommand;
1209
- } else {
1210
- managedCommand = projectManagedHookCommand({
1211
- absoluteRunner,
1212
- scriptPath,
1213
- runtime: 'codex',
1214
- platform,
1215
- });
1216
- }
1217
-
1218
- if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
1219
- return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
814
+ return hooksSurface.ensureCodexHooksJsonEvent(targetDir, eventName, opts);
1220
815
  }
1221
816
 
1222
817
  /**
@@ -1226,11 +821,11 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
1226
821
  * @param {string} eventName
1227
822
  */
1228
823
  function removeCodexHooksJsonEvent(targetDir, eventName) {
1229
- return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
824
+ return hooksSurface.removeCodexHooksJsonEvent(targetDir, eventName);
1230
825
  }
1231
826
 
1232
827
  function removeCodexHooksJsonSessionStart(targetDir) {
1233
- return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
828
+ return hooksSurface.removeCodexHooksJsonSessionStart(targetDir);
1234
829
  }
1235
830
 
1236
831
  /**
@@ -1247,62 +842,7 @@ function removeCodexHooksJsonSessionStart(targetDir) {
1247
842
  * runtime: target runtime name for shell projection policy.
1248
843
  */
1249
844
  function buildHookCommand(configDir, hookName, opts) {
1250
- if (!opts) opts = {};
1251
- const platform = opts.platform || process.platform;
1252
- const runtime = opts.runtime || 'generic';
1253
- const isShellHook = hookName.endsWith('.sh');
1254
-
1255
- // #166: Claude Code executes these hook commands inside a bash context on
1256
- // Windows, so wrapping `.sh` hooks with an explicit `bash.exe` path can
1257
- // trigger `bash.exe: ... cannot execute binary file`. Emit only the quoted
1258
- // script path for Claude on Windows.
1259
- if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
1260
- if (opts.portableHooks) {
1261
- const portableBaseDir = projectPortableHookBaseDir({
1262
- configDir,
1263
- homeDir: os.homedir(),
1264
- });
1265
- return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
1266
- }
1267
- return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
1268
- }
1269
-
1270
- // POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
1271
- // but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
1272
- // Windows Codex launches hooks from PowerShell/cmd environments where bare
1273
- // `bash` may not be on PATH, so resolve Git Bash explicitly or return null so
1274
- // callers skip registration instead of installing a known-broken hook (#3393).
1275
- // .js hooks still need the absolute node path because GUI-launched runtimes
1276
- // start with a minimal PATH that may not include nvm/Homebrew/Volta node
1277
- // binaries (#2979).
1278
- const nodeRunner = resolveNodeRunner();
1279
- const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
1280
- // Runner resolvers return null when the executable path is unavailable.
1281
- // Fall through with null so callers can skip registration with a warning
1282
- // instead of emitting a command that recreates the original hook failure.
1283
- if (runner === null) return null;
1284
-
1285
- if (opts.portableHooks) {
1286
- const portableBaseDir = projectPortableHookBaseDir({
1287
- configDir,
1288
- homeDir: os.homedir(),
1289
- });
1290
- return projectManagedHookCommand({
1291
- absoluteRunner: runner,
1292
- scriptPath: `${portableBaseDir}/hooks/${hookName}`,
1293
- runtime: opts.runtime || 'generic',
1294
- platform,
1295
- });
1296
- }
1297
-
1298
- // Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046).
1299
- const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
1300
- return projectManagedHookCommand({
1301
- absoluteRunner: runner,
1302
- scriptPath: hooksPath,
1303
- runtime,
1304
- platform,
1305
- });
845
+ return hooksSurface.buildHookCommand(configDir, hookName, opts);
1306
846
  }
1307
847
 
1308
848
  /**
@@ -1669,6 +1209,52 @@ function injectEffortFrontmatter(content, effortValue) {
1669
1209
  return `${before}effort: ${effortValue}${eol}${after}`;
1670
1210
  }
1671
1211
 
1212
+ /**
1213
+ * #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
1214
+ * Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
1215
+ * inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
1216
+ * which break on unknown frontmatter keys.
1217
+ */
1218
+ function injectDisallowedToolsFrontmatter(content, disallowedValue) {
1219
+ // Detect the dominant EOL from the first line (the opening `---`).
1220
+ // If the very first `---` is followed by \r\n, treat the whole file as CRLF.
1221
+ const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
1222
+
1223
+ // Build a frontmatter-matching regex that tolerates an optional \r before
1224
+ // each \n, so we handle both LF and CRLF files without needing to normalise
1225
+ // the whole content.
1226
+ const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
1227
+ const match = fmRe.exec(content);
1228
+ if (!match) return content; // no YAML frontmatter — leave unchanged
1229
+
1230
+ // Idempotency guard: don't insert a second disallowedTools: line.
1231
+ const fmBody = match[1]; // content between the two `---` lines
1232
+ if (/^disallowedTools:/m.test(fmBody)) return content;
1233
+
1234
+ // Locate the exact position of the closing `---` line so we can insert
1235
+ // before it using a simple string splice.
1236
+ const openLen = 3 + eol.length; // "---" + eol
1237
+ const closingStart = match.index + openLen + fmBody.length;
1238
+
1239
+ const before = content.slice(0, closingStart);
1240
+ const after = content.slice(closingStart);
1241
+ return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
1242
+ }
1243
+
1244
+ // #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
1245
+ // Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
1246
+ // output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
1247
+ // excluded (it legitimately uses Write AND Edit to create/patch test files).
1248
+ const READONLY_AGENT_DISALLOWED_TOOLS = {
1249
+ 'gsd-plan-checker': 'Write, Edit, MultiEdit',
1250
+ 'gsd-integration-checker': 'Write, Edit, MultiEdit',
1251
+ 'gsd-ui-checker': 'Write, Edit, MultiEdit',
1252
+ 'gsd-verifier': 'Edit, MultiEdit',
1253
+ 'gsd-doc-verifier': 'Edit, MultiEdit',
1254
+ 'gsd-eval-auditor': 'Edit, MultiEdit',
1255
+ 'gsd-ui-auditor': 'Edit, MultiEdit',
1256
+ };
1257
+
1672
1258
  /**
1673
1259
  * #2517 — Read a single GSD config file (defaults.json or per-project
1674
1260
  * config.json) into a plain object, returning null on missing/empty files
@@ -1896,6 +1482,35 @@ const claudeToGeminiTools = {
1896
1482
  TodoWrite: 'write_todos',
1897
1483
  };
1898
1484
 
1485
+ // Tool name mapping from Claude/GSD agents to Kimi CLI module paths.
1486
+ // Kimi custom agent YAML requires fully-qualified module paths.
1487
+ const claudeToKimiTools = {
1488
+ Read: 'kimi_cli.tools.file:ReadFile',
1489
+ ReadFile: 'kimi_cli.tools.file:ReadFile',
1490
+ Write: 'kimi_cli.tools.file:WriteFile',
1491
+ WriteFile: 'kimi_cli.tools.file:WriteFile',
1492
+ Edit: 'kimi_cli.tools.file:StrReplaceFile',
1493
+ MultiEdit: 'kimi_cli.tools.file:StrReplaceFile',
1494
+ StrReplaceFile: 'kimi_cli.tools.file:StrReplaceFile',
1495
+ Bash: 'kimi_cli.tools.shell:Shell',
1496
+ Shell: 'kimi_cli.tools.shell:Shell',
1497
+ Grep: 'kimi_cli.tools.file:Grep',
1498
+ Glob: 'kimi_cli.tools.file:Glob',
1499
+ Agent: 'kimi_cli.tools.agent:Agent',
1500
+ Task: 'kimi_cli.tools.agent:Agent',
1501
+ AskUserQuestion: 'kimi_cli.tools.ask_user:AskUserQuestion',
1502
+ TodoWrite: 'kimi_cli.tools.todo:SetTodoList',
1503
+ SetTodoList: 'kimi_cli.tools.todo:SetTodoList',
1504
+ WebSearch: 'kimi_cli.tools.web:SearchWeb',
1505
+ SearchWeb: 'kimi_cli.tools.web:SearchWeb',
1506
+ WebFetch: 'kimi_cli.tools.web:FetchURL',
1507
+ FetchURL: 'kimi_cli.tools.web:FetchURL',
1508
+ ReadMediaFile: 'kimi_cli.tools.file:ReadMediaFile',
1509
+ TaskList: 'kimi_cli.tools.background:TaskList',
1510
+ TaskOutput: 'kimi_cli.tools.background:TaskOutput',
1511
+ TaskStop: 'kimi_cli.tools.background:TaskStop',
1512
+ };
1513
+
1899
1514
  /**
1900
1515
  * Convert a Claude Code tool name to OpenCode format
1901
1516
  * - Applies special mappings (AskUserQuestion -> question, etc.)
@@ -1945,6 +1560,63 @@ function convertGeminiToolName(claudeTool) {
1945
1560
  return claudeTool.toLowerCase();
1946
1561
  }
1947
1562
 
1563
+ function createKimiToolDiagnostic(reason, tool, source = null) {
1564
+ const isMcp = reason === 'mcp_managed';
1565
+ return {
1566
+ level: 'warning',
1567
+ code: isMcp ? 'kimi_mcp_tool_excluded' : 'kimi_unsupported_tool',
1568
+ reason,
1569
+ message: isMcp
1570
+ ? `MCP-managed tool '${tool}' is configured outside Kimi agent YAML.`
1571
+ : `Tool '${tool}' is not supported by the Kimi tool mapper.`,
1572
+ value: tool,
1573
+ source,
1574
+ };
1575
+ }
1576
+
1577
+ /**
1578
+ * Convert a Claude/GSD tool name to a Kimi CLI module path.
1579
+ * @returns {string|null} Kimi module path, or null when excluded/unsupported.
1580
+ */
1581
+ function convertKimiToolName(claudeTool) {
1582
+ const tool = String(claudeTool || '').trim();
1583
+ if (!tool) return null;
1584
+ if (tool.startsWith('mcp__')) return null;
1585
+ return claudeToKimiTools[tool] || null;
1586
+ }
1587
+
1588
+ function mapClaudeToolsToKimiTools(claudeTools, options = {}) {
1589
+ const diagnostics = [];
1590
+ const tools = [];
1591
+ const seen = new Set();
1592
+ const source = options && Object.prototype.hasOwnProperty.call(options, 'source')
1593
+ ? options.source
1594
+ : null;
1595
+
1596
+ for (const rawTool of Array.isArray(claudeTools) ? claudeTools : []) {
1597
+ const tool = String(rawTool || '').trim();
1598
+ if (!tool) continue;
1599
+
1600
+ if (tool.startsWith('mcp__')) {
1601
+ diagnostics.push(createKimiToolDiagnostic('mcp_managed', tool, source));
1602
+ continue;
1603
+ }
1604
+
1605
+ const kimiTool = convertKimiToolName(tool);
1606
+ if (!kimiTool) {
1607
+ diagnostics.push(createKimiToolDiagnostic('unsupported_tool', tool, source));
1608
+ continue;
1609
+ }
1610
+
1611
+ if (!seen.has(kimiTool)) {
1612
+ seen.add(kimiTool);
1613
+ tools.push(kimiTool);
1614
+ }
1615
+ }
1616
+
1617
+ return { tools, diagnostics };
1618
+ }
1619
+
1948
1620
  const claudeToKiloAgentPermissions = {
1949
1621
  Read: 'read',
1950
1622
  Write: 'edit',
@@ -2065,12 +1737,13 @@ function convertClaudeToCopilotContent(content, isGlobal = false) {
2065
1737
  return c;
2066
1738
  }
2067
1739
 
1740
+ // isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
2068
1741
  /**
2069
1742
  * Convert a Claude command (.md) to a Copilot skill (SKILL.md).
2070
1743
  * Transforms frontmatter only — body passes through with CONV-06/07 applied.
2071
1744
  * Skills keep original tool names (no mapping) per CONTEXT.md decision.
2072
1745
  */
2073
- function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) {
1746
+ function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
2074
1747
  const converted = convertClaudeToCopilotContent(content, isGlobal);
2075
1748
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
2076
1749
  if (!frontmatter) return converted;
@@ -2225,6 +1898,283 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c
2225
1898
  return `${fm}\n${normalizedBody}`;
2226
1899
  }
2227
1900
 
1901
+ function normalizeKimiSkillName(skillName) {
1902
+ let text = String(skillName || '').trim().toLowerCase();
1903
+ if (text.startsWith('/')) text = text.slice(1);
1904
+ if (text.startsWith('$')) text = text.slice(1);
1905
+ text = text.replace(/^gsd:/, 'gsd-');
1906
+ if (!text.startsWith('gsd-')) text = `gsd-${text}`;
1907
+ text = text.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
1908
+ return text || 'gsd-command';
1909
+ }
1910
+
1911
+ function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) {
1912
+ if (!Array.isArray(cmdNames) || cmdNames.length === 0) return content;
1913
+ const commands = [...cmdNames].sort((a, b) => b.length - a.length).map(escapeRegExp);
1914
+ const commandGroup = commands.join('|');
1915
+ const colonPattern = new RegExp(`(?<![A-Za-z0-9_/:.-])/?gsd:(${commandGroup})(?=[^A-Za-z0-9_-]|$)`, 'g');
1916
+ const hyphenPattern = new RegExp(`(?:/|\\$)gsd-(${commandGroup})(?=[^A-Za-z0-9_-]|$)`, 'g');
1917
+
1918
+ return content
1919
+ .replace(colonPattern, (_, cmd) => `/skill:gsd-${cmd}`)
1920
+ .replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`);
1921
+ }
1922
+
1923
+ function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cmdNames = null) {
1924
+ const { frontmatter, body } = extractFrontmatterAndBody(content);
1925
+ const kimiSkillName = normalizeKimiSkillName(skillName);
1926
+ const names = cmdNames || readGsdCommandNames();
1927
+ const description = frontmatter
1928
+ ? extractFrontmatterField(frontmatter, 'description') || `Run GSD workflow ${kimiSkillName}.`
1929
+ : `Run GSD workflow ${kimiSkillName}.`;
1930
+ const normalizedBody = convertGsdCommandReferencesToKimiSkillInvocations(
1931
+ frontmatter ? body : content,
1932
+ names
1933
+ );
1934
+
1935
+ return `---\nname: ${kimiSkillName}\ndescription: ${yamlQuote(toSingleLine(description))}\n---\nInvoke this Kimi skill with \`/skill:${kimiSkillName}\`.\n\n${normalizedBody}`;
1936
+ }
1937
+
1938
+ const KIMI_CANONICAL_GSD_AGENT_RE = /^gsd-[a-z0-9-]+$/;
1939
+
1940
+ function parseKimiAgentSource(source) {
1941
+ if (typeof source === 'string') {
1942
+ return {
1943
+ path: null,
1944
+ content: source,
1945
+ };
1946
+ }
1947
+ if (!source || typeof source !== 'object' || typeof source.content !== 'string') {
1948
+ return null;
1949
+ }
1950
+ return {
1951
+ path: typeof source.path === 'string' ? source.path : null,
1952
+ content: source.content,
1953
+ };
1954
+ }
1955
+
1956
+ function parseFrontmatterTools(frontmatter) {
1957
+ if (!frontmatter) return [];
1958
+ const lines = frontmatter.split(/\r?\n/);
1959
+ const tools = [];
1960
+ let collecting = false;
1961
+
1962
+ for (const line of lines) {
1963
+ const trimmed = line.trim();
1964
+ if (!trimmed) continue;
1965
+
1966
+ if (collecting) {
1967
+ if (trimmed.startsWith('- ')) {
1968
+ tools.push(trimmed.slice(2).trim());
1969
+ continue;
1970
+ }
1971
+ collecting = false;
1972
+ }
1973
+
1974
+ if (trimmed === 'tools:' || trimmed === 'allowed-tools:') {
1975
+ collecting = true;
1976
+ continue;
1977
+ }
1978
+
1979
+ if (trimmed.startsWith('tools:') || trimmed.startsWith('allowed-tools:')) {
1980
+ const value = trimmed.slice(trimmed.indexOf(':') + 1).trim();
1981
+ if (value) {
1982
+ for (const tool of value.split(',')) {
1983
+ const name = tool.trim();
1984
+ if (name) tools.push(name);
1985
+ }
1986
+ } else {
1987
+ collecting = true;
1988
+ }
1989
+ }
1990
+ }
1991
+
1992
+ return tools;
1993
+ }
1994
+
1995
+ function addKimiAgentDiagnostic(diagnostics, code, message, value, source = null) {
1996
+ diagnostics.push({
1997
+ level: 'warning',
1998
+ code,
1999
+ message,
2000
+ value,
2001
+ source,
2002
+ });
2003
+ }
2004
+
2005
+ function mapKimiAgentContractTools(toolNames, diagnostics, sourceName) {
2006
+ const result = mapClaudeToolsToKimiTools(toolNames, { source: sourceName });
2007
+ diagnostics.push(...result.diagnostics);
2008
+ return result.tools;
2009
+ }
2010
+
2011
+ function neutralizeKimiAgentPrompt(content) {
2012
+ const { frontmatter, body } = extractFrontmatterAndBody(content);
2013
+ let prompt = frontmatter ? body : content;
2014
+ prompt = neutralizeAgentReferences(prompt, 'AGENTS.md');
2015
+ prompt = prompt.replace(/~\/\.claude\/gsd-core\b/g, 'GSD core');
2016
+ prompt = prompt.replace(/\$HOME\/\.claude\/gsd-core\b/g, 'GSD core');
2017
+ return prompt.replace(/^\s*\r?\n/, '');
2018
+ }
2019
+
2020
+ function pushKimiToolsYaml(lines, indent, tools) {
2021
+ const prefix = ' '.repeat(indent);
2022
+ if (!Array.isArray(tools) || tools.length === 0) {
2023
+ lines.push(`${prefix}tools: []`);
2024
+ return;
2025
+ }
2026
+ lines.push(`${prefix}tools:`);
2027
+ for (const tool of tools) {
2028
+ lines.push(`${prefix} - ${yamlQuote(tool)}`);
2029
+ }
2030
+ }
2031
+
2032
+ function buildKimiRootAgentYaml({ description, tools, subagents }) {
2033
+ const lines = [
2034
+ 'version: 1',
2035
+ 'agent:',
2036
+ ' name: gsd',
2037
+ ` description: ${yamlQuote(toSingleLine(description || 'Run GSD workflows in Kimi CLI.'))}`,
2038
+ ' extend: default',
2039
+ ' system_prompt_path: ./gsd.md',
2040
+ ];
2041
+ pushKimiToolsYaml(lines, 2, tools);
2042
+
2043
+ if (subagents.length > 0) {
2044
+ lines.push(' subagents:');
2045
+ for (const subagent of subagents) {
2046
+ lines.push(` ${subagent.name}:`);
2047
+ lines.push(` path: ./subagents/${subagent.name}.yaml`);
2048
+ lines.push(` description: ${yamlQuote(toSingleLine(subagent.description))}`);
2049
+ }
2050
+ }
2051
+
2052
+ return `${lines.join('\n')}\n`;
2053
+ }
2054
+
2055
+ function buildKimiSubagentYaml({ name, description, tools }) {
2056
+ const lines = [
2057
+ 'version: 1',
2058
+ 'agent:',
2059
+ ` name: ${name}`,
2060
+ ` description: ${yamlQuote(toSingleLine(description || `Run ${name}.`))}`,
2061
+ ` system_prompt_path: ./${name}.md`,
2062
+ ];
2063
+ pushKimiToolsYaml(lines, 2, tools);
2064
+ return `${lines.join('\n')}\n`;
2065
+ }
2066
+
2067
+ function buildKimiAgentArtifacts({
2068
+ rootAgent = '',
2069
+ subagents = [],
2070
+ requestedSubagents = null,
2071
+ } = {}) {
2072
+ const diagnostics = [];
2073
+ const rootSource = parseKimiAgentSource(rootAgent) || { path: null, content: '' };
2074
+ const { frontmatter: rootFrontmatter } = extractFrontmatterAndBody(rootSource.content);
2075
+ const rootDescription = rootFrontmatter
2076
+ ? extractFrontmatterField(rootFrontmatter, 'description') || 'Run GSD workflows in Kimi CLI.'
2077
+ : 'Run GSD workflows in Kimi CLI.';
2078
+
2079
+ const subagentSources = Array.isArray(subagents) ? subagents : [];
2080
+ if (!Array.isArray(subagents)) {
2081
+ addKimiAgentDiagnostic(
2082
+ diagnostics,
2083
+ 'kimi_unsupported_subagents_input',
2084
+ 'Subagents input must be an array of Markdown strings or source objects.',
2085
+ typeof subagents,
2086
+ null
2087
+ );
2088
+ }
2089
+
2090
+ const subagentMap = new Map();
2091
+ for (const source of subagentSources) {
2092
+ const parsed = parseKimiAgentSource(source);
2093
+ if (!parsed) {
2094
+ addKimiAgentDiagnostic(
2095
+ diagnostics,
2096
+ 'kimi_unsupported_subagent_input',
2097
+ 'Subagent source must be a Markdown string or an object with content.',
2098
+ typeof source,
2099
+ null
2100
+ );
2101
+ continue;
2102
+ }
2103
+
2104
+ const { frontmatter } = extractFrontmatterAndBody(parsed.content);
2105
+ const fallbackName = parsed.path ? path.basename(parsed.path, path.extname(parsed.path)) : null;
2106
+ const name = frontmatter
2107
+ ? extractFrontmatterField(frontmatter, 'name') || fallbackName
2108
+ : fallbackName;
2109
+ if (!name || !KIMI_CANONICAL_GSD_AGENT_RE.test(name)) {
2110
+ addKimiAgentDiagnostic(
2111
+ diagnostics,
2112
+ 'kimi_invalid_subagent_name',
2113
+ 'Subagent source does not use a canonical gsd-* Kimi agent name.',
2114
+ name || '(missing)',
2115
+ parsed.path
2116
+ );
2117
+ continue;
2118
+ }
2119
+
2120
+ const description = frontmatter
2121
+ ? extractFrontmatterField(frontmatter, 'description') || `Run ${name}.`
2122
+ : `Run ${name}.`;
2123
+ const tools = mapKimiAgentContractTools(parseFrontmatterTools(frontmatter), diagnostics, name);
2124
+ subagentMap.set(name, {
2125
+ name,
2126
+ description,
2127
+ tools,
2128
+ prompt: neutralizeKimiAgentPrompt(parsed.content),
2129
+ });
2130
+ }
2131
+
2132
+ const requested = Array.isArray(requestedSubagents) && requestedSubagents.length > 0
2133
+ ? requestedSubagents
2134
+ : [...subagentMap.keys()];
2135
+ const selectedSubagents = [];
2136
+ for (const requestedName of requested) {
2137
+ if (subagentMap.has(requestedName)) {
2138
+ selectedSubagents.push(subagentMap.get(requestedName));
2139
+ continue;
2140
+ }
2141
+ addKimiAgentDiagnostic(
2142
+ diagnostics,
2143
+ 'kimi_unknown_subagent',
2144
+ 'Requested subagent was not generated and will not be emitted in Kimi YAML.',
2145
+ requestedName,
2146
+ null
2147
+ );
2148
+ }
2149
+
2150
+ const rootTools = mapKimiAgentContractTools(parseFrontmatterTools(rootFrontmatter), diagnostics, 'gsd');
2151
+ if (selectedSubagents.length > 0 && !rootTools.includes('kimi_cli.tools.agent:Agent')) {
2152
+ rootTools.push('kimi_cli.tools.agent:Agent');
2153
+ }
2154
+
2155
+ return {
2156
+ root: {
2157
+ name: 'gsd',
2158
+ yamlPath: 'agents/gsd.yaml',
2159
+ promptPath: 'agents/gsd.md',
2160
+ yaml: buildKimiRootAgentYaml({
2161
+ description: rootDescription,
2162
+ tools: rootTools,
2163
+ subagents: selectedSubagents,
2164
+ }),
2165
+ prompt: neutralizeKimiAgentPrompt(rootSource.content),
2166
+ },
2167
+ subagents: selectedSubagents.map((subagent) => ({
2168
+ name: subagent.name,
2169
+ yamlPath: `agents/subagents/${subagent.name}.yaml`,
2170
+ promptPath: `agents/subagents/${subagent.name}.md`,
2171
+ yaml: buildKimiSubagentYaml(subagent),
2172
+ prompt: subagent.prompt,
2173
+ })),
2174
+ diagnostics,
2175
+ };
2176
+ }
2177
+
2228
2178
  /**
2229
2179
  * Convert a Claude agent (.md) to a Copilot agent (.agent.md).
2230
2180
  * Applies tool mapping + deduplication, formats tools as JSON array.
@@ -2261,8 +2211,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
2261
2211
  /**
2262
2212
  * Apply Antigravity-specific content conversion — path replacement + command name conversion.
2263
2213
  * Path mappings depend on install mode:
2264
- * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agent/
2265
- * Local: ~/.claude/ → .agent/, ./.claude/ → ./.agent/
2214
+ * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
2215
+ * Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
2266
2216
  * Applied to ALL Antigravity content (skills, agents, engine files).
2267
2217
  * @param {string} content - Source content to convert
2268
2218
  * @param {boolean} [isGlobal=false] - Whether this is a global install
@@ -2276,14 +2226,14 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
2276
2226
  c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity');
2277
2227
  c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity');
2278
2228
  } else {
2279
- c = c.replace(/\$HOME\/\.claude\//g, '.agent/');
2280
- c = c.replace(/~\/\.claude\//g, '.agent/');
2229
+ c = c.replace(/\$HOME\/\.claude\//g, '.agents/');
2230
+ c = c.replace(/~\/\.claude\//g, '.agents/');
2281
2231
  // Bare form (no trailing slash) — must come after slash form to avoid double-replace
2282
- c = c.replace(/\$HOME\/\.claude\b/g, '.agent');
2283
- c = c.replace(/~\/\.claude\b/g, '.agent');
2232
+ c = c.replace(/\$HOME\/\.claude\b/g, '.agents');
2233
+ c = c.replace(/~\/\.claude\b/g, '.agents');
2284
2234
  }
2285
- c = c.replace(/\.\/\.claude\//g, './.agent/');
2286
- c = c.replace(/\.claude\//g, '.agent/');
2235
+ c = c.replace(/\.\/\.claude\//g, './.agents/');
2236
+ c = c.replace(/\.claude\//g, '.agents/');
2287
2237
  // Command name conversion (all gsd: references → gsd-)
2288
2238
  c = c.replace(/gsd:/g, 'gsd-');
2289
2239
  // Runtime-neutral agent name replacement (#766)
@@ -2291,12 +2241,13 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
2291
2241
  return c;
2292
2242
  }
2293
2243
 
2244
+ // isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
2294
2245
  /**
2295
2246
  * Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
2296
2247
  * Transforms frontmatter to minimal name + description only.
2297
2248
  * Body passes through with path/command conversions applied.
2298
2249
  */
2299
- function convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal = false) {
2250
+ function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
2300
2251
  const converted = convertClaudeToAntigravityContent(content, isGlobal);
2301
2252
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
2302
2253
  if (!frontmatter) return converted;
@@ -2555,12 +2506,22 @@ function convertClaudeToWindsurfMarkdown(content) {
2555
2506
  // Replace subagent_type from Claude to Windsurf format
2556
2507
  converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
2557
2508
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
2558
- // Replace project-level Claude conventions with Windsurf equivalents
2559
- converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
2560
- converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
2561
- converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
2562
- converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
2563
- converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
2509
+ // Replace project-level Claude conventions with Windsurf/Devin equivalents
2510
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
2511
+ // Legacy .windsurf/ is still recognized on read but new installs use .devin/.
2512
+ converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
2513
+ converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
2514
+ converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
2515
+ converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
2516
+ converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
2517
+ converted = converted.replace(/\.\/\.claude\//g, './.devin/');
2518
+ converted = converted.replace(/\.claude\//g, '.devin/');
2519
+ // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
2520
+ // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
2521
+ converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
2522
+ converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
2523
+ // Environment variable name rewrite
2524
+ converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
2564
2525
  // Remove Claude Code-specific bug workarounds before brand replacement
2565
2526
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2566
2527
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
@@ -2782,6 +2743,12 @@ function convertClaudeToTraeMarkdown(content) {
2782
2743
  converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/');
2783
2744
  converted = converted.replace(/\.\/\.claude\//g, './.trae/');
2784
2745
  converted = converted.replace(/\.claude\//g, '.trae/');
2746
+ // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
2747
+ // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
2748
+ converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.trae');
2749
+ converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.trae');
2750
+ // Environment variable name rewrite
2751
+ converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR');
2785
2752
  converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
2786
2753
  converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
2787
2754
  converted = converted.replace(/\bClaude Code\b/g, 'Trae');
@@ -3009,6 +2976,16 @@ function convertSlashCommandsToCodexSkillMentions(content) {
3009
2976
  return converted;
3010
2977
  }
3011
2978
 
2979
+ const CODEX_GSD_TOOLS_INVOCATION = 'node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs"';
2980
+
2981
+ function rewriteBareGsdToolsCommandsForCodex(content) {
2982
+ return content
2983
+ .replace(/(^[ \t]*)gsd-tools(?=\s)/gm, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
2984
+ .replace(/(\$\(\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
2985
+ .replace(/(`\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`)
2986
+ .replace(/((?:&&|\|\||[;|])\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`);
2987
+ }
2988
+
3012
2989
  function convertClaudeToCodexMarkdown(content) {
3013
2990
  let converted = convertSlashCommandsToCodexSkillMentions(content);
3014
2991
  converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
@@ -3034,6 +3011,10 @@ function convertClaudeToCodexMarkdown(content) {
3034
3011
  // `.claudeignore` → `.codexignore` (#2639). Codex honors its own ignore
3035
3012
  // file; leaving the Claude-specific name is misleading in agent prompts.
3036
3013
  converted = converted.replace(/\.claudeignore\b/g, '.codexignore');
3014
+ // Codex installs the tools shim under ~/.codex but does not guarantee a
3015
+ // bare `gsd-tools` binary on PATH. Keep resolver probes such as
3016
+ // `command -v gsd-tools` intact; rewrite only command invocations.
3017
+ converted = rewriteBareGsdToolsCommandsForCodex(converted);
3037
3018
  // Runtime-neutral agent name replacement (#766)
3038
3019
  converted = neutralizeAgentReferences(converted, 'AGENTS.md');
3039
3020
  return converted;
@@ -3073,7 +3054,13 @@ Execute mode fallback:
3073
3054
  ## C. Task() → spawn_agent Mapping
3074
3055
  GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools:
3075
3056
 
3076
- Direct mapping:
3057
+ **Schema detection (required first step):** Codex exposes two \`spawn_agent\` schemas:
3058
+ - **agent_type-capable schema** (e.g. \`multi_agent_v2\`): \`spawn_agent\` accepts \`agent_type\`, \`message\`, \`reasoning_effort\`, \`fork_context\`, etc. — typed GSD agent dispatch is available.
3059
+ - **Generic schema** (\`multi_agent_v1\`): \`spawn_agent\` accepts only \`message\`, \`items\`, \`fork_context\` — there is **no \`agent_type\` field**. Typed GSD agent dispatch is unavailable in this session.
3060
+
3061
+ Before spawning, inspect the \`spawn_agent\` tool's visible parameter schema (via \`tool_search\` or the tool list) to determine which form is active.
3062
+
3063
+ Typed mapping (agent_type-capable schema only):
3077
3064
  - \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
3078
3065
  - \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\`
3079
3066
  - \`Task(model="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter;
@@ -3090,6 +3077,24 @@ Direct mapping:
3090
3077
  Workflows that require this isolation must fail closed or use an explicit
3091
3078
  manual worktree protocol before spawning (#3360).
3092
3079
 
3080
+ Generic-agent workaround (multi_agent_v1 schema — NO agent_type field):
3081
+ When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch
3082
+ (\`gsd-planner\`, \`gsd-executor\`, etc.) is NOT possible. This is a known Codex limitation
3083
+ (openai/codex#15250). **This workaround is NOT equivalent to typed gsd-planner/gsd-executor
3084
+ execution** — GSD agents carry project-aware prompts, audit logging, and workflow context
3085
+ that a generic subagent lacks. Use the following fallback:
3086
+ 1. Resolve your active Codex config root — the directory that contains your \`config.toml\`.
3087
+ This directory is determined in priority order: \`$CODEX_HOME\` (if set), the path given
3088
+ by \`--config-dir\` (if passed on invocation), a local \`.codex\` directory in the current
3089
+ project (if \`--local\` was used), or the default global config directory. Read
3090
+ \`agents/<agent-name>.toml\` relative to that config root to extract the agent's system
3091
+ instructions.
3092
+ 2. Inject those instructions as a role-preamble into a generic \`spawn_agent(message=...)\` call.
3093
+ 3. Label results and logs clearly as "generic-agent workaround" so the orchestrator and user
3094
+ know full typed-agent guarantees are not in effect.
3095
+ 4. Where typed dispatch is mandatory for correctness (e.g. worktree isolation), fail closed
3096
+ and report the schema limitation rather than silently degrading.
3097
+
3093
3098
  Spawn restriction:
3094
3099
  - Codex restricts \`spawn_agent\` to cases where the user has explicitly
3095
3100
  requested sub-agents. When automatic spawning is not permitted, do the
@@ -3182,14 +3187,17 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3182
3187
  // Task() model parameters). See #2256.
3183
3188
  // Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
3184
3189
  const modelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
3190
+ let hasPinnedModel = false;
3185
3191
  if (modelOverride) {
3186
3192
  lines.push(`model = ${JSON.stringify(modelOverride)}`);
3193
+ hasPinnedModel = true;
3187
3194
  } else if (runtimeResolver) {
3188
3195
  // #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
3189
3196
  // from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
3190
3197
  const entry = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
3191
3198
  if (entry?.model) {
3192
3199
  lines.push(`model = ${JSON.stringify(entry.model)}`);
3200
+ hasPinnedModel = true;
3193
3201
  // model is resolved here; reasoning_effort from catalog tier is REPLACED by the
3194
3202
  // unified effort resolver below (#443). Do NOT emit entry.reasoning_effort here.
3195
3203
  }
@@ -3200,9 +3208,15 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null,
3200
3208
  // from the same effort.agent_overrides / effort.routing_tier_defaults / effort.default
3201
3209
  // config source. Codex does not support 'max' → clamped to 'xhigh' by
3202
3210
  // gsdRenderEffortForRuntime('codex', ...).
3203
- const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
3204
- const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
3205
- lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
3211
+ // #838 — Do not pin effort when Codex is intentionally inheriting the parent
3212
+ // chat model. A TOML with no `model` but a static `model_reasoning_effort`
3213
+ // creates confusing partial routing: model follows the Codex UI while effort
3214
+ // follows GSD. Keep those knobs coupled unless GSD also pins the model.
3215
+ if (hasPinnedModel) {
3216
+ const _universalEffortCodex = resolveInstallTimeEffort(effortCfg, resolvedName !== agentName ? resolvedName : agentName);
3217
+ const _renderedEffortCodex = _getGsdEffortCatalog().renderEffortForRuntime('codex', _universalEffortCodex).value;
3218
+ lines.push(`model_reasoning_effort = ${JSON.stringify(_renderedEffortCodex)}`);
3219
+ }
3206
3220
 
3207
3221
  // #774 — Emit service_tier and model_verbosity for light-tier agents.
3208
3222
  // Light-tier agents (routingTier: "light" in model-catalog.json) are haiku-equivalent
@@ -5196,36 +5210,14 @@ function rewriteTomlKeyLines(content, matches, key) {
5196
5210
  return rewritten;
5197
5211
  }
5198
5212
 
5199
- /**
5200
- * Atomic write — write to <target>.tmp-<pid>-<n> first, then renameSync over
5201
- * the target. Eliminates the partial-write corruption window: an interrupted
5202
- * write leaves the temp file (which we clean up) but never truncates the
5203
- * original target. Used for any mutation of Codex config.toml so we cannot
5204
- * leave the user with a half-written file (#2760 fix 4).
5205
- *
5206
- * Every temp path written is recorded in __atomicWrittenTmps so that
5207
- * _cleanTmpFiles() can scope cleanup to files this installer process actually
5208
- * created, avoiding accidental deletion of unrelated tools' temp files.
5209
- */
5210
- let __atomicWriteCounter = 0;
5211
- // Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
5212
- const __atomicWrittenTmps = new Set();
5213
- function atomicWriteFileSync(target, data, options) {
5214
- __atomicWriteCounter += 1;
5215
- const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
5216
- __atomicWrittenTmps.add(tmp);
5217
- try {
5218
- fs.writeFileSync(tmp, data, options);
5219
- fs.renameSync(tmp, target);
5220
- // Successful rename: the tmp path no longer exists, but leave it in the
5221
- // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
5222
- // lingers (e.g. a rename succeeded but left a stale entry on some FS).
5223
- } catch (e) {
5224
- // Best-effort cleanup of the partial temp file; never mask the real error.
5225
- try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
5226
- throw e;
5227
- }
5228
- }
5213
+ // atomicWriteFileSync and __atomicWrittenTmps are now owned by the
5214
+ // runtime-hooks-surface module and imported here so both install.js's
5215
+ // direct config.toml writes and the module's Cursor/Codex hooks.json
5216
+ // writes share the SAME tracking Set. _cleanTmpFiles() below reads
5217
+ // hooksSurface.__atomicWrittenTmps to scope cleanup to installer-owned
5218
+ // temps only.
5219
+ const atomicWriteFileSync = hooksSurface.atomicWriteFileSync;
5220
+ const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
5229
5221
 
5230
5222
  /**
5231
5223
  * Merge GSD config block into an existing or new config.toml.
@@ -5273,7 +5265,7 @@ function mergeCodexConfig(configPath, gsdBlock) {
5273
5265
 
5274
5266
  /**
5275
5267
  * Repair config.toml files corrupted by pre-#1346 GSD installs.
5276
- * Non-boolean keys (e.g. model = "gpt-5.3-codex") that ended up under [features]
5268
+ * Non-boolean keys (e.g. model = "gpt-5.4") that ended up under [features]
5277
5269
  * are relocated before the [features] header so Codex can parse them correctly.
5278
5270
  * Returns the content unchanged if no trapped keys are found.
5279
5271
  */
@@ -5563,23 +5555,12 @@ const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
5563
5555
  * engine layout, not the (separate) #782 Cline skills directory.
5564
5556
  */
5565
5557
  function buildClineRulesBody() {
5566
- return [
5567
- '# GSD Core — Git. Ship. Done.',
5568
- '',
5569
- '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
5570
- ' the user runs a `/gsd-*` command.',
5571
- '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
5572
- '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
5573
- '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
5574
- '- Do not apply GSD workflows unless the user explicitly asks for them.',
5575
- '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
5576
- ' step to the user using Cline\'s ask_user tool after completing it.',
5577
- ].join('\n') + '\n';
5558
+ return hooksSurface.buildClineRulesBody();
5578
5559
  }
5579
5560
 
5580
5561
  /** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
5581
5562
  function buildClineAgentsMdBody() {
5582
- return buildClineRulesBody();
5563
+ return hooksSurface.buildClineAgentsMdBody();
5583
5564
  }
5584
5565
 
5585
5566
  /**
@@ -5588,92 +5569,22 @@ function buildClineAgentsMdBody() {
5588
5569
  * Cline invokes hooks as executable scripts named exactly after the event with
5589
5570
  * no extension, passing the operation context as JSON on stdin and reading a
5590
5571
  * JSON decision from stdout ({ cancel, errorMessage, contextModification }).
5591
- *
5592
- * This hook is a self-standing planning-artifact guard: it cancels write-class
5593
- * tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
5594
- * allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
5595
- * hook bug can never wedge the user. No dependency on the #782 skills work.
5596
- */
5597
- function buildClinePreToolUseHook() {
5598
- return `#!/usr/bin/env node
5599
- 'use strict';
5600
- /* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
5601
- * Protocol: JSON on stdin -> JSON decision on stdout.
5602
- * Honored fields: { cancel, errorMessage, contextModification }.
5603
- * Fails open: any error allows the operation. */
5604
- let raw = '';
5605
- process.stdin.setEncoding('utf8');
5606
- process.stdin.on('data', (c) => { raw += c; });
5607
- process.stdin.on('end', () => {
5608
- const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
5609
- let input;
5610
- try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
5611
- try {
5612
- const tool = String(
5613
- input.toolName || input.tool_name || input.tool ||
5614
- (input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
5615
- ).toLowerCase();
5616
- const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
5617
- // Collect only PATH-bearing field values (not free-form content), so a doc
5618
- // that merely mentions ".planning/" in its body is never falsely blocked.
5619
- const paths = [];
5620
- const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
5621
- const walk = (v, depth) => {
5622
- if (depth > 5 || paths.length > 64) return;
5623
- if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
5624
- if (v && typeof v === 'object') {
5625
- for (const k of Object.keys(v)) {
5626
- const val = v[k];
5627
- if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
5628
- else walk(val, depth + 1);
5629
- }
5630
- }
5631
- };
5632
- walk(input, 0);
5633
- const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
5634
- if (isWrite && paths.some(isPlanningPath)) {
5635
- return process.stdout.write(JSON.stringify({
5636
- cancel: true,
5637
- errorMessage:
5638
- 'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
5639
- }));
5640
- }
5641
- } catch { /* fall through to allow */ }
5642
- return allow();
5643
- });
5644
- `;
5645
- }
5646
-
5647
- /**
5648
- * Merge the GSD AGENTS.md block into an existing file (or create it), preserving
5649
- * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
5650
- */
5651
- function mergeGsdAgentsMd(filePath, gsdContent) {
5652
- const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
5653
-
5654
- if (!fs.existsSync(filePath)) {
5655
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
5656
- fs.writeFileSync(filePath, gsdBlock + '\n');
5657
- return;
5658
- }
5659
-
5660
- const existing = fs.readFileSync(filePath, 'utf8');
5661
- const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
5662
- const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
5663
-
5664
- if (openIndex !== -1 && closeIndex !== -1) {
5665
- const before = existing.substring(0, openIndex).trimEnd();
5666
- const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
5667
- let newContent = '';
5668
- if (before) newContent += before + '\n\n';
5669
- newContent += gsdBlock;
5670
- if (after) newContent += '\n\n' + after;
5671
- newContent += '\n';
5672
- fs.writeFileSync(filePath, newContent);
5673
- return;
5674
- }
5572
+ *
5573
+ * This hook is a self-standing planning-artifact guard: it cancels write-class
5574
+ * tool calls that target `.planning/` (GSD-owned artifacts), and otherwise
5575
+ * allows the operation. It FAILS OPEN — any parse/IO error allows the call so a
5576
+ * hook bug can never wedge the user. No dependency on the #782 skills work.
5577
+ */
5578
+ function buildClinePreToolUseHook() {
5579
+ return hooksSurface.buildClinePreToolUseHook();
5580
+ }
5675
5581
 
5676
- fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
5582
+ /**
5583
+ * Merge the GSD AGENTS.md block into an existing file (or create it), preserving
5584
+ * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
5585
+ */
5586
+ function mergeGsdAgentsMd(filePath, gsdContent) {
5587
+ return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
5677
5588
  }
5678
5589
 
5679
5590
  /**
@@ -5703,53 +5614,7 @@ function stripGsdFromAgentsMd(content) {
5703
5614
  * caller can hash-track them).
5704
5615
  */
5705
5616
  function writeClineArtifacts(targetDir, isGlobalInstall) {
5706
- const written = [];
5707
- const clinerulesDir = path.join(targetDir, '.clinerules');
5708
-
5709
- // Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a
5710
- // file and a directory, so the legacy file must be removed first. The legacy
5711
- // file is GSD-authored (the installer wrote its full contents with no user
5712
- // merge surface), so replacing it with the newer directory form is the
5713
- // intended upgrade. Use lstat so a symlink is unlinked in place rather than
5714
- // followed (which would write GSD files through the link into an external dir).
5715
- try {
5716
- if (fs.existsSync(clinerulesDir)) {
5717
- const st = fs.lstatSync(clinerulesDir);
5718
- if (st.isFile() || st.isSymbolicLink()) {
5719
- fs.unlinkSync(clinerulesDir);
5720
- console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
5721
- }
5722
- }
5723
- } catch { /* best-effort migration */ }
5724
-
5725
- fs.mkdirSync(clinerulesDir, { recursive: true });
5726
- fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
5727
- written.push('.clinerules/gsd.md');
5728
- console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
5729
-
5730
- const hooksDir = path.join(clinerulesDir, 'hooks');
5731
- fs.mkdirSync(hooksDir, { recursive: true });
5732
- const hookPath = path.join(hooksDir, 'PreToolUse');
5733
- fs.writeFileSync(hookPath, buildClinePreToolUseHook());
5734
- try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
5735
- written.push('.clinerules/hooks/PreToolUse');
5736
- console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
5737
-
5738
- // Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md
5739
- // (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber
5740
- // a user's or another tool's AGENTS.md. Tracked via markers (like copilot),
5741
- // not the per-configDir manifest, since it lives outside configDir.
5742
- if (isGlobalInstall) {
5743
- try {
5744
- const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
5745
- mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
5746
- console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
5747
- } catch (err) {
5748
- console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
5749
- }
5750
- }
5751
-
5752
- return written;
5617
+ return hooksSurface.writeClineArtifacts(targetDir, isGlobalInstall);
5753
5618
  }
5754
5619
 
5755
5620
  // ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
@@ -5779,11 +5644,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
5779
5644
  * @returns {object} Cursor hook entry object
5780
5645
  */
5781
5646
  function buildCursorHookEntry(scriptPath) {
5782
- return {
5783
- type: 'command',
5784
- command: scriptPath.replace(/\\/g, '/'),
5785
- [GSD_CURSOR_HOOK_MARKER]: true,
5786
- };
5647
+ return hooksSurface.buildCursorHookEntry(scriptPath);
5787
5648
  }
5788
5649
 
5789
5650
  /**
@@ -5794,7 +5655,7 @@ function buildCursorHookEntry(scriptPath) {
5794
5655
  * @returns {boolean}
5795
5656
  */
5796
5657
  function isManagedCursorHookEntry(entry) {
5797
- return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]);
5658
+ return hooksSurface.isManagedCursorHookEntry(entry);
5798
5659
  }
5799
5660
 
5800
5661
  /**
@@ -5815,74 +5676,7 @@ function isManagedCursorHookEntry(entry) {
5815
5676
  * @returns {{ changed: boolean, wrote: boolean, path: string }}
5816
5677
  */
5817
5678
  function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
5818
- let parsed = {};
5819
- let currentContent = null;
5820
-
5821
- if (fs.existsSync(hooksJsonPath)) {
5822
- const raw = fs.readFileSync(hooksJsonPath, 'utf8');
5823
- currentContent = raw;
5824
- if (raw.trim()) {
5825
- try {
5826
- parsed = JSON.parse(raw);
5827
- } catch (err) {
5828
- throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
5829
- }
5830
- }
5831
- }
5832
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
5833
-
5834
- // Cursor's canonical hooks.json schema is { "version": 1, "hooks": { ... } }.
5835
- // GSD always writes (and migrates to) the nested shape so Cursor reads it correctly.
5836
- // The flat shape { "sessionStart": [...] } is accepted on read for backwards compat
5837
- // with manually-written files, but the output always uses the nested form.
5838
- const hasNestedHooksObject =
5839
- parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
5840
- if (!hasNestedHooksObject) {
5841
- // Migrate flat shape (or empty {}) to nested: lift event keys into hooks:{}.
5842
- const eventKeys = ['sessionStart', 'postToolUse'];
5843
- const lifted = {};
5844
- for (const k of eventKeys) {
5845
- if (Array.isArray(parsed[k])) {
5846
- lifted[k] = parsed[k];
5847
- delete parsed[k];
5848
- }
5849
- }
5850
- parsed.hooks = lifted;
5851
- }
5852
- if (!parsed.version) parsed.version = 1;
5853
- const hookTable = parsed.hooks;
5854
-
5855
- // Events GSD manages.
5856
- const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
5857
- const entries = managedEntries || {};
5858
-
5859
- for (const event of MANAGED_EVENTS) {
5860
- const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
5861
- // Strip all prior GSD-managed entries for this event.
5862
- const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
5863
- const newEntry = entries[event] || null;
5864
- if (newEntry) {
5865
- hookTable[event] = [...userOwned, newEntry];
5866
- } else {
5867
- // Remove-only: keep user entries, or delete the key if it would be empty.
5868
- if (userOwned.length > 0) {
5869
- hookTable[event] = userOwned;
5870
- } else {
5871
- delete hookTable[event];
5872
- }
5873
- }
5874
- }
5875
-
5876
- // hookTable is parsed.hooks (always nested now); no reassignment needed.
5877
- // Write only if content changed or if we're creating the file for the first time.
5878
- const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
5879
- const changed = currentContent !== nextContent;
5880
- const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
5881
- if (shouldWrite) {
5882
- atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
5883
- }
5884
-
5885
- return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
5679
+ return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
5886
5680
  }
5887
5681
 
5888
5682
  /**
@@ -5898,65 +5692,7 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
5898
5692
  * @returns {{ hooksJsonPath: string, changed: boolean }}
5899
5693
  */
5900
5694
  function writeCursorHooksJson(targetDir, src, opts) {
5901
- opts = opts || {};
5902
- const hooksDir = path.join(targetDir, 'hooks');
5903
- fs.mkdirSync(hooksDir, { recursive: true });
5904
-
5905
- // Copy the two GSD-managed hook scripts from the GSD source hooks/ directory.
5906
- // Apply the same /gsd:/gi → gsd- rewrite used by copyWithPathReplacement for Cursor
5907
- // JS files, so the installed hook scripts contain no /gsd: colon refs (bug-376 2b).
5908
- // Track which scripts were successfully installed so we never register a hook entry
5909
- // that references a script that wasn't copied (dangling command guard).
5910
- const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
5911
- const srcHooksDir = path.join(src, 'hooks');
5912
- const installedScripts = new Set();
5913
- for (const script of hookScripts) {
5914
- const srcPath = path.join(srcHooksDir, script);
5915
- const destPath = path.join(hooksDir, script);
5916
- if (fs.existsSync(srcPath)) {
5917
- let content = fs.readFileSync(srcPath, 'utf8');
5918
- // Rewrite /gsd:<cmd> → gsd-<cmd> so installed hook scripts are consistent
5919
- // with the Cursor convention (no colon-form slash commands in agent context).
5920
- content = content.replace(/gsd:/gi, 'gsd-');
5921
- fs.writeFileSync(destPath, content);
5922
- try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
5923
- installedScripts.add(script);
5924
- }
5925
- }
5926
-
5927
- // Build command strings using the same buildHookCommand helper used by other runtimes.
5928
- // buildHookCommand resolves the node runner + emits "<runner>" "<targetDir>/hooks/<name>".
5929
- const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
5930
- // buildHookCommand('gsd-cursor-session-start.js', ...): sessionStart → context injection
5931
- // Only register the hook entry if the script was actually installed (dangling guard).
5932
- const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
5933
- ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
5934
- : null;
5935
- // buildHookCommand('gsd-cursor-post-tool.js', ...): postToolUse → STATE.md update monitor
5936
- const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
5937
- ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
5938
- : null;
5939
-
5940
- // Build managed entries; skip events whose command couldn't be resolved (e.g. no node).
5941
- const managedEntries = {};
5942
- if (sessionStartCmd) {
5943
- managedEntries.sessionStart = {
5944
- type: 'command',
5945
- command: sessionStartCmd,
5946
- [GSD_CURSOR_HOOK_MARKER]: true,
5947
- };
5948
- }
5949
- if (postToolCmd) {
5950
- managedEntries.postToolUse = {
5951
- type: 'command',
5952
- command: postToolCmd,
5953
- [GSD_CURSOR_HOOK_MARKER]: true,
5954
- };
5955
- }
5956
-
5957
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
5958
- const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
5959
- return { hooksJsonPath, changed: result.changed };
5695
+ return hooksSurface.writeCursorHooksJson(targetDir, src, opts);
5960
5696
  }
5961
5697
 
5962
5698
  /**
@@ -5967,31 +5703,7 @@ function writeCursorHooksJson(targetDir, src, opts) {
5967
5703
  * @returns {{ changed: boolean }}
5968
5704
  */
5969
5705
  function removeCursorHooksJson(targetDir) {
5970
- const hooksJsonPath = path.join(targetDir, 'hooks.json');
5971
- if (!fs.existsSync(hooksJsonPath)) return { changed: false };
5972
- const result = reconcileCursorHooksJson(hooksJsonPath, null);
5973
- // If the resulting file has no meaningful hook content, remove it.
5974
- // A file is "empty" if it contains only the scaffolding (version, empty hooks
5975
- // object, or a bare {}) with no user-authored hook entries.
5976
- if (result.changed) {
5977
- try {
5978
- const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
5979
- const parsed = JSON.parse(contentRaw);
5980
- // reconcileCursorHooksJson always writes the nested { version, hooks:{} } shape.
5981
- // The file is "empty" when there are no remaining hook events with entries.
5982
- const hookTable = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
5983
- ? parsed.hooks
5984
- : {};
5985
- const hasAnyEvents = Object.keys(hookTable).some(
5986
- (k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0,
5987
- );
5988
- if (!hasAnyEvents) {
5989
- fs.unlinkSync(hooksJsonPath);
5990
- return { changed: true };
5991
- }
5992
- } catch { /* best-effort: leave the file */ }
5993
- }
5994
- return { changed: result.changed };
5706
+ return hooksSurface.removeCursorHooksJson(targetDir);
5995
5707
  }
5996
5708
 
5997
5709
  /**
@@ -6009,19 +5721,7 @@ function removeCursorHooksJson(targetDir) {
6009
5721
  * @returns {object} Copilot hooks-configuration object
6010
5722
  */
6011
5723
  function buildCopilotHookConfig() {
6012
- return {
6013
- version: 1,
6014
- hooks: {
6015
- sessionStart: [
6016
- {
6017
- type: 'command',
6018
- bash: GSD_COPILOT_SESSION_HOOK_BASH,
6019
- powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
6020
- timeoutSec: 10,
6021
- },
6022
- ],
6023
- },
6024
- };
5724
+ return hooksSurface.buildCopilotHookConfig();
6025
5725
  }
6026
5726
 
6027
5727
  /**
@@ -6038,11 +5738,7 @@ function buildCopilotHookConfig() {
6038
5738
  * @returns {string} The path the hook config was written to
6039
5739
  */
6040
5740
  function writeCopilotHookConfig(targetDir) {
6041
- const hooksDir = path.join(targetDir, 'hooks');
6042
- fs.mkdirSync(hooksDir, { recursive: true });
6043
- const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
6044
- fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
6045
- return hookPath;
5741
+ return hooksSurface.writeCopilotHookConfig(targetDir);
6046
5742
  }
6047
5743
 
6048
5744
  /**
@@ -7090,8 +6786,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
7090
6786
  * @param {string} stagedDir
7091
6787
  * @param {string} runtime
7092
6788
  * @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
6789
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7093
6790
  */
7094
- function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
6791
+ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
7095
6792
  if (!fs.existsSync(stagedDir)) return;
7096
6793
 
7097
6794
  // Walk all SKILL.md files under stagedDir
@@ -7100,9 +6797,9 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
7100
6797
  const fullPath = path.join(dir, entry.name);
7101
6798
  if (entry.isDirectory()) {
7102
6799
  walkAndRewrite(fullPath);
7103
- } else if (entry.name === 'SKILL.md') {
6800
+ } else if (entry.name.endsWith('.md')) {
7104
6801
  let content = fs.readFileSync(fullPath, 'utf8');
7105
- content = _applyRuntimeRewrites(content, runtime, pathPrefix);
6802
+ content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
7106
6803
  fs.writeFileSync(fullPath, content);
7107
6804
  }
7108
6805
  }
@@ -7123,9 +6820,10 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
7123
6820
  * @param {string} stagedDir directory of staged flat .md command files (may be source dir)
7124
6821
  * @param {string} runtime
7125
6822
  * @param {string} pathPrefix
6823
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7126
6824
  * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
7127
6825
  */
7128
- function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
6826
+ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
7129
6827
  if (!fs.existsSync(stagedDir)) return stagedDir;
7130
6828
  // Always copy to a temp dir — stageSkillsForProfile() returns the original source
7131
6829
  // dir on full/default profile (skills === '*'), so writing in-place would corrupt the
@@ -7135,7 +6833,7 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
7135
6833
  for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
7136
6834
  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
7137
6835
  let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
7138
- content = _applyRuntimeRewrites(content, runtime, pathPrefix);
6836
+ content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
7139
6837
  // For augment commands, apply the markdown conversion so tool references
7140
6838
  // and skill paths use Augment equivalents.
7141
6839
  if (runtime === 'augment') {
@@ -7157,9 +6855,10 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
7157
6855
  * @param {string} content
7158
6856
  * @param {string} runtime
7159
6857
  * @param {string} pathPrefix trailing-slash string
6858
+ * @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
7160
6859
  * @returns {string}
7161
6860
  */
7162
- function _applyRuntimeRewrites(content, runtime, pathPrefix) {
6861
+ function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) {
7163
6862
  const dirName = getDirName(runtime);
7164
6863
  const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
7165
6864
 
@@ -7196,13 +6895,30 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
7196
6895
  content = processAttribution(content, getCommitAttribution(runtime));
7197
6896
  break;
7198
6897
 
7199
- case 'windsurf':
6898
+ case 'windsurf': {
7200
6899
  content = content.replace(/~\/\.claude\//g, pathPrefix);
7201
6900
  content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7202
6901
  content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
6902
+ // Bare forms (no trailing slash) — use (?![\w-]) instead of \b so that
6903
+ // .claude-plugin / .claudeignore are NOT corrupted (the \b word-boundary
6904
+ // fires between 'e' and '-', which rewrites .claude-plugin → .devin-plugin).
6905
+ content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
6906
+ content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
7203
6907
  content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix);
6908
+ // Stage-1 converter rewrites .claude/skills/ → .devin/skills/ (workspace-relative
6909
+ // form). For global installs the real path is pathPrefix + skills/, so fix that up
6910
+ // here using the real isGlobal flag (threaded from installRuntimeArtifacts scope,
6911
+ // not derived from pathPrefix substring which misclassifies custom config dirs).
6912
+ // For local installs, the relative .devin/ form is correct — leave it. (#1085)
6913
+ if (isGlobal) {
6914
+ content = content.replace(/\.devin\/skills\//g, `${pathPrefix}skills/`);
6915
+ content = content.replace(/\.\/\.devin\//g, pathPrefix);
6916
+ content = content.replace(/~\/\.devin(?![\w-])/g, normalizedPathPrefix);
6917
+ content = content.replace(/\$HOME\/\.devin(?![\w-])/g, normalizedPathPrefix);
6918
+ }
7204
6919
  content = processAttribution(content, getCommitAttribution(runtime));
7205
6920
  break;
6921
+ }
7206
6922
 
7207
6923
  case 'augment':
7208
6924
  content = content.replace(/~\/\.claude\//g, pathPrefix);
@@ -7291,6 +7007,16 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
7291
7007
  content = processAttribution(content, getCommitAttribution(runtime));
7292
7008
  break;
7293
7009
 
7010
+ case 'kimi':
7011
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
7012
+ content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
7013
+ content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
7014
+ content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
7015
+ content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
7016
+ content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
7017
+ content = processAttribution(content, getCommitAttribution(runtime));
7018
+ break;
7019
+
7294
7020
  default:
7295
7021
  // Unknown runtime — no rewrites.
7296
7022
  // OpenCode/Kilo are intentionally absent: their skills are written by
@@ -7314,6 +7040,7 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
7314
7040
  * encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in
7315
7041
  * which case write as `${stem}.md` (directory IS the namespace).
7316
7042
  * - agents: write as-is (files already carry their own `gsd-` prefix).
7043
+ * For kimi-agents kind: recursively copy generated YAML/prompt files.
7317
7044
  */
7318
7045
  function _copyStaged(stagedDir, destDir, kind) {
7319
7046
  if (!fs.existsSync(stagedDir)) return;
@@ -7330,6 +7057,11 @@ function _copyStaged(stagedDir, destDir, kind) {
7330
7057
  return;
7331
7058
  }
7332
7059
 
7060
+ if (kind.kind === 'kimi-agents') {
7061
+ fs.cpSync(stagedDir, destDir, { recursive: true });
7062
+ return;
7063
+ }
7064
+
7333
7065
  // commands or agents
7334
7066
  const entries = fs.readdirSync(stagedDir, { withFileTypes: true });
7335
7067
  // For commands: apply prefix unless the destSubpath's last segment already
@@ -7361,11 +7093,27 @@ function _copyStaged(stagedDir, destDir, kind) {
7361
7093
 
7362
7094
  /**
7363
7095
  * Remove GSD-prefixed entries from destDir matching kind.prefix.
7364
- * For Hermes nested case (prefix === ''): the destSubpath IS the namespace
7365
- * (skills/gsd) — remove the entire destDir.
7096
+ * For the prefix='' case: the destSubpath IS the namespace — remove the entire
7097
+ * destDir. (No current runtime uses prefix='' after #947 reversed Hermes; kept
7098
+ * as a defensive guard for future runtimes.)
7366
7099
  */
7367
7100
  function _removeGsdEntries(destDir, kind) {
7368
7101
  if (!fs.existsSync(destDir)) return;
7102
+ if (kind.kind === 'kimi-agents') {
7103
+ for (const fileName of ['gsd.yaml', 'gsd.md']) {
7104
+ fs.rmSync(path.join(destDir, fileName), { force: true });
7105
+ }
7106
+ const subagentsDir = path.join(destDir, 'subagents');
7107
+ if (fs.existsSync(subagentsDir)) {
7108
+ for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) {
7109
+ if (!entry.isFile()) continue;
7110
+ if (!entry.name.startsWith('gsd-')) continue;
7111
+ if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue;
7112
+ fs.rmSync(path.join(subagentsDir, entry.name), { force: true });
7113
+ }
7114
+ }
7115
+ return;
7116
+ }
7369
7117
  if (kind.prefix === '') {
7370
7118
  // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd)
7371
7119
  // The directory itself is the GSD namespace, so remove it entirely.
@@ -7419,20 +7167,11 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') {
7419
7167
  }
7420
7168
  }
7421
7169
 
7422
- // Hermes: remove intermediate-layout skills/gsd/gsd-*/ entries that existed
7423
- // between #2841 and #3664. Phase 2 (#3664) uses prefix='' producing bare-stem
7424
- // names (skills/gsd/<stem>/SKILL.md); the intermediate layout had the gsd-
7425
- // prefix inside the nested dir (skills/gsd/gsd-<stem>/SKILL.md). Only
7426
- // children whose name starts with gsd- are removed — the parent skills/gsd/
7427
- // directory and any non-gsd- siblings (user content) are preserved.
7428
- const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
7429
- if (fs.existsSync(nestedGsdDir)) {
7430
- for (const entry of fs.readdirSync(nestedGsdDir, { withFileTypes: true })) {
7431
- if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
7432
- fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
7433
- }
7434
- }
7435
- }
7170
+ // Hermes: bare-stem skills/gsd/<stem>/ cleanup is deferred to AFTER the
7171
+ // layout-driven install loop in installRuntimeArtifacts, where the exact set
7172
+ // of staged gsd-<stem>/ dirs is known. Removing here (before staging) would
7173
+ // require readGsdCommandNames() which misses skills like 'dev-preferences'
7174
+ // that are not in the commands directory. See _removeHermesBareStemDirs().
7436
7175
  }
7437
7176
 
7438
7177
  // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973).
@@ -7489,6 +7228,18 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
7489
7228
  }
7490
7229
  }
7491
7230
  }
7231
+
7232
+ // Hermes: pre-#947 bare-stem skills/gsd/<stem>/ entries (dirs that do NOT
7233
+ // start with 'gsd-') — the #3664 layout used prefix='' so GSD-owned skills
7234
+ // had bare names (e.g. skills/gsd/help/). These are stale on uninstall.
7235
+ const nestedGsdDirForUninstall = path.join(configDir, 'skills', 'gsd');
7236
+ if (fs.existsSync(nestedGsdDirForUninstall)) {
7237
+ for (const entry of fs.readdirSync(nestedGsdDirForUninstall, { withFileTypes: true })) {
7238
+ if (entry.isDirectory() && !entry.name.startsWith('gsd-')) {
7239
+ fs.rmSync(path.join(nestedGsdDirForUninstall, entry.name), { recursive: true });
7240
+ }
7241
+ }
7242
+ }
7492
7243
  }
7493
7244
 
7494
7245
  // Return saved artifacts so the caller can migrate after layout-driven removal.
@@ -7539,6 +7290,43 @@ function _restoreDir(dir, snapshot) {
7539
7290
  }
7540
7291
  }
7541
7292
 
7293
+ /**
7294
+ * After the layout-driven install loop writes new gsd-<stem>/ dirs to
7295
+ * skills/gsd/, remove any pre-existing bare-stem dirs (skills/gsd/<stem>/)
7296
+ * that correspond to the newly installed gsd-<stem> entries.
7297
+ *
7298
+ * The removal set is derived from the ACTUAL installed skill dirs (every
7299
+ * entry starting with 'gsd-' that is a directory), so it covers ALL shipped
7300
+ * GSD skills — including 'dev-preferences' and future additions — without
7301
+ * relying on readGsdCommandNames() which only enumerates the commands source
7302
+ * tree and can miss skills that ship outside that directory.
7303
+ *
7304
+ * Safety: a bare dir is ONLY removed when a corresponding gsd-<stem>/ dir was
7305
+ * installed this run. A user-owned dir 'skills/gsd/my-workflow/' that has no
7306
+ * matching 'skills/gsd/gsd-my-workflow/' is never touched.
7307
+ *
7308
+ * @param {string} nestedGsdDir absolute path to skills/gsd/ category dir
7309
+ */
7310
+ function _removeHermesBareStemDirs(nestedGsdDir) {
7311
+ if (!fs.existsSync(nestedGsdDir)) return;
7312
+ const entries = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
7313
+
7314
+ // Collect the set of stems that were installed as gsd-<stem>/ this run.
7315
+ const installedStems = new Set();
7316
+ for (const entry of entries) {
7317
+ if (entry.isDirectory() && entry.name.startsWith('gsd-')) {
7318
+ installedStems.add(entry.name.slice('gsd-'.length)); // e.g. 'quick', 'dev-preferences'
7319
+ }
7320
+ }
7321
+
7322
+ // Remove any bare <stem>/ dir for which gsd-<stem>/ was just installed.
7323
+ for (const entry of entries) {
7324
+ if (entry.isDirectory() && !entry.name.startsWith('gsd-') && installedStems.has(entry.name)) {
7325
+ fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true });
7326
+ }
7327
+ }
7328
+ }
7329
+
7542
7330
  function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
7543
7331
  // Legacy cleanup before layout-driven writes
7544
7332
  _runLegacyInstallMigrations(runtime, configDir, scope);
@@ -7562,11 +7350,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
7562
7350
  // stagedForCopy: the directory to copy from (may differ from staged if rewrites
7563
7351
  // produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
7564
7352
  let stagedForCopy = staged;
7565
- if (kind.kind === 'skills') {
7566
- applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
7353
+ const isGlobal = scope === 'global';
7354
+ if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
7355
+ applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal);
7567
7356
  } else if (kind.kind === 'commands') {
7568
7357
  // Returns a temp dir with rewritten content so source files are never mutated.
7569
- stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
7358
+ stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal);
7570
7359
  }
7571
7360
  // applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under
7572
7361
  // os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856).
@@ -7579,29 +7368,15 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
7579
7368
  // then restore after. This preserves user dirs across a wipe-and-replace
7580
7369
  // install (#2973 / #3664).
7581
7370
  //
7582
- // For prefix='' (Hermes): _removeGsdEntries wipes the entire dest dir (skills/gsd/).
7583
- // Preserve every subdir that is NOT in the staged set — those are user-added dirs
7584
- // (e.g. user-content/) that GSD does not manage.
7585
- //
7586
- // For prefix='gsd-' (others): _removeGsdEntries removes only gsd-* entries.
7587
- // Non-gsd-* user dirs (e.g. my-custom-skill/) are untouched. Only preserve the
7588
- // explicit user-owned GSD-prefixed skill gsd-dev-preferences, which GSD does not
7589
- // reinstall from source but must survive the prune (#2973).
7371
+ // All runtimes (incl. Hermes after #947) use prefix='gsd-'.
7372
+ // _removeGsdEntries removes only gsd-* entries; non-gsd-* user dirs are
7373
+ // untouched. Preserve the explicit user-owned GSD-prefixed skill
7374
+ // gsd-dev-preferences, which GSD does not reinstall from source but must
7375
+ // survive the prune (#2973).
7590
7376
  const toPreserve = new Map(); // dirName -> Map<relPath, Buffer>
7591
7377
 
7592
- if (kind.prefix === '') {
7593
- // Hermes: wipes entire dest dir — preserve anything not in staged.
7594
- const stagedNames = fs.existsSync(stagedForCopy)
7595
- ? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true })
7596
- .filter(e => e.isDirectory()).map(e => e.name))
7597
- : new Set();
7598
- for (const entry of fs.readdirSync(dest, { withFileTypes: true })) {
7599
- if (!entry.isDirectory() || stagedNames.has(entry.name)) continue;
7600
- const snap = _snapshotDir(path.join(dest, entry.name));
7601
- if (snap.size > 0) toPreserve.set(entry.name, snap);
7602
- }
7603
- } else {
7604
- // Non-Hermes: only preserve explicitly user-owned GSD-prefixed skill dirs.
7378
+ {
7379
+ // Preserve explicitly user-owned GSD-prefixed skill dirs.
7605
7380
  // gsd-dev-preferences is the sole user-customisable skill in this category.
7606
7381
  const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences'];
7607
7382
  for (const dirName of USER_OWNED_SKILL_DIRS) {
@@ -7631,6 +7406,21 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
7631
7406
  }
7632
7407
  }
7633
7408
  }
7409
+
7410
+ // Hermes: after the install loop has written all gsd-<stem>/ dirs to
7411
+ // skills/gsd/, remove any stale bare-stem dirs (skills/gsd/<stem>/) that
7412
+ // correspond to the newly installed gsd-<stem> entries. This is the robust
7413
+ // replacement for the readGsdCommandNames()-based pre-install cleanup that
7414
+ // missed skills like 'dev-preferences' (#947 adversarial review).
7415
+ //
7416
+ // We run this AFTER the install loop so the installed set is authoritative:
7417
+ // every gsd-<stem>/ present now was written this run (or was there before
7418
+ // with the same prefix). User-owned bare dirs with no gsd-<stem> counterpart
7419
+ // are untouched.
7420
+ if (runtime === 'hermes') {
7421
+ const nestedGsdDirForCleanup = path.join(configDir, 'skills', 'gsd');
7422
+ _removeHermesBareStemDirs(nestedGsdDirForCleanup);
7423
+ }
7634
7424
  }
7635
7425
 
7636
7426
  /**
@@ -7736,6 +7526,23 @@ function uninstallRuntimeArtifacts(runtime, configDir, scope) {
7736
7526
  _removeGsdEntries(dest, kind);
7737
7527
  }
7738
7528
 
7529
+ // Hermes: after removing gsd-* skill dirs from skills/gsd/, also remove
7530
+ // the GSD-managed DESCRIPTION.md and then the category dir itself if it
7531
+ // contains no user content (#947). _removeGsdEntries removed gsd-* dirs
7532
+ // but left the category container and DESCRIPTION.md intact.
7533
+ if (runtime === 'hermes') {
7534
+ const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
7535
+ if (fs.existsSync(nestedGsdDir)) {
7536
+ // Remove GSD-owned DESCRIPTION.md (written by writeHermesCategoryDescription)
7537
+ fs.rmSync(path.join(nestedGsdDir, 'DESCRIPTION.md'), { force: true });
7538
+ // Remove the category dir if empty (no user content remaining)
7539
+ const remaining = fs.readdirSync(nestedGsdDir, { withFileTypes: true });
7540
+ if (remaining.length === 0) {
7541
+ fs.rmSync(nestedGsdDir, { recursive: true, force: true });
7542
+ }
7543
+ }
7544
+ }
7545
+
7739
7546
  // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the
7740
7547
  // runtime-aware SKILL.md location after all layout-driven removal is
7741
7548
  // complete. Do NOT restore to commands/gsd/ — the user is uninstalling.
@@ -7795,6 +7602,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7795
7602
  content = content.replace(globalClaudeRegex, pathPrefix);
7796
7603
  content = content.replace(globalClaudeHomeRegex, pathPrefix);
7797
7604
  content = content.replace(localClaudeRegex, `./${dirName}/`);
7605
+ content = content.replace(/~\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7606
+ content = content.replace(/\$HOME\/\.claude\b/g, pathPrefix.replace(/\/$/, ''));
7607
+ content = content.replace(/\.\/\.claude\b/g, `./${dirName}`);
7798
7608
  content = content.replace(/~\/\.qwen\//g, pathPrefix);
7799
7609
  content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
7800
7610
  content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`);
@@ -7880,11 +7690,12 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
7880
7690
  jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
7881
7691
  fs.writeFileSync(destPath, jsContent);
7882
7692
  } else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
7883
- // For Windsurf, also convert Claude references in JS/CJS utility scripts
7693
+ // For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
7694
+ // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
7884
7695
  let jsContent = fs.readFileSync(srcPath, 'utf8');
7885
7696
  jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
7886
- jsContent = jsContent.replace(/\.claude\/skills\//g, '.windsurf/skills/');
7887
- jsContent = jsContent.replace(/CLAUDE\.md/g, '.windsurf/rules');
7697
+ jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
7698
+ jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
7888
7699
  jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
7889
7700
  fs.writeFileSync(destPath, jsContent);
7890
7701
  } else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
@@ -8130,6 +7941,7 @@ function uninstall(isGlobal, runtime = 'claude') {
8130
7941
  if (runtime === 'trae') runtimeLabel = 'Trae';
8131
7942
  if (runtime === 'qwen') runtimeLabel = 'Qwen Code';
8132
7943
  if (runtime === 'hermes') runtimeLabel = 'Hermes Agent';
7944
+ if (runtime === 'kimi') runtimeLabel = 'Kimi CLI';
8133
7945
  if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy';
8134
7946
 
8135
7947
  console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`);
@@ -9109,6 +8921,7 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
9109
8921
  const isWindsurf = runtime === 'windsurf';
9110
8922
  const isTrae = runtime === 'trae';
9111
8923
  const isCline = runtime === 'cline';
8924
+ const isKimi = runtime === 'kimi';
9112
8925
  const isHermes = runtime === 'hermes';
9113
8926
  const gsdDir = path.join(configDir, 'gsd-core');
9114
8927
  const commandsDir = path.join(configDir, 'commands', 'gsd');
@@ -9156,8 +8969,8 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
9156
8969
  }
9157
8970
  }
9158
8971
  if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || (!isOpencode && !isGemini)) && fs.existsSync(codexSkillsDir)) {
9159
- // Hermes uses prefix '' (bare stem names); all others use 'gsd-'
9160
- const skillListPrefix = isHermes ? '' : 'gsd-';
8972
+ // All runtimes (including Hermes post-#947) use the canonical 'gsd-' prefix.
8973
+ const skillListPrefix = 'gsd-';
9161
8974
  for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) {
9162
8975
  const skillRoot = path.join(codexSkillsDir, skillName);
9163
8976
  const skillHashes = generateManifest(skillRoot);
@@ -9173,7 +8986,16 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
9173
8986
  }
9174
8987
  }
9175
8988
  }
9176
- if (fs.existsSync(agentsDir)) {
8989
+ if (isKimi && fs.existsSync(agentsDir)) {
8990
+ const agentHashes = generateManifest(agentsDir);
8991
+ for (const [rel, hash] of Object.entries(agentHashes)) {
8992
+ const isRootAgent = rel === 'gsd.yaml' || rel === 'gsd.md';
8993
+ const isSubagent = /^subagents\/gsd-[^/]+\.(yaml|md)$/.test(rel);
8994
+ if (isRootAgent || isSubagent) {
8995
+ manifest.files['agents/' + rel] = hash;
8996
+ }
8997
+ }
8998
+ } else if (fs.existsSync(agentsDir)) {
9177
8999
  for (const file of fs.readdirSync(agentsDir)) {
9178
9000
  if (file.startsWith('gsd-') && (file.endsWith('.md') || file.endsWith('.toml'))) {
9179
9001
  manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file));
@@ -9194,12 +9016,20 @@ function writeManifest(configDir, runtime = 'claude', options = {}) {
9194
9016
 
9195
9017
  // Track hook files so saveLocalPatches() can detect user modifications
9196
9018
  // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline)
9197
- if (!isCodex && !isCopilot && !isCline) {
9019
+ if (!isCodex && !isCopilot && !isCline && !isKimi) {
9198
9020
  const hooksDir = path.join(configDir, 'hooks');
9199
9021
  if (fs.existsSync(hooksDir)) {
9200
- for (const file of fs.readdirSync(hooksDir)) {
9201
- if (file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))) {
9202
- manifest.files['hooks/' + file] = fileHash(path.join(hooksDir, file));
9022
+ // Drive from INSTALLED_HOOK_FILES (the canonical HOOKS_TO_COPY set from
9023
+ // scripts/build-hooks.js) rather than a prefix/extension regex, so the
9024
+ // manifest set is structurally identical to the build set. The old regex
9025
+ // `file.startsWith('gsd-') && (file.endsWith('.js') || file.endsWith('.sh'))`
9026
+ // missed managed-hooks-registry.cjs (wrong prefix, .cjs extension), causing
9027
+ // detect-custom-files to flag it as a perpetual false-positive custom file
9028
+ // on every /gsd-update. See #941.
9029
+ for (const hook of INSTALLED_HOOK_FILES) {
9030
+ const hookPath = path.join(hooksDir, hook);
9031
+ if (fs.existsSync(hookPath)) {
9032
+ manifest.files['hooks/' + hook] = fileHash(hookPath);
9203
9033
  }
9204
9034
  }
9205
9035
  // Track hooks/lib/ helpers so saveLocalPatches() can back up user edits
@@ -9530,6 +9360,8 @@ function reportLocalPatches(configDir, runtime = 'claude') {
9530
9360
  ? '$gsd-update --reapply'
9531
9361
  : runtime === 'cursor'
9532
9362
  ? 'gsd-update --reapply (mention the skill name)'
9363
+ : runtime === 'kimi'
9364
+ ? '/skill:gsd-update --reapply'
9533
9365
  : '/gsd-update --reapply';
9534
9366
  console.log('');
9535
9367
  console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):');
@@ -9560,6 +9392,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9560
9392
  const isOpencode = runtime === 'opencode';
9561
9393
  const isGemini = runtime === 'gemini';
9562
9394
  const isKilo = runtime === 'kilo';
9395
+ const isKimi = runtime === 'kimi';
9563
9396
  const isCodex = runtime === 'codex';
9564
9397
  const isCopilot = runtime === 'copilot';
9565
9398
  const isAntigravity = runtime === 'antigravity';
@@ -9571,10 +9404,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9571
9404
  const isHermes = runtime === 'hermes';
9572
9405
  const isCodebuddy = runtime === 'codebuddy';
9573
9406
  const isCline = runtime === 'cline';
9574
- const configIntent = resolveRuntimeConfigIntent(runtime);
9407
+ const plan = resolveInstallPlan(runtime);
9575
9408
  const dirName = getDirName(runtime);
9576
9409
  const src = path.join(__dirname, '..');
9577
9410
 
9411
+ if (isKimi && !isGlobal) {
9412
+ console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`);
9413
+ console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`);
9414
+ console.log(` Project-level Kimi install semantics remain deferred.`);
9415
+ return {
9416
+ runtime,
9417
+ skipped: true,
9418
+ reason: 'kimi_local_deferred',
9419
+ configDir: null,
9420
+ settingsPath: null,
9421
+ settings: null,
9422
+ statuslineCommand: null,
9423
+ updateBannerCommand: null,
9424
+ rollbackInstallerMigrations: () => {},
9425
+ };
9426
+ }
9427
+
9578
9428
  // Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh).
9579
9429
  // Defined early so it is visible to both the main and Codex code paths.
9580
9430
  // `allowlist` (when non-empty) restricts copying to the named top-level entries,
@@ -9608,6 +9458,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9608
9458
  // Get the target directory based on runtime and install type.
9609
9459
  // Cline local installs write to the project root (like Claude Code) — .clinerules
9610
9460
  // lives at the root, not inside a .cline/ subdirectory.
9461
+ // #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
9462
+ // directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
9463
+ // but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
9464
+ // auto-removed on reinstall (dual-read fallback per issue #791 spec).
9611
9465
  const targetDir = isGlobal
9612
9466
  ? getGlobalConfigDir(runtime, explicitConfigDir)
9613
9467
  : isCline
@@ -9629,7 +9483,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9629
9483
  // @-references resolve correctly (#2376 Windows, #2831 macOS/Linux).
9630
9484
  // gsd update marker re-application (ADR-0010 Deviation 2):
9631
9485
  // Resolve which profile to use for this runtime's install:
9632
- // 1. --minimal / --core-only → back-compat path (stageSkillsForMode keeps strict core allowlist)
9486
+ // 1. --minimal / --core-only → back-compat alias for the core profile
9633
9487
  // 2. Explicit --profile=<name> → use it (overrides any marker)
9634
9488
  // 3. Marker exists in targetDir → honor it (prevents silent expansion on update)
9635
9489
  // 4. Else → 'full' (back-compat for fresh non-interactive installs)
@@ -9638,8 +9492,16 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9638
9492
  // differ, the caller may use mostRestrictiveProfile() across the per-runtime
9639
9493
  // results — here we resolve each runtime independently.
9640
9494
  //
9641
- // Note: --minimal uses stageSkillsForMode (back-compat: strict allowlist, no closure).
9642
- // Named profiles (--profile=X or marker-driven) use resolveProfile() for transitive closure.
9495
+ // ADR-857 phase 4c: ALL profiles (including core/minimal) use stageSkillsForProfile
9496
+ // with the registry-aware _resolvedProfile so future tier:core capabilities are
9497
+ // staged on core installs. The 'minimal' back-compat distinction is now ONLY the
9498
+ // empty manifest (core profile has no transitive deps); the registry IS consulted.
9499
+ // MINIMAL is intentionally the same skill set as the 'core' profile
9500
+ // (MINIMAL_ALLOWLIST_SET === Set(PROFILES.core)) — it is NOT a separately curated
9501
+ // subset. Any future tier:core capability therefore DOES belong in a minimal/core
9502
+ // install. Using stageSkillsForProfile(_resolvedProfile) honors the registry while
9503
+ // keeping the effective skill set identical to the prior stageSkillsForMode path
9504
+ // until a tier:core capability is registered.
9643
9505
  const _activeProfileName = hasMinimal
9644
9506
  ? 'core' // --minimal is a back-compat alias for the core profile; marker records 'core'
9645
9507
  : resolveEffectiveProfile({
@@ -9650,19 +9512,18 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9650
9512
  const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
9651
9513
  // Load the manifest and compute resolved profile for named profiles.
9652
9514
  // For --minimal/core: use an empty manifest (core profile has no transitive
9653
- // deps) to produce a resolvedProfile with the core skill set. This allows
9654
- // installRuntimeArtifacts to use stageSkillsForProfile uniformly across all
9655
- // profile modes without a null sentinel.
9515
+ // deps) to produce a resolvedProfile with the core skill set. Registry IS
9516
+ // consulted so tier:core capability skills are included when registered.
9656
9517
  const _commandsDir = path.join(src, 'commands', 'gsd');
9657
9518
  const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
9658
9519
  const _resolvedProfile = resolveProfile({
9659
9520
  modes: [_activeProfileName],
9660
9521
  manifest: _skillsManifest,
9522
+ registry: _capabilityRegistry,
9661
9523
  });
9662
- // Unified staging function: for --minimal uses stageSkillsForMode (back-compat);
9663
- // for named profiles uses stageSkillsForProfile (new API with transitive closure).
9524
+ // Unified staging function: all profiles use stageSkillsForProfile with the
9525
+ // registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
9664
9526
  function _stageSkills(commandsGsdDir) {
9665
- if (_isCoreProfileAlias) return stageSkillsForMode(commandsGsdDir, _effectiveInstallMode);
9666
9527
  return stageSkillsForProfile(commandsGsdDir, _resolvedProfile);
9667
9528
  }
9668
9529
  function _stageAgents(agentsDir) {
@@ -9701,6 +9562,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9701
9562
  if (isTrae) runtimeLabel = 'Trae';
9702
9563
  if (isQwen) runtimeLabel = 'Qwen Code';
9703
9564
  if (isHermes) runtimeLabel = 'Hermes Agent';
9565
+ if (isKimi) runtimeLabel = 'Kimi';
9704
9566
  if (isCodebuddy) runtimeLabel = 'CodeBuddy';
9705
9567
  if (isCline) runtimeLabel = 'Cline';
9706
9568
 
@@ -9990,6 +9852,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
9990
9852
  // Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782).
9991
9853
  const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf ||
9992
9854
  isAugment || isTrae || isCodebuddy || isQwen || isHermes ||
9855
+ isKimi ||
9993
9856
  (runtime === 'claude' && isGlobal) ||
9994
9857
  (isCline && isGlobal);
9995
9858
 
@@ -10017,9 +9880,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10017
9880
  if (isHermes) {
10018
9881
  const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd');
10019
9882
  if (fs.existsSync(hermesSkillsDir)) {
10020
- // Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix)
9883
+ // Hermes layout uses prefix: 'gsd-' (#947) — skill dirs have gsd-<stem> names
10021
9884
  const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true })
10022
- .filter(e => e.isDirectory()).length;
9885
+ .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
10023
9886
  if (count > 0) {
10024
9887
  console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`);
10025
9888
  } else {
@@ -10028,6 +9891,26 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10028
9891
  } else {
10029
9892
  failures.push('skills/gsd/*');
10030
9893
  }
9894
+ } else if (isKimi) {
9895
+ const skillsDir = path.join(targetDir, 'skills');
9896
+ const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml');
9897
+ if (fs.existsSync(skillsDir)) {
9898
+ const count = fs.readdirSync(skillsDir, { withFileTypes: true })
9899
+ .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
9900
+ if (count > 0) {
9901
+ console.log(` ${green}✓${reset} Installed ${count} Kimi skills to skills/`);
9902
+ } else {
9903
+ failures.push('skills/gsd-*');
9904
+ }
9905
+ } else {
9906
+ failures.push('skills/gsd-*');
9907
+ }
9908
+ if (fs.existsSync(rootAgentPath)) {
9909
+ console.log(` ${green}✓${reset} Generated Kimi root agent: ${rootAgentPath}`);
9910
+ console.log(` Launch with: kimi --agent-file ${rootAgentPath}`);
9911
+ } else {
9912
+ failures.push('agents/gsd.yaml');
9913
+ }
10031
9914
  } else {
10032
9915
  const skillsDir = path.join(targetDir, 'skills');
10033
9916
  if (fs.existsSync(skillsDir)) {
@@ -10264,7 +10147,9 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10264
10147
  }
10265
10148
  }
10266
10149
 
10267
- if (isMinimalMode(_effectiveInstallMode)) {
10150
+ if (isKimi) {
10151
+ console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`);
10152
+ } else if (isMinimalMode(_effectiveInstallMode)) {
10268
10153
  // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections.
10269
10154
  // Without stripping them here, a full → minimal reinstall would leave the
10270
10155
  // runtime advertising the old full agent surface even though the agent
@@ -10367,6 +10252,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10367
10252
  const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
10368
10253
  const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
10369
10254
  content = injectEffortFrontmatter(content, _renderedEffort);
10255
+ const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
10256
+ if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
10370
10257
  }
10371
10258
  // #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
10372
10259
  // to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
@@ -10407,7 +10294,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10407
10294
  failures.push('VERSION');
10408
10295
  }
10409
10296
 
10410
- if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
10297
+ if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) {
10411
10298
  // Write package.json to force CommonJS mode for GSD scripts
10412
10299
  // Prevents "require is not defined" errors when project has "type": "module"
10413
10300
  // Node.js walks up looking for package.json - this stops inheritance from project
@@ -10503,7 +10390,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10503
10390
  // the hooks/lib/ helpers — otherwise the Codex comment downstream
10504
10391
  // ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice.
10505
10392
  const hooksLibSrc = path.join(src, 'hooks', 'lib');
10506
- if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) {
10393
+ if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && fs.existsSync(hooksLibSrc)) {
10507
10394
  const hooksLibDest = path.join(targetDir, 'hooks', 'lib');
10508
10395
  fs.mkdirSync(hooksLibDest, { recursive: true });
10509
10396
  copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES);
@@ -10664,7 +10551,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
10664
10551
  throw _earlyInstallErr;
10665
10552
  }
10666
10553
 
10667
- if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
10554
+ if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
10668
10555
  // Capture pre-install snapshots before ANY GSD mutation
10669
10556
  // (#2760 fix 3). On post-write schema-validation failure OR any throw
10670
10557
  // during the mutation sequence (write failure, merge throw, etc.) we
@@ -11043,7 +10930,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11043
10930
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11044
10931
  }
11045
10932
 
11046
- if (configIntent.installSurface === 'copilot-instructions') {
10933
+ if (plan.installSurface === 'copilot-instructions') {
11047
10934
  // Generate copilot-instructions.md
11048
10935
  const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
11049
10936
  const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -11073,7 +10960,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11073
10960
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11074
10961
  }
11075
10962
 
11076
- if (configIntent.installSurface === 'cursor-hooks-json') {
10963
+ if (plan.installSurface === 'cursor-hooks-json') {
11077
10964
  // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
11078
10965
  // Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
11079
10966
  const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
@@ -11088,13 +10975,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11088
10975
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11089
10976
  }
11090
10977
 
11091
- if (configIntent.installSurface === 'profile-marker-only') {
11092
- // Windsurf/Trae use skills — no config.toml, no settings.json hooks needed
10978
+ if (plan.installSurface === 'profile-marker-only') {
10979
+ // Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
11093
10980
  persistActiveProfileMarker();
11094
10981
  return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
11095
10982
  }
11096
10983
 
11097
- if (configIntent.installSurface === 'cline-rules') {
10984
+ if (plan.installSurface === 'cline-rules') {
11098
10985
  // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
11099
10986
  // at .clinerules/gsd.md and a PreToolUse lifecycle hook at
11100
10987
  // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
@@ -11107,8 +10994,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11107
10994
  }
11108
10995
 
11109
10996
  // Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs).
11110
- // Gemini and Antigravity use AfterTool instead of PostToolUse for post-tool hooks
11111
- const postToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse';
10997
+ // ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor.
10998
+ // runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse.
10999
+ // Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} — identical to the old check.
11000
+ // A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe).
11001
+ const _hookEventsDialect = plan.hookEvents;
11002
+ const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse';
11112
11003
  // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
11113
11004
  // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
11114
11005
  // settings.json. Global installs and all other runtimes continue to use settings.json.
@@ -11282,464 +11173,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
11282
11173
  }
11283
11174
  }
11284
11175
 
11285
- // Configure SessionStart hook for update checking (skip for opencode)
11286
- if (!isOpencode && !isKilo) {
11287
- if (!settings.hooks) {
11288
- settings.hooks = {};
11289
- }
11290
- if (!settings.hooks.SessionStart) {
11291
- settings.hooks.SessionStart = [];
11292
- }
11293
-
11294
- const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
11295
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-check-update'))
11296
- );
11297
-
11298
- // Guard: only register if the hook file was actually installed (#1754).
11299
- // When hooks/dist/ is missing from the npm package (as in v1.32.0), the
11300
- // copy step produces no files but the registration step ran unconditionally,
11301
- // causing "hook error" on every tool invocation.
11302
- const checkUpdateFile = path.join(targetDir, 'hooks', 'gsd-check-update.js');
11303
- if (!hasGsdUpdateHook && fs.existsSync(checkUpdateFile) && updateCheckCommand) {
11304
- settings.hooks.SessionStart.push({
11305
- hooks: [
11306
- {
11307
- type: 'command',
11308
- command: updateCheckCommand
11309
- }
11310
- ]
11311
- });
11312
- console.log(` ${green}✓${reset} Configured update check hook`);
11313
- } else if (!hasGsdUpdateHook && !fs.existsSync(checkUpdateFile)) {
11314
- console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`);
11315
- }
11316
-
11317
- // Configure post-tool hook for context window monitoring
11318
- if (!settings.hooks[postToolEvent]) {
11319
- settings.hooks[postToolEvent] = [];
11320
- }
11321
-
11322
- const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry =>
11323
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
11324
- );
11325
-
11326
- const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
11327
- if (!hasContextMonitorHook && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
11328
- settings.hooks[postToolEvent].push({
11329
- matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
11330
- hooks: [
11331
- {
11332
- type: 'command',
11333
- command: contextMonitorCommand,
11334
- timeout: 10
11335
- }
11336
- ]
11337
- });
11338
- console.log(` ${green}✓${reset} Configured context window monitor hook`);
11339
- } else if (!hasContextMonitorHook && !fs.existsSync(contextMonitorFile)) {
11340
- console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`);
11341
- } else {
11342
- // Migrate existing context monitor hooks: add matcher and timeout if missing
11343
- for (const entry of settings.hooks[postToolEvent]) {
11344
- if (entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))) {
11345
- let migrated = false;
11346
- if (!entry.matcher) {
11347
- entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
11348
- migrated = true;
11349
- }
11350
- for (const h of entry.hooks) {
11351
- if (h.command && h.command.includes('gsd-context-monitor') && !h.timeout) {
11352
- h.timeout = 10;
11353
- migrated = true;
11354
- }
11355
- }
11356
- if (migrated) {
11357
- console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`);
11358
- }
11359
- }
11360
- }
11361
- }
11362
-
11363
- // Configure PreToolUse hook for prompt injection detection
11364
- // Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks
11365
- const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse';
11366
- if (!settings.hooks[preToolEvent]) {
11367
- settings.hooks[preToolEvent] = [];
11368
- }
11369
-
11370
- const hasPromptGuardHook = settings.hooks[preToolEvent].some(entry =>
11371
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-prompt-guard'))
11372
- );
11373
-
11374
- const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
11375
- if (!hasPromptGuardHook && fs.existsSync(promptGuardFile) && promptGuardCommand) {
11376
- settings.hooks[preToolEvent].push({
11377
- matcher: 'Write|Edit',
11378
- hooks: [
11379
- {
11380
- type: 'command',
11381
- command: promptGuardCommand,
11382
- timeout: 5
11383
- }
11384
- ]
11385
- });
11386
- console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
11387
- } else if (!hasPromptGuardHook && !fs.existsSync(promptGuardFile)) {
11388
- console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`);
11389
- }
11390
-
11391
- // Configure PreToolUse hook for read-before-edit guidance (#1628)
11392
- // Prevents infinite retry loops when non-Claude models attempt to edit
11393
- // files without reading them first. Advisory-only — does not block.
11394
- const hasReadGuardHook = settings.hooks[preToolEvent].some(entry =>
11395
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-guard'))
11396
- );
11397
-
11398
- const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js');
11399
- if (!hasReadGuardHook && fs.existsSync(readGuardFile) && readGuardCommand) {
11400
- settings.hooks[preToolEvent].push({
11401
- matcher: 'Write|Edit',
11402
- hooks: [
11403
- {
11404
- type: 'command',
11405
- command: readGuardCommand,
11406
- timeout: 5
11407
- }
11408
- ]
11409
- });
11410
- console.log(` ${green}✓${reset} Configured read-before-edit guard hook`);
11411
- } else if (!hasReadGuardHook && !fs.existsSync(readGuardFile)) {
11412
- console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`);
11413
- }
11414
-
11415
- // Configure PostToolUse hook for read-time prompt injection scanning (#2201)
11416
- // Scans content returned by the Read tool for injection patterns, including
11417
- // summarisation-specific patterns that survive context compression.
11418
- const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some(entry =>
11419
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-read-injection-scanner'))
11420
- );
11421
-
11422
- const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
11423
- if (!hasReadInjectionScannerHook && fs.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) {
11424
- settings.hooks[postToolEvent].push({
11425
- matcher: 'Read',
11426
- hooks: [
11427
- {
11428
- type: 'command',
11429
- command: readInjectionScannerCommand,
11430
- timeout: 5
11431
- }
11432
- ]
11433
- });
11434
- console.log(` ${green}✓${reset} Configured read injection scanner hook`);
11435
- } else if (!hasReadInjectionScannerHook && !fs.existsSync(readInjectionScannerFile)) {
11436
- console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`);
11437
- }
11438
-
11439
- // Community hooks — registered on install but opt-in at runtime.
11440
- // Each hook checks .planning/config.json for hooks.community: true
11441
- // and exits silently (no-op) if not enabled. This lets users enable
11442
- // them per-project by adding: "hooks": { "community": true }
11443
-
11444
- // Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
11445
- // Detects file edits outside GSD workflow context and advises using
11446
- // /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks
11447
- // unsafe Bash commands that violate worktree-agent isolation.
11448
- const workflowGuardCommand = isGlobal
11449
- ? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts)
11450
- : localCmd('gsd-workflow-guard.js');
11451
- const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
11452
- const workflowGuardHookEntry = settings.hooks[preToolEvent].find(entry =>
11453
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-workflow-guard'))
11454
- );
11455
- const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
11456
-
11457
- const workflowGuardFile = path.join(targetDir, 'hooks', 'gsd-workflow-guard.js');
11458
- if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) {
11459
- workflowGuardHookEntry.matcher = workflowGuardMatcher;
11460
- console.log(` ${green}✓${reset} Updated workflow guard hook matcher`);
11461
- } else if (!hasWorkflowGuardHook && fs.existsSync(workflowGuardFile) && workflowGuardCommand) {
11462
- settings.hooks[preToolEvent].push({
11463
- matcher: workflowGuardMatcher,
11464
- hooks: [
11465
- {
11466
- type: 'command',
11467
- command: workflowGuardCommand,
11468
- timeout: 5
11469
- }
11470
- ]
11471
- });
11472
- console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
11473
- } else if (!hasWorkflowGuardHook && !fs.existsSync(workflowGuardFile)) {
11474
- console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`);
11475
- }
11476
-
11477
- // Configure PreToolUse hook for worktree absolute-path safety (#260)
11478
- // Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve
11479
- // outside the current worktree root. Prevents executor agents from
11480
- // accidentally writing to the main checkout when running in isolation="worktree".
11481
- const worktreePathGuardCommand = isGlobal
11482
- ? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
11483
- : localCmd('gsd-worktree-path-guard.js');
11484
- const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some(entry =>
11485
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-worktree-path-guard'))
11486
- );
11487
- const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
11488
- if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
11489
- settings.hooks[preToolEvent].push({
11490
- matcher: 'Write|Edit|MultiEdit',
11491
- hooks: [
11492
- {
11493
- type: 'command',
11494
- command: worktreePathGuardCommand,
11495
- timeout: 5
11496
- }
11497
- ]
11498
- });
11499
- console.log(` ${green}✓${reset} Configured worktree path guard hook`);
11500
- } else if (!hasWorktreePathGuardHook && !fs.existsSync(worktreePathGuardFile)) {
11501
- console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`);
11502
- }
11503
-
11504
- // Configure commit validation hook (Conventional Commits enforcement, opt-in)
11505
- const validateCommitCommand = isGlobal
11506
- ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
11507
- : localShellCmd('gsd-validate-commit.sh');
11508
- const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
11509
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit'))
11510
- );
11511
- // Guard: only register if the .sh file was actually installed. If the npm package
11512
- // omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
11513
- // causes a hook error on every Bash tool invocation.
11514
- const validateCommitFile = path.join(targetDir, 'hooks', 'gsd-validate-commit.sh');
11515
- if (!hasValidateCommitHook && fs.existsSync(validateCommitFile) && validateCommitCommand) {
11516
- settings.hooks[preToolEvent].push({
11517
- matcher: 'Bash',
11518
- hooks: [
11519
- {
11520
- type: 'command',
11521
- command: validateCommitCommand,
11522
- timeout: 5
11523
- }
11524
- ]
11525
- });
11526
- console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
11527
- } else if (!hasValidateCommitHook && !fs.existsSync(validateCommitFile)) {
11528
- console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`);
11529
- } else if (!hasValidateCommitHook && !validateCommitCommand) {
11530
- console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
11531
- }
11532
-
11533
- // Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
11534
- // PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
11535
- // on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
11536
- // .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
11537
- const graphifyUpdateCommand = isGlobal
11538
- ? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
11539
- : localShellCmd('gsd-graphify-update.sh');
11540
- const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry =>
11541
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-graphify-update'))
11542
- );
11543
- const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
11544
- if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
11545
- settings.hooks[postToolEvent].push({
11546
- matcher: 'Bash',
11547
- hooks: [
11548
- {
11549
- type: 'command',
11550
- command: graphifyUpdateCommand,
11551
- timeout: 5
11552
- }
11553
- ]
11554
- });
11555
- console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
11556
- } else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) {
11557
- console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
11558
- } else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
11559
- console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
11560
- }
11561
-
11562
- // Configure session state orientation hook (opt-in)
11563
- const sessionStateCommand = isGlobal
11564
- ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
11565
- : localShellCmd('gsd-session-state.sh');
11566
- const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
11567
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state'))
11568
- );
11569
- const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh');
11570
- if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) {
11571
- settings.hooks.SessionStart.push({
11572
- hooks: [
11573
- {
11574
- type: 'command',
11575
- command: sessionStateCommand
11576
- }
11577
- ]
11578
- });
11579
- console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
11580
- } else if (!hasSessionStateHook && !fs.existsSync(sessionStateFile)) {
11581
- console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`);
11582
- } else if (!hasSessionStateHook && !sessionStateCommand) {
11583
- console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`);
11584
- }
11585
-
11586
- // Configure phase boundary detection hook (opt-in)
11587
- const phaseBoundaryCommand = isGlobal
11588
- ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
11589
- : localShellCmd('gsd-phase-boundary.sh');
11590
- const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
11591
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))
11592
- );
11593
- const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
11594
- if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
11595
- settings.hooks[postToolEvent].push({
11596
- matcher: 'Write|Edit',
11597
- hooks: [
11598
- {
11599
- type: 'command',
11600
- command: phaseBoundaryCommand,
11601
- timeout: 5
11602
- }
11603
- ]
11604
- });
11605
- console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
11606
- } else if (!hasPhaseBoundaryHook && !fs.existsSync(phaseBoundaryFile)) {
11607
- console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`);
11608
- } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
11609
- console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
11610
- }
11611
-
11612
- // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
11613
- // Claude Code (since #770) and Qwen Code (since #788) both support these
11614
- // three lifecycle events. Wire gsd-context-monitor so agents get context-
11615
- // headroom warnings at subagent completion, model stop, and pre-compaction
11616
- // (the most critical moment to surface headroom info).
11617
- //
11618
- // SubagentStop — subagent lifecycle completion (context headroom tracking)
11619
- // Stop — model stop / final-response moment (context headroom)
11620
- // PreCompact — fires before conversation compaction (most critical
11621
- // moment to surface context headroom warnings)
11622
- //
11623
- // Note: UserPromptSubmit is NOT wired here. That event carries the raw
11624
- // user prompt text, not a tool invocation, so gsd-prompt-guard (which
11625
- // exits unless tool_name is Write/Edit) would be a silent no-op. A
11626
- // dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
11627
- if (isQwen || runtime === 'claude') {
11628
- const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code';
11629
- // SubagentStop, Stop, PreCompact — route through the context monitor.
11630
- for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
11631
- if (!settings.hooks[event]) {
11632
- settings.hooks[event] = [];
11633
- }
11634
- const alreadyHasContextMonitor = settings.hooks[event].some(entry =>
11635
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
11636
- );
11637
- if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
11638
- settings.hooks[event].push({
11639
- hooks: [
11640
- {
11641
- type: 'command',
11642
- command: contextMonitorCommand,
11643
- timeout: 10
11644
- }
11645
- ]
11646
- });
11647
- console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
11648
- } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
11649
- console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
11650
- }
11651
- }
11652
- }
11653
- // ── end SubagentStop / Stop / PreCompact events ────────────────────────────
11654
-
11655
- // ── Gemini-only extended hook events (#776) ───────────────────────────────
11656
- // Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
11657
- // gsd previously did not register. Three high-value events are added here:
11658
- //
11659
- // BeforeAgent — fires after user submits a prompt, before the agent
11660
- // plans. Wire gsd-context-monitor for context headroom
11661
- // awareness at prompt time.
11662
- // AfterAgent — fires once per turn after the model generates its final
11663
- // response. Wire gsd-context-monitor to track headroom
11664
- // after each agent turn completes.
11665
- // BeforeModel — fires before each LLM call (per-turn, not per-session).
11666
- // Wire gsd-context-monitor for per-turn context injection
11667
- // — more precise than session-start-only injection.
11668
- //
11669
- // All three reuse gsd-context-monitor.js — no new hook files needed.
11670
- // The `decision:"deny"` retry capability of AfterAgent is intentionally
11671
- // left to the hook script to implement when triggered (gsd-context-monitor
11672
- // exits 0 / advisory-only today; an active quality gate is a follow-on).
11673
- //
11674
- // Note: BeforeToolSelection is NOT wired. That event does not map to a
11675
- // gsd hook use case at this time; deferred to a follow-on issue.
11676
- //
11677
- // Guard: isGemini is defined at the top of install() (line ~8696).
11678
- if (isGemini) {
11679
- for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
11680
- if (!Array.isArray(settings.hooks[geminiEvent])) {
11681
- settings.hooks[geminiEvent] = [];
11682
- }
11683
- const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
11684
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor'))
11685
- );
11686
- if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
11687
- settings.hooks[geminiEvent].push({
11688
- hooks: [
11689
- {
11690
- type: 'command',
11691
- command: contextMonitorCommand,
11692
- timeout: 10
11693
- }
11694
- ]
11695
- });
11696
- console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
11697
- } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
11698
- console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
11699
- }
11700
- }
11701
- }
11702
- // ── end Gemini-only extended hook events ──────────────────────────────────
11703
-
11704
- // ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
11705
- // Claude Code fires FileChanged when a watched file changes on disk. Wire
11706
- // gsd-config-reload.js to reload the gsd config context whenever the user
11707
- // edits .planning/config.json mid-session, eliminating the need to restart.
11708
- //
11709
- // The matcher "config.json" watches for changes to any file named config.json
11710
- // (Claude Code matches by filename, not full path). The hook exits silently
11711
- // when the changed file is not the gsd config.
11712
- //
11713
- // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
11714
- // verified; extend in a follow-on if empirically confirmed.
11715
- if (runtime === 'claude') {
11716
- if (!settings.hooks.FileChanged) {
11717
- settings.hooks.FileChanged = [];
11718
- }
11719
- const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js');
11720
- const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry =>
11721
- entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-config-reload'))
11722
- );
11723
- if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) {
11724
- settings.hooks.FileChanged.push({
11725
- matcher: 'config.json',
11726
- hooks: [
11727
- {
11728
- type: 'command',
11729
- command: configReloadCommand,
11730
- timeout: 8
11731
- }
11732
- ]
11733
- });
11734
- console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
11735
- } else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) {
11736
- console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
11737
- } else if (!alreadyHasConfigReload && !configReloadCommand) {
11738
- console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
11739
- }
11740
- }
11741
- // ── end FileChanged hook ────────────────────────────────────────────────────
11742
- }
11176
+ // Register all GSD-managed hook entries into settings.hooks.* for runtimes
11177
+ // that use the settings.json hook surface (ADR-857 phase 5f-1b).
11178
+ // settings is mutated in place by applySettingsJsonHooks.
11179
+ applySettingsJsonHooks(settings, {
11180
+ runtime,
11181
+ isGlobal,
11182
+ targetDir,
11183
+ postToolEvent,
11184
+ hookEvents: _hookEventsDialect,
11185
+ extendedHookEvents: plan.extendedHookEvents,
11186
+ hooksSurface: plan.hooksSurface,
11187
+ updateCheckCommand,
11188
+ contextMonitorCommand,
11189
+ promptGuardCommand,
11190
+ readGuardCommand,
11191
+ readInjectionScannerCommand,
11192
+ configReloadCommand,
11193
+ hookOpts,
11194
+ localCmd,
11195
+ localShellCmd,
11196
+ });
11743
11197
 
11744
11198
  // ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
11745
11199
  // Detect `hooksConfig.enabled: false` in the already-loaded settings object
@@ -11854,9 +11308,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11854
11308
  const isWindsurf = runtime === 'windsurf';
11855
11309
  const isTrae = runtime === 'trae';
11856
11310
  const isCline = runtime === 'cline';
11857
- const configIntent = resolveRuntimeConfigIntent(runtime);
11311
+ const plan = resolveInstallPlan(runtime);
11858
11312
 
11859
- if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) {
11313
+ if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
11860
11314
  if (!isGlobal && !forceStatusline) {
11861
11315
  // Local installs skip statusLine by default: repo settings.json takes precedence over
11862
11316
  // profile-level settings.json in Claude Code, so writing here would silently clobber
@@ -11882,14 +11336,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11882
11336
  // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
11883
11337
  // cline either lack the surface or use a different config schema.
11884
11338
  const { shouldInstallBanner, bannerCommand } = bannerOpts;
11885
- if (shouldInstallBanner && settings && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) {
11339
+ if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
11886
11340
  if (!bannerCommand) {
11887
11341
  console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
11888
11342
  } else {
11889
11343
  if (!settings.hooks) settings.hooks = {};
11890
11344
  if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
11891
11345
  const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
11892
- entry && entry.hooks && entry.hooks.some(h => h && h.command && h.command.includes('gsd-update-banner'))
11346
+ entry && entry.hooks && entry.hooks.some(h => h && referencesHook(h, 'gsd-update-banner'))
11893
11347
  );
11894
11348
  const bannerHookFile = configDir ? path.join(configDir, 'hooks', 'gsd-update-banner.js') : null;
11895
11349
  const bannerInstalled = bannerHookFile ? fs.existsSync(bannerHookFile) : false;
@@ -11922,17 +11376,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11922
11376
  // {type: 'command', command: null} items that the runtime hook schema
11923
11377
  // rejects at parse time. validateHookFields filters those out so the file
11924
11378
  // we write is always schema-valid.
11925
- if (configIntent.writesSharedSettings) {
11379
+ if (settingsPath && settings && plan.writesSharedSettings) {
11926
11380
  writeSettings(settingsPath, validateHookFields(settings));
11927
11381
  }
11928
11382
 
11929
11383
  // Configure OpenCode permissions
11930
- if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
11384
+ if (plan.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
11931
11385
  configureOpencodePermissions(isGlobal, configDir);
11932
11386
  }
11933
11387
 
11934
11388
  // Configure Kilo permissions
11935
- if (configIntent.finishPermissionWriter === 'kilo') {
11389
+ if (plan.finishPermissionWriter === 'kilo') {
11936
11390
  configureKiloPermissions(isGlobal, configDir);
11937
11391
  }
11938
11392
 
@@ -11971,6 +11425,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11971
11425
  if (runtime === 'cline') program = 'Cline';
11972
11426
  if (runtime === 'qwen') program = 'Qwen Code';
11973
11427
  if (runtime === 'hermes') program = 'Hermes Agent';
11428
+ if (runtime === 'kimi') program = 'Kimi CLI';
11974
11429
 
11975
11430
  let command = '/gsd-new-project';
11976
11431
  if (runtime === 'opencode') command = '/gsd-new-project';
@@ -11986,6 +11441,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
11986
11441
  if (runtime === 'cline') command = '/gsd-new-project';
11987
11442
  if (runtime === 'qwen') command = '/gsd-new-project';
11988
11443
  if (runtime === 'hermes') command = '/gsd-new-project';
11444
+ if (runtime === 'kimi') command = '/skill:gsd-new-project';
11989
11445
 
11990
11446
  // Claude Code global installs use the skills/ format (CC 2.1.88+).
11991
11447
  // Restart is required for CC to pick up newly-installed skills, and the
@@ -12000,6 +11456,16 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
12000
11456
  return;
12001
11457
  }
12002
11458
 
11459
+ if (runtime === 'kimi') {
11460
+ const agentPath = configDir ? path.join(configDir, 'agents', 'gsd.yaml') : 'agents/gsd.yaml';
11461
+ console.log(`
11462
+ ${green}Done!${reset} Start ${program} with ${cyan}kimi --agent-file ${agentPath}${reset}, then run ${cyan}${command}${reset}.
11463
+
11464
+ ${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r
11465
+ `);
11466
+ return;
11467
+ }
11468
+
12003
11469
  console.log(`
12004
11470
  ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}.
12005
11471
 
@@ -12076,14 +11542,15 @@ const runtimeMap = {
12076
11542
  '8': 'cursor',
12077
11543
  '9': 'gemini',
12078
11544
  '10': 'hermes',
12079
- '11': 'kilo',
12080
- '12': 'opencode',
12081
- '13': 'qwen',
12082
- '14': 'trae',
12083
- '15': 'windsurf'
11545
+ '11': 'kimi',
11546
+ '12': 'kilo',
11547
+ '13': 'opencode',
11548
+ '14': 'qwen',
11549
+ '15': 'trae',
11550
+ '16': 'windsurf'
12084
11551
  };
12085
- const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
12086
- const ALL_RUNTIMES_OPTION = '16';
11552
+ const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
11553
+ const ALL_RUNTIMES_OPTION = '17';
12087
11554
 
12088
11555
  /**
12089
11556
  * Build the runtime-selection prompt text shown by the interactive installer.
@@ -12101,12 +11568,13 @@ function buildRuntimePromptText() {
12101
11568
  ${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset}
12102
11569
  ${cyan}9${reset}) Gemini ${dim}(~/.gemini)${reset}
12103
11570
  ${cyan}10${reset}) Hermes Agent ${dim}(~/.hermes)${reset}
12104
- ${cyan}11${reset}) Kilo ${dim}(~/.config/kilo)${reset}
12105
- ${cyan}12${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
12106
- ${cyan}13${reset}) Qwen Code ${dim}(~/.qwen)${reset}
12107
- ${cyan}14${reset}) Trae ${dim}(~/.trae)${reset}
12108
- ${cyan}15${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
12109
- ${cyan}16${reset}) All
11571
+ ${cyan}11${reset}) Kimi ${dim}(~/.config/agents, then ~/.agents if existing)${reset}
11572
+ ${cyan}12${reset}) Kilo ${dim}(~/.config/kilo)${reset}
11573
+ ${cyan}13${reset}) OpenCode ${dim}(~/.config/opencode)${reset}
11574
+ ${cyan}14${reset}) Qwen Code ${dim}(~/.qwen)${reset}
11575
+ ${cyan}15${reset}) Trae ${dim}(~/.trae)${reset}
11576
+ ${cyan}16${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset}
11577
+ ${cyan}17${reset}) All
12110
11578
 
12111
11579
  ${dim}Select multiple: 1,2,6 or 1 2 6${reset}
12112
11580
  `;
@@ -12117,7 +11585,7 @@ function buildRuntimePromptText() {
12117
11585
  * Pure function — exported so tests can verify split/dedupe/fallback behavior.
12118
11586
  * - Accepts comma- and/or whitespace-separated choices
12119
11587
  * - Deduplicates while preserving order
12120
- * - Maps option 16 ("All") to every runtime
11588
+ * - Maps option 17 ("All") to every runtime
12121
11589
  * - Falls back to ['claude'] when nothing valid is selected
12122
11590
  */
12123
11591
  function parseRuntimeInput(answer) {
@@ -12457,9 +11925,11 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
12457
11925
  '.codex',
12458
11926
  '.copilot',
12459
11927
  '.github', // copilot local form
12460
- '.agent', // antigravity local form
11928
+ '.agents', // antigravity local form (canonical, #791)
11929
+ '.agent', // antigravity local form (legacy, backward-compat)
12461
11930
  '.cursor',
12462
- '.windsurf',
11931
+ '.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
11932
+ '.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
12463
11933
  '.codeium/windsurf',
12464
11934
  '.augment',
12465
11935
  '.trae',
@@ -12569,6 +12039,8 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) {
12569
12039
  try {
12570
12040
  const printSummaries = () => {
12571
12041
  for (const result of results) {
12042
+ if (result && result.skipped) continue;
12043
+ if (!result) continue;
12572
12044
  const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline;
12573
12045
  finishInstall(
12574
12046
  result.settingsPath,
@@ -12682,6 +12154,10 @@ module.exports = {
12682
12154
  uninstall,
12683
12155
  convertSlashCommandsToCodexSkillMentions,
12684
12156
  convertClaudeCommandToCodexSkill,
12157
+ convertClaudeCommandToKimiSkill,
12158
+ convertKimiToolName,
12159
+ mapClaudeToolsToKimiTools,
12160
+ buildKimiAgentArtifacts,
12685
12161
  convertClaudeToOpencodeFrontmatter,
12686
12162
  convertClaudeToKiloFrontmatter,
12687
12163
  convertClaudeCommandToOpencodeSkill,
@@ -12695,6 +12171,7 @@ module.exports = {
12695
12171
  GSD_CODEX_MARKER,
12696
12172
  CODEX_AGENT_SANDBOX,
12697
12173
  getDirName,
12174
+ getGlobalDir,
12698
12175
  getConfigDirFromHome,
12699
12176
  resolveKiloConfigPath,
12700
12177
  configureKiloPermissions,
@@ -12761,6 +12238,7 @@ module.exports = {
12761
12238
  maybeSuggestPathExport,
12762
12239
  runtimeMap,
12763
12240
  allRuntimes,
12241
+ selectRuntimesFromArgs,
12764
12242
  GSD_UNINSTALL_HOOKS,
12765
12243
  parseRuntimeInput,
12766
12244
  buildRuntimePromptText,
@@ -12770,6 +12248,8 @@ module.exports = {
12770
12248
  buildHookCommand,
12771
12249
  normalizeNodePath,
12772
12250
  resolveNodeRunner,
12251
+ referencesHook,
12252
+ applySettingsJsonHooks,
12773
12253
  rewriteLegacyManagedNodeHookCommands,
12774
12254
  buildCodexHookBlock,
12775
12255
  rewriteLegacyCodexHookBlock,
@@ -12811,12 +12291,11 @@ if (require.main === module && !process.env.GSD_TEST_MODE) {
12811
12291
  console.error('Usage: node install.js --skills-root <runtime>');
12812
12292
  process.exit(1);
12813
12293
  }
12814
- const globalDir = getGlobalConfigDir(runtimeArg, null);
12815
- // Hermes nests GSD skills under skills/gsd/ as a single category (#2841).
12816
- // Other runtimes use a flat skills/ root.
12817
- const skillsRoot = runtimeArg === 'hermes'
12818
- ? path.join(globalDir, 'skills', 'gsd')
12819
- : path.join(globalDir, 'skills');
12294
+ const skillsRoot = getGlobalSkillsBase(runtimeArg);
12295
+ if (skillsRoot === null) {
12296
+ console.error(`${runtimeArg} does not use a skills directory`);
12297
+ process.exit(1);
12298
+ }
12820
12299
  console.log(skillsRoot);
12821
12300
  } else if (hasGlobal && hasLocal) {
12822
12301
  console.error(` ${yellow}Cannot specify both --global and --local${reset}`);