@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
@@ -0,0 +1,1439 @@
1
+ 'use strict';
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ /**
6
+ * Runtime Hooks Surface Module — hook-surface writer functions extracted from
7
+ * bin/install.js (ADR-857 phase 5f-1).
8
+ *
9
+ * Owns the lifecycle writer functions for hook surfaces managed by GSD on four
10
+ * runtimes:
11
+ * Cline: writeClineArtifacts + supporting helpers/constants
12
+ * Cursor: buildCursorHookEntry, isManagedCursorHookEntry,
13
+ * reconcileCursorHooksJson, writeCursorHooksJson, removeCursorHooksJson
14
+ * Copilot: buildCopilotHookConfig, writeCopilotHookConfig
15
+ * Codex hooks.json: ensureCodexHooksJsonSessionStart, ensureCodexHooksJsonEvent,
16
+ * reconcileCodexHooksJsonEvent, reconcileCodexHooksJsonSessionStart,
17
+ * removeCodexHooksJsonEvent, removeCodexHooksJsonSessionStart,
18
+ * buildCodexHookWindowsShimIR, buildCodexHookBlock, rewriteLegacyCodexHookBlock
19
+ * Shared: buildHookCommand, rewriteLegacyManagedNodeHookCommands
20
+ *
21
+ * BEHAVIOR-PRESERVING RELOCATION: all logic is copied verbatim from
22
+ * bin/install.js. No behavior change, no descriptor reads, no new IO.
23
+ *
24
+ * bin/install.js re-exports every symbol from this module so existing
25
+ * consumers that do require('../bin/install.js').writeCursorHooksJson
26
+ * (etc.) continue to work unchanged.
27
+ */
28
+ const node_fs_1 = __importDefault(require("node:fs"));
29
+ const node_path_1 = __importDefault(require("node:path"));
30
+ const node_os_1 = __importDefault(require("node:os"));
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const shellCmdProjection = require("./shell-command-projection.cjs");
33
+ const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, } = shellCmdProjection;
34
+ // ---------------------------------------------------------------------------
35
+ // Terminal color constants (mirrors install.js for console output parity)
36
+ // ---------------------------------------------------------------------------
37
+ const green = '\x1b[32m';
38
+ const yellow = '\x1b[33m';
39
+ const reset = '\x1b[0m';
40
+ // ---------------------------------------------------------------------------
41
+ // Codex config.toml constants (subset needed by this module)
42
+ // ---------------------------------------------------------------------------
43
+ // ---------------------------------------------------------------------------
44
+ // Copilot hook constants
45
+ // ---------------------------------------------------------------------------
46
+ const GSD_COPILOT_HOOK_FILE = 'gsd-session.json';
47
+ const GSD_COPILOT_SESSION_MSG_PRESENT = 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.';
48
+ const GSD_COPILOT_SESSION_MSG_ABSENT = 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.';
49
+ const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' +
50
+ `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` +
51
+ `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`;
52
+ const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' +
53
+ `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
54
+ `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
55
+ // ---------------------------------------------------------------------------
56
+ // Cursor hook constants
57
+ // ---------------------------------------------------------------------------
58
+ const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
59
+ const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
60
+ const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
61
+ // ---------------------------------------------------------------------------
62
+ // Cline / AGENTS.md constants
63
+ // ---------------------------------------------------------------------------
64
+ const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
65
+ const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
66
+ // ---------------------------------------------------------------------------
67
+ // atomicWriteFileSync — shared canonical implementation.
68
+ //
69
+ // __atomicWrittenTmps is exported so bin/install.js can merge it into its
70
+ // _cleanTmpFiles() scan, ensuring that atomic writes performed by this
71
+ // module (Cursor hooks.json, Codex hooks.json shims) participate in the
72
+ // same temp-file cleanup as writes performed directly by install.js.
73
+ //
74
+ // Every temp path written is recorded in the Set so _cleanTmpFiles() can
75
+ // scope cleanup to files this installer process actually created, avoiding
76
+ // accidental deletion of unrelated tools' temp files.
77
+ // ---------------------------------------------------------------------------
78
+ let __atomicWriteCounter = 0;
79
+ // Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
80
+ const __atomicWrittenTmps = new Set();
81
+ function atomicWriteFileSync(target, data, options) {
82
+ __atomicWriteCounter += 1;
83
+ const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
84
+ __atomicWrittenTmps.add(tmp);
85
+ try {
86
+ node_fs_1.default.writeFileSync(tmp, data, options);
87
+ node_fs_1.default.renameSync(tmp, target);
88
+ // Successful rename: the tmp path no longer exists, but leave it in the
89
+ // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
90
+ // lingers (e.g. a rename succeeded but left a stale entry on some FS).
91
+ }
92
+ catch (e) {
93
+ try {
94
+ node_fs_1.default.rmSync(tmp, { force: true });
95
+ }
96
+ catch { /* ignore */ }
97
+ throw e;
98
+ }
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // parseTomlValue + findMultilineBasicStringClose
102
+ // (needed by rewriteLegacyCodexHookBlock — pure TOML helpers, no state)
103
+ // ---------------------------------------------------------------------------
104
+ function findMultilineBasicStringClose(line, startIndex) {
105
+ let i = startIndex;
106
+ while (i < line.length) {
107
+ if (line.startsWith('"""', i) && (i === 0 || line[i - 1] !== '\\')) {
108
+ return i;
109
+ }
110
+ i += 1;
111
+ }
112
+ return -1;
113
+ }
114
+ function parseTomlValue(text, i) {
115
+ // Skip leading whitespace.
116
+ while (i < text.length && (text[i] === ' ' || text[i] === '\t')) {
117
+ i += 1;
118
+ }
119
+ if (i >= text.length) {
120
+ throw new Error('expected value, got end of input');
121
+ }
122
+ const ch = text[i];
123
+ // Basic string
124
+ if (ch === '"') {
125
+ if (text.startsWith('"""', i)) {
126
+ const close = findMultilineBasicStringClose(text, i + 3);
127
+ if (close === -1) {
128
+ throw new Error('unterminated multi-line basic string');
129
+ }
130
+ const raw = text.slice(i + 3, close);
131
+ return { value: raw.replace(/^\r?\n/, ''), end: close + 3 };
132
+ }
133
+ let j = i + 1;
134
+ let out = '';
135
+ while (j < text.length) {
136
+ const c = text[j];
137
+ if (c === '\\') {
138
+ const next = text[j + 1];
139
+ if (next === 'n') {
140
+ out += '\n';
141
+ j += 2;
142
+ continue;
143
+ }
144
+ if (next === 't') {
145
+ out += '\t';
146
+ j += 2;
147
+ continue;
148
+ }
149
+ if (next === 'r') {
150
+ out += '\r';
151
+ j += 2;
152
+ continue;
153
+ }
154
+ if (next === '\\') {
155
+ out += '\\';
156
+ j += 2;
157
+ continue;
158
+ }
159
+ if (next === '"') {
160
+ out += '"';
161
+ j += 2;
162
+ continue;
163
+ }
164
+ if (next === '/') {
165
+ out += '/';
166
+ j += 2;
167
+ continue;
168
+ }
169
+ out += next === undefined ? '' : next;
170
+ j += 2;
171
+ continue;
172
+ }
173
+ if (c === '"') {
174
+ return { value: out, end: j + 1 };
175
+ }
176
+ out += c;
177
+ j += 1;
178
+ }
179
+ throw new Error('unterminated basic string');
180
+ }
181
+ // Literal string
182
+ if (ch === '\'') {
183
+ if (text.startsWith("'''", i)) {
184
+ const close = text.indexOf("'''", i + 3);
185
+ if (close === -1)
186
+ throw new Error('unterminated multi-line literal string');
187
+ return { value: text.slice(i + 3, close).replace(/^\r?\n/, ''), end: close + 3 };
188
+ }
189
+ const close = text.indexOf('\'', i + 1);
190
+ if (close === -1)
191
+ throw new Error('unterminated literal string');
192
+ return { value: text.slice(i + 1, close), end: close + 1 };
193
+ }
194
+ // Boolean
195
+ if (text.startsWith('true', i))
196
+ return { value: true, end: i + 4 };
197
+ if (text.startsWith('false', i))
198
+ return { value: false, end: i + 5 };
199
+ // Number (integer or float, simplified)
200
+ const numMatch = text.slice(i).match(/^[+-]?(?:0x[0-9a-fA-F_]+|0o[0-7_]+|0b[01_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?|inf|nan)/);
201
+ if (numMatch) {
202
+ const raw = numMatch[0];
203
+ const cleaned = raw.replace(/_/g, '');
204
+ const num = Number(cleaned);
205
+ return { value: isNaN(num) ? cleaned : num, end: i + raw.length };
206
+ }
207
+ // Datetime (simplified passthrough)
208
+ const dtMatch = text.slice(i).match(/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?/);
209
+ if (dtMatch) {
210
+ return { value: dtMatch[0], end: i + dtMatch[0].length };
211
+ }
212
+ throw new Error(`parseTomlValue: unexpected character '${ch}' at position ${i}`);
213
+ }
214
+ function normalizeNodePath(execPath, opts) {
215
+ if (!execPath)
216
+ return execPath;
217
+ const env = (opts && opts.env) || process.env;
218
+ const existsSync = (opts && opts.existsSync) || node_fs_1.default.existsSync;
219
+ const normalizedForMatch = execPath.replace(/\\/g, '/');
220
+ if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
221
+ const candidates = [];
222
+ if (env.FNM_DIR) {
223
+ candidates.push(`${env.FNM_DIR}/aliases/default/node.exe`);
224
+ candidates.push(`${env.FNM_DIR}/aliases/default/bin/node`);
225
+ }
226
+ if (env.APPDATA) {
227
+ candidates.push(`${env.APPDATA}/fnm/aliases/default/node.exe`);
228
+ }
229
+ for (const candidate of candidates) {
230
+ if (candidate && existsSync(candidate))
231
+ return candidate;
232
+ }
233
+ return execPath;
234
+ }
235
+ if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
236
+ return '/usr/local/bin/node';
237
+ }
238
+ if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
239
+ return '/opt/homebrew/bin/node';
240
+ }
241
+ return execPath;
242
+ }
243
+ function resolveNodeRunner(opts) {
244
+ const execPath = typeof process.execPath === 'string' ? process.execPath : '';
245
+ if (!execPath)
246
+ return null;
247
+ const stablePath = normalizeNodePath(execPath, opts);
248
+ return JSON.stringify(stablePath.replace(/\\/g, '/'));
249
+ }
250
+ function resolveBashRunner(opts) {
251
+ const platform = (opts && opts.platform) || process.platform;
252
+ if (platform !== 'win32')
253
+ return 'bash';
254
+ const env = (opts && opts.env) || process.env;
255
+ const exists = (opts && opts.existsSync) || node_fs_1.default.existsSync;
256
+ const candidates = [];
257
+ if (env.GSD_BASH_PATH)
258
+ candidates.push(env.GSD_BASH_PATH);
259
+ if (env.ProgramFiles)
260
+ candidates.push(node_path_1.default.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'));
261
+ if (env['ProgramFiles(x86)'])
262
+ candidates.push(node_path_1.default.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'));
263
+ if (env.SystemDrive) {
264
+ candidates.push(node_path_1.default.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe'));
265
+ candidates.push(node_path_1.default.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe'));
266
+ }
267
+ for (const candidate of candidates) {
268
+ if (candidate && exists(candidate)) {
269
+ return JSON.stringify(candidate.replace(/\\/g, '/'));
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
275
+ if (!settings || !settings.hooks || !absoluteRunner)
276
+ return false;
277
+ if (!opts)
278
+ opts = {};
279
+ const platform = opts.platform || process.platform;
280
+ let changed = false;
281
+ for (const entries of Object.values(settings.hooks)) {
282
+ if (!Array.isArray(entries))
283
+ continue;
284
+ for (const entry of entries) {
285
+ if (!entry || !Array.isArray(entry.hooks))
286
+ continue;
287
+ for (const h of entry.hooks) {
288
+ if (!h || typeof h.command !== 'string')
289
+ continue;
290
+ if (Array.isArray(h.args) && h.args.length > 0)
291
+ continue;
292
+ let trimmed = h.command.trim();
293
+ const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
294
+ if (hadPowerShellCallOperator) {
295
+ trimmed = trimmed.replace(/^&\s+/, '').trim();
296
+ }
297
+ const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
298
+ trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
299
+ if (!m)
300
+ continue;
301
+ let _runnerToken, scriptToken, scriptPath;
302
+ if (/^node\s+/.test(trimmed)) {
303
+ _runnerToken = 'node';
304
+ scriptToken = m[1];
305
+ scriptPath = m[2] || m[3] || m[4] || '';
306
+ }
307
+ else {
308
+ _runnerToken = m[1];
309
+ const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
310
+ const stableRunner = normalizeNodePath(runnerPath);
311
+ if (stableRunner === runnerPath && platform !== 'win32')
312
+ continue;
313
+ scriptToken = m[5];
314
+ scriptPath = m[6] || m[7] || m[8] || '';
315
+ }
316
+ if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' }))
317
+ continue;
318
+ const projectedCommand = projectLegacySettingsHookCommand({
319
+ absoluteRunner,
320
+ scriptPath,
321
+ scriptToken,
322
+ runtime: opts.runtime || 'generic',
323
+ platform,
324
+ });
325
+ if (!projectedCommand)
326
+ continue;
327
+ if (h.command === projectedCommand)
328
+ continue;
329
+ h.command = projectedCommand;
330
+ changed = true;
331
+ }
332
+ }
333
+ }
334
+ return changed;
335
+ }
336
+ function buildCodexHookBlock(targetDir, opts) {
337
+ const absoluteRunner = opts && opts.absoluteRunner;
338
+ if (!absoluteRunner)
339
+ return null;
340
+ const eol = (opts && opts.eol) || '\n';
341
+ const platform = (opts && opts.platform) || process.platform;
342
+ const updateCheckScript = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js');
343
+ const commandValue = projectCodexHookTomlCommand({
344
+ absoluteRunner,
345
+ scriptPath: updateCheckScript,
346
+ platform,
347
+ });
348
+ return `${eol}# GSD Hooks${eol}` +
349
+ `[[hooks.SessionStart]]${eol}` +
350
+ `${eol}` +
351
+ `[[hooks.SessionStart.hooks]]${eol}` +
352
+ `type = "command"${eol}` +
353
+ `command = "${commandValue}"${eol}`;
354
+ }
355
+ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
356
+ if (!content || !absoluteRunner)
357
+ return { content, changed: false };
358
+ const platform = (opts && opts.platform) || process.platform;
359
+ let changed = false;
360
+ const updated = content.replace(/^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm, (full, prefix, scriptToken, suffix) => {
361
+ const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
362
+ let scriptPath = scriptToken;
363
+ if (quoted) {
364
+ try {
365
+ scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
366
+ }
367
+ catch {
368
+ scriptPath = quoted[1];
369
+ }
370
+ }
371
+ if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' }))
372
+ return full;
373
+ const desiredCommand = projectCodexHookTomlCommand({
374
+ absoluteRunner,
375
+ scriptPath,
376
+ platform,
377
+ });
378
+ const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
379
+ if (currentCommand === desiredCommand)
380
+ return full;
381
+ changed = true;
382
+ return `${prefix}${desiredCommand}${suffix}`;
383
+ });
384
+ return { content: updated, changed };
385
+ }
386
+ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
387
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
388
+ const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
389
+ const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
390
+ const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
391
+ const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
392
+ let parsed = {};
393
+ let currentContent = null;
394
+ if (node_fs_1.default.existsSync(hooksJsonPath)) {
395
+ const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
396
+ currentContent = raw;
397
+ if (raw.trim()) {
398
+ try {
399
+ parsed = JSON.parse(raw);
400
+ }
401
+ catch (err) {
402
+ throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
403
+ }
404
+ }
405
+ }
406
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
407
+ parsed = {};
408
+ const usesNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
409
+ const hookTable = usesNestedHooksObject ? parsed['hooks'] : parsed;
410
+ const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
411
+ let removedLegacy = false;
412
+ const sanitizedEntries = [];
413
+ for (const entry of eventEntries) {
414
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
415
+ continue;
416
+ const entryObj = entry;
417
+ const originalHooks = Array.isArray(entryObj['hooks']) ? entryObj['hooks'] : [];
418
+ if (originalHooks.length === 0) {
419
+ sanitizedEntries.push(entry);
420
+ continue;
421
+ }
422
+ const keptHooks = originalHooks.filter((hook) => {
423
+ const cmd = hook && typeof hook === 'object' ? hook['command'] : null;
424
+ const managed = isManagedHookCommand(cmd, {
425
+ surface: 'codex-hooks-json',
426
+ includeLegacyAliases: true,
427
+ configDir: targetDir,
428
+ });
429
+ if (managed)
430
+ removedLegacy = true;
431
+ return !managed;
432
+ });
433
+ if (keptHooks.length === 0)
434
+ continue;
435
+ const nextEntry = { ...entryObj, hooks: keptHooks };
436
+ sanitizedEntries.push(nextEntry);
437
+ }
438
+ if (managedCommand) {
439
+ const hookEntry = { type: 'command', command: managedCommand };
440
+ if (commandWindows)
441
+ hookEntry['commandWindows'] = commandWindows;
442
+ if (timeout !== undefined)
443
+ hookEntry['timeout'] = timeout;
444
+ const newEntry = { hooks: [hookEntry] };
445
+ if (matcher !== undefined)
446
+ newEntry['matcher'] = matcher;
447
+ sanitizedEntries.push(newEntry);
448
+ }
449
+ if (sanitizedEntries.length > 0) {
450
+ hookTable[eventName] = sanitizedEntries;
451
+ }
452
+ else {
453
+ delete hookTable[eventName];
454
+ }
455
+ if (usesNestedHooksObject)
456
+ parsed['hooks'] = hookTable;
457
+ const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
458
+ const changed = currentContent !== nextContent;
459
+ const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
460
+ if (shouldWrite) {
461
+ atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
462
+ }
463
+ return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
464
+ }
465
+ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
466
+ return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
467
+ }
468
+ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
469
+ if (!absoluteRunnerToken)
470
+ return null;
471
+ let interpreter;
472
+ try {
473
+ interpreter = JSON.parse(absoluteRunnerToken);
474
+ }
475
+ catch {
476
+ interpreter = absoluteRunnerToken;
477
+ }
478
+ const targetAbs = scriptAbsPath.replace(/\\/g, '/');
479
+ const scriptQuoted = JSON.stringify(targetAbs);
480
+ const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
481
+ const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
482
+ const runnerQuoted = JSON.stringify(interpreter);
483
+ return {
484
+ invocation: { interpreter, target: scriptAbsPath },
485
+ cmdPath,
486
+ hookCommand,
487
+ eol: { cmd: '\r\n' },
488
+ passthroughArgs: true,
489
+ render: {
490
+ cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
491
+ },
492
+ };
493
+ }
494
+ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
495
+ const platform = opts.platform || process.platform;
496
+ const absoluteRunner = opts.absoluteRunner || null;
497
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
498
+ if (!absoluteRunner)
499
+ return { changed: false, wrote: false, path: hooksJsonPath };
500
+ const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
501
+ const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
502
+ let managedCommand;
503
+ if (platform === 'win32') {
504
+ const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
505
+ if (!shimIR)
506
+ return { changed: false, wrote: false, path: hooksJsonPath };
507
+ try {
508
+ atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
509
+ }
510
+ catch (shimWriteErr) {
511
+ const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
512
+ console.warn(` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
513
+ `Fix the write error (permissions? disk full?) and re-run the installer. ` +
514
+ `Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`);
515
+ return { changed: false, wrote: false, path: hooksJsonPath };
516
+ }
517
+ managedCommand = shimIR.hookCommand;
518
+ }
519
+ else {
520
+ managedCommand = projectManagedHookCommand({
521
+ absoluteRunner,
522
+ scriptPath,
523
+ runtime: 'codex',
524
+ platform,
525
+ }) ?? undefined;
526
+ }
527
+ if (!managedCommand)
528
+ return { changed: false, wrote: false, path: hooksJsonPath };
529
+ const commandWindows = platform === 'win32'
530
+ ? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
531
+ : undefined;
532
+ return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
533
+ }
534
+ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
535
+ const platform = opts.platform || process.platform;
536
+ const absoluteRunner = opts.absoluteRunner || null;
537
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
538
+ if (!absoluteRunner)
539
+ return { changed: false, wrote: false, path: hooksJsonPath };
540
+ const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
541
+ let managedCommand;
542
+ if (platform === 'win32') {
543
+ const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
544
+ if (!shimIR)
545
+ return { changed: false, wrote: false, path: hooksJsonPath };
546
+ try {
547
+ atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
548
+ }
549
+ catch (shimWriteErr) {
550
+ const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
551
+ console.warn(` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
552
+ `Fix the write error (permissions? disk full?) and re-run the installer.`);
553
+ return { changed: false, wrote: false, path: hooksJsonPath };
554
+ }
555
+ managedCommand = shimIR.hookCommand;
556
+ }
557
+ else {
558
+ managedCommand = projectManagedHookCommand({
559
+ absoluteRunner,
560
+ scriptPath,
561
+ runtime: 'codex',
562
+ platform,
563
+ }) ?? undefined;
564
+ }
565
+ if (!managedCommand)
566
+ return { changed: false, wrote: false, path: hooksJsonPath };
567
+ return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
568
+ }
569
+ // ---------------------------------------------------------------------------
570
+ // removeCodexHooksJsonEvent / removeCodexHooksJsonSessionStart
571
+ // ---------------------------------------------------------------------------
572
+ function removeCodexHooksJsonEvent(targetDir, eventName) {
573
+ return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
574
+ }
575
+ function removeCodexHooksJsonSessionStart(targetDir) {
576
+ return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
577
+ }
578
+ function buildHookCommand(configDir, hookName, opts) {
579
+ if (!opts)
580
+ opts = {};
581
+ const platform = opts.platform || process.platform;
582
+ const runtime = opts.runtime || 'generic';
583
+ const isShellHook = hookName.endsWith('.sh');
584
+ if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
585
+ if (opts.portableHooks) {
586
+ const portableBaseDir = projectPortableHookBaseDir({
587
+ configDir,
588
+ homeDir: node_os_1.default.homedir(),
589
+ });
590
+ return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
591
+ }
592
+ return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
593
+ }
594
+ const nodeRunner = resolveNodeRunner();
595
+ const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
596
+ if (runner === null)
597
+ return null;
598
+ if (opts.portableHooks) {
599
+ const portableBaseDir = projectPortableHookBaseDir({
600
+ configDir,
601
+ homeDir: node_os_1.default.homedir(),
602
+ });
603
+ return projectManagedHookCommand({
604
+ absoluteRunner: runner,
605
+ scriptPath: `${portableBaseDir}/hooks/${hookName}`,
606
+ runtime: opts.runtime || 'generic',
607
+ platform,
608
+ });
609
+ }
610
+ const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
611
+ return projectManagedHookCommand({
612
+ absoluteRunner: runner,
613
+ scriptPath: hooksPath,
614
+ runtime,
615
+ platform,
616
+ });
617
+ }
618
+ // ---------------------------------------------------------------------------
619
+ // Cline helpers
620
+ // ---------------------------------------------------------------------------
621
+ function buildClineRulesBody() {
622
+ return [
623
+ '# GSD Core — Git. Ship. Done.',
624
+ '',
625
+ '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
626
+ ' the user runs a `/gsd-*` command.',
627
+ '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
628
+ '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
629
+ '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
630
+ '- Do not apply GSD workflows unless the user explicitly asks for them.',
631
+ '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
632
+ ' step to the user using Cline\'s ask_user tool after completing it.',
633
+ ].join('\n') + '\n';
634
+ }
635
+ function buildClineAgentsMdBody() {
636
+ return buildClineRulesBody();
637
+ }
638
+ function buildClinePreToolUseHook() {
639
+ return `#!/usr/bin/env node
640
+ 'use strict';
641
+ /* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
642
+ * Protocol: JSON on stdin -> JSON decision on stdout.
643
+ * Honored fields: { cancel, errorMessage, contextModification }.
644
+ * Fails open: any error allows the operation. */
645
+ let raw = '';
646
+ process.stdin.setEncoding('utf8');
647
+ process.stdin.on('data', (c) => { raw += c; });
648
+ process.stdin.on('end', () => {
649
+ const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
650
+ let input;
651
+ try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
652
+ try {
653
+ const tool = String(
654
+ input.toolName || input.tool_name || input.tool ||
655
+ (input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
656
+ ).toLowerCase();
657
+ const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
658
+ // Collect only PATH-bearing field values (not free-form content), so a doc
659
+ // that merely mentions ".planning/" in its body is never falsely blocked.
660
+ const paths = [];
661
+ const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
662
+ const walk = (v, depth) => {
663
+ if (depth > 5 || paths.length > 64) return;
664
+ if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
665
+ if (v && typeof v === 'object') {
666
+ for (const k of Object.keys(v)) {
667
+ const val = v[k];
668
+ if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
669
+ else walk(val, depth + 1);
670
+ }
671
+ }
672
+ };
673
+ walk(input, 0);
674
+ const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
675
+ if (isWrite && paths.some(isPlanningPath)) {
676
+ return process.stdout.write(JSON.stringify({
677
+ cancel: true,
678
+ errorMessage:
679
+ 'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
680
+ }));
681
+ }
682
+ } catch { /* fall through to allow */ }
683
+ return allow();
684
+ });
685
+ `;
686
+ }
687
+ function mergeGsdAgentsMd(filePath, gsdContent) {
688
+ const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
689
+ if (!node_fs_1.default.existsSync(filePath)) {
690
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
691
+ node_fs_1.default.writeFileSync(filePath, gsdBlock + '\n');
692
+ return;
693
+ }
694
+ const existing = node_fs_1.default.readFileSync(filePath, 'utf8');
695
+ const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
696
+ const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
697
+ if (openIndex !== -1 && closeIndex !== -1) {
698
+ const before = existing.substring(0, openIndex).trimEnd();
699
+ const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
700
+ let newContent = '';
701
+ if (before)
702
+ newContent += before + '\n\n';
703
+ newContent += gsdBlock;
704
+ if (after)
705
+ newContent += '\n\n' + after;
706
+ newContent += '\n';
707
+ node_fs_1.default.writeFileSync(filePath, newContent);
708
+ return;
709
+ }
710
+ node_fs_1.default.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
711
+ }
712
+ // ---------------------------------------------------------------------------
713
+ // writeClineArtifacts
714
+ // ---------------------------------------------------------------------------
715
+ function writeClineArtifacts(targetDir, isGlobalInstall) {
716
+ const written = [];
717
+ const clinerulesDir = node_path_1.default.join(targetDir, '.clinerules');
718
+ try {
719
+ if (node_fs_1.default.existsSync(clinerulesDir)) {
720
+ const st = node_fs_1.default.lstatSync(clinerulesDir);
721
+ if (st.isFile() || st.isSymbolicLink()) {
722
+ node_fs_1.default.unlinkSync(clinerulesDir);
723
+ console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
724
+ }
725
+ }
726
+ }
727
+ catch { /* best-effort migration */ }
728
+ node_fs_1.default.mkdirSync(clinerulesDir, { recursive: true });
729
+ node_fs_1.default.writeFileSync(node_path_1.default.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
730
+ written.push('.clinerules/gsd.md');
731
+ console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
732
+ const hooksDir = node_path_1.default.join(clinerulesDir, 'hooks');
733
+ node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
734
+ const hookPath = node_path_1.default.join(hooksDir, 'PreToolUse');
735
+ node_fs_1.default.writeFileSync(hookPath, buildClinePreToolUseHook());
736
+ try {
737
+ node_fs_1.default.chmodSync(hookPath, 0o755);
738
+ }
739
+ catch { /* Windows: hooks unsupported anyway */ }
740
+ written.push('.clinerules/hooks/PreToolUse');
741
+ console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
742
+ if (isGlobalInstall) {
743
+ try {
744
+ const agentsPath = node_path_1.default.join(node_os_1.default.homedir(), '.agents', 'AGENTS.md');
745
+ mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
746
+ console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
747
+ }
748
+ catch (err) {
749
+ console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
750
+ }
751
+ }
752
+ return written;
753
+ }
754
+ // ---------------------------------------------------------------------------
755
+ // Cursor hook functions
756
+ // ---------------------------------------------------------------------------
757
+ function buildCursorHookEntry(scriptPath) {
758
+ return {
759
+ type: 'command',
760
+ command: scriptPath.replace(/\\/g, '/'),
761
+ [GSD_CURSOR_HOOK_MARKER]: true,
762
+ };
763
+ }
764
+ function isManagedCursorHookEntry(entry) {
765
+ return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]);
766
+ }
767
+ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
768
+ let parsed = {};
769
+ let currentContent = null;
770
+ if (node_fs_1.default.existsSync(hooksJsonPath)) {
771
+ const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
772
+ currentContent = raw;
773
+ if (raw.trim()) {
774
+ try {
775
+ parsed = JSON.parse(raw);
776
+ }
777
+ catch (err) {
778
+ throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
779
+ }
780
+ }
781
+ }
782
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
783
+ parsed = {};
784
+ const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
785
+ if (!hasNestedHooksObject) {
786
+ const eventKeys = ['sessionStart', 'postToolUse'];
787
+ const lifted = {};
788
+ for (const k of eventKeys) {
789
+ if (Array.isArray(parsed[k])) {
790
+ lifted[k] = parsed[k];
791
+ delete parsed[k];
792
+ }
793
+ }
794
+ parsed['hooks'] = lifted;
795
+ }
796
+ if (!parsed['version'])
797
+ parsed['version'] = 1;
798
+ const hookTable = parsed['hooks'];
799
+ const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
800
+ const entries = managedEntries || {};
801
+ for (const event of MANAGED_EVENTS) {
802
+ const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
803
+ const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
804
+ const newEntry = entries[event] || null;
805
+ if (newEntry) {
806
+ hookTable[event] = [...userOwned, newEntry];
807
+ }
808
+ else {
809
+ if (userOwned.length > 0) {
810
+ hookTable[event] = userOwned;
811
+ }
812
+ else {
813
+ delete hookTable[event];
814
+ }
815
+ }
816
+ }
817
+ const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
818
+ const changed = currentContent !== nextContent;
819
+ const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
820
+ if (shouldWrite) {
821
+ atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
822
+ }
823
+ return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
824
+ }
825
+ function writeCursorHooksJson(targetDir, src, opts) {
826
+ opts = opts || {};
827
+ const hooksDir = node_path_1.default.join(targetDir, 'hooks');
828
+ node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
829
+ const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
830
+ const srcHooksDir = node_path_1.default.join(src, 'hooks');
831
+ const installedScripts = new Set();
832
+ for (const script of hookScripts) {
833
+ const srcPath = node_path_1.default.join(srcHooksDir, script);
834
+ const destPath = node_path_1.default.join(hooksDir, script);
835
+ if (node_fs_1.default.existsSync(srcPath)) {
836
+ let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
837
+ content = content.replace(/gsd:/gi, 'gsd-');
838
+ node_fs_1.default.writeFileSync(destPath, content);
839
+ try {
840
+ node_fs_1.default.chmodSync(destPath, 0o755);
841
+ }
842
+ catch { /* Windows: ignore chmod */ }
843
+ installedScripts.add(script);
844
+ }
845
+ }
846
+ const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
847
+ const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
848
+ ? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
849
+ : null;
850
+ const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
851
+ ? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
852
+ : null;
853
+ const managedEntries = {};
854
+ if (sessionStartCmd) {
855
+ managedEntries['sessionStart'] = {
856
+ type: 'command',
857
+ command: sessionStartCmd,
858
+ [GSD_CURSOR_HOOK_MARKER]: true,
859
+ };
860
+ }
861
+ if (postToolCmd) {
862
+ managedEntries['postToolUse'] = {
863
+ type: 'command',
864
+ command: postToolCmd,
865
+ [GSD_CURSOR_HOOK_MARKER]: true,
866
+ };
867
+ }
868
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
869
+ const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
870
+ return { hooksJsonPath, changed: result.changed };
871
+ }
872
+ function removeCursorHooksJson(targetDir) {
873
+ const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
874
+ if (!node_fs_1.default.existsSync(hooksJsonPath))
875
+ return { changed: false };
876
+ const result = reconcileCursorHooksJson(hooksJsonPath, null);
877
+ if (result.changed) {
878
+ try {
879
+ const contentRaw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
880
+ const parsed = JSON.parse(contentRaw);
881
+ const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
882
+ ? parsed['hooks']
883
+ : {};
884
+ const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
885
+ if (!hasAnyEvents) {
886
+ node_fs_1.default.unlinkSync(hooksJsonPath);
887
+ return { changed: true };
888
+ }
889
+ }
890
+ catch { /* best-effort: leave the file */ }
891
+ }
892
+ return { changed: result.changed };
893
+ }
894
+ // ---------------------------------------------------------------------------
895
+ // Copilot hook functions
896
+ // ---------------------------------------------------------------------------
897
+ function buildCopilotHookConfig() {
898
+ return {
899
+ version: 1,
900
+ hooks: {
901
+ sessionStart: [
902
+ {
903
+ type: 'command',
904
+ bash: GSD_COPILOT_SESSION_HOOK_BASH,
905
+ powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
906
+ timeoutSec: 10,
907
+ },
908
+ ],
909
+ },
910
+ };
911
+ }
912
+ function writeCopilotHookConfig(targetDir) {
913
+ const hooksDir = node_path_1.default.join(targetDir, 'hooks');
914
+ node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
915
+ const hookPath = node_path_1.default.join(hooksDir, GSD_COPILOT_HOOK_FILE);
916
+ node_fs_1.default.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
917
+ return hookPath;
918
+ }
919
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
920
+ function applySettingsJsonHooks(settings, opts) {
921
+ /* eslint-disable @typescript-eslint/no-unsafe-member-access,
922
+ @typescript-eslint/no-unsafe-call,
923
+ @typescript-eslint/no-unsafe-assignment */
924
+ const { runtime, isGlobal, targetDir, postToolEvent, hookEvents, extendedHookEvents, hooksSurface, updateCheckCommand, contextMonitorCommand, promptGuardCommand, readGuardCommand, readInjectionScannerCommand, configReloadCommand, hookOpts, localCmd, localShellCmd, } = opts;
925
+ // ADR-857 phase 5f-3: extended hook events are now driven by the registry
926
+ // descriptor field rather than hardcoded runtime-name checks.
927
+ const extendedEvents = Array.isArray(extendedHookEvents) ? extendedHookEvents : [];
928
+ // ADR-857 phase 5g drive 3: hook-skip guard is driven by the hooksSurface
929
+ // descriptor field. Only runtimes with hooksSurface === 'settings-json'
930
+ // register settings.json hooks; runtimes with hooksSurface === 'none'
931
+ // (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
932
+ // the old !isOpencode && !isKilo check.
933
+ if (hooksSurface !== 'none') {
934
+ if (!settings.hooks) {
935
+ settings.hooks = {};
936
+ }
937
+ if (!settings.hooks.SessionStart) {
938
+ settings.hooks.SessionStart = [];
939
+ }
940
+ const hasGsdUpdateHook = settings.hooks.SessionStart.some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-check-update')));
941
+ // Guard: only register if the hook file was actually installed (#1754).
942
+ // When hooks/dist/ is missing from the npm package (as in v1.32.0), the
943
+ // copy step produces no files but the registration step ran unconditionally,
944
+ // causing "hook error" on every tool invocation.
945
+ const checkUpdateFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-check-update.js');
946
+ if (!hasGsdUpdateHook && node_fs_1.default.existsSync(checkUpdateFile) && updateCheckCommand) {
947
+ settings.hooks.SessionStart.push({
948
+ hooks: [
949
+ {
950
+ type: 'command',
951
+ command: updateCheckCommand
952
+ }
953
+ ]
954
+ });
955
+ console.log(` ${green}✓${reset} Configured update check hook`);
956
+ }
957
+ else if (!hasGsdUpdateHook && !node_fs_1.default.existsSync(checkUpdateFile)) {
958
+ console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`);
959
+ }
960
+ // Configure post-tool hook for context window monitoring
961
+ if (!settings.hooks[postToolEvent]) {
962
+ settings.hooks[postToolEvent] = [];
963
+ }
964
+ const hasContextMonitorHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor')));
965
+ const contextMonitorFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-context-monitor.js');
966
+ if (!hasContextMonitorHook && node_fs_1.default.existsSync(contextMonitorFile) && contextMonitorCommand) {
967
+ settings.hooks[postToolEvent].push({
968
+ matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
969
+ hooks: [
970
+ {
971
+ type: 'command',
972
+ command: contextMonitorCommand,
973
+ timeout: 10
974
+ }
975
+ ]
976
+ });
977
+ console.log(` ${green}✓${reset} Configured context window monitor hook`);
978
+ }
979
+ else if (!hasContextMonitorHook && !node_fs_1.default.existsSync(contextMonitorFile)) {
980
+ console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`);
981
+ }
982
+ else {
983
+ // Migrate existing context monitor hooks: add matcher and timeout if missing
984
+ for (const entry of settings.hooks[postToolEvent]) {
985
+ if (entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor'))) {
986
+ let migrated = false;
987
+ if (!entry.matcher) {
988
+ entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
989
+ migrated = true;
990
+ }
991
+ for (const h of entry.hooks) {
992
+ if (referencesHook(h, 'gsd-context-monitor') && !h.timeout) {
993
+ h.timeout = 10;
994
+ migrated = true;
995
+ }
996
+ }
997
+ if (migrated) {
998
+ console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`);
999
+ }
1000
+ }
1001
+ }
1002
+ }
1003
+ // Configure PreToolUse hook for prompt injection detection
1004
+ // ADR-857 phase 5f-2: drive dialect from opts.hookEvents (registry descriptor).
1005
+ // hookEvents='gemini' → BeforeTool; all others → PreToolUse.
1006
+ // Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} (same as old check).
1007
+ const preToolEvent = hookEvents === 'gemini' ? 'BeforeTool' : 'PreToolUse';
1008
+ if (!settings.hooks[preToolEvent]) {
1009
+ settings.hooks[preToolEvent] = [];
1010
+ }
1011
+ const hasPromptGuardHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-prompt-guard')));
1012
+ const promptGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
1013
+ if (!hasPromptGuardHook && node_fs_1.default.existsSync(promptGuardFile) && promptGuardCommand) {
1014
+ settings.hooks[preToolEvent].push({
1015
+ matcher: 'Write|Edit',
1016
+ hooks: [
1017
+ {
1018
+ type: 'command',
1019
+ command: promptGuardCommand,
1020
+ timeout: 5
1021
+ }
1022
+ ]
1023
+ });
1024
+ console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
1025
+ }
1026
+ else if (!hasPromptGuardHook && !node_fs_1.default.existsSync(promptGuardFile)) {
1027
+ console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`);
1028
+ }
1029
+ // Configure PreToolUse hook for read-before-edit guidance (#1628)
1030
+ // Prevents infinite retry loops when non-Claude models attempt to edit
1031
+ // files without reading them first. Advisory-only — does not block.
1032
+ const hasReadGuardHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-read-guard')));
1033
+ const readGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-read-guard.js');
1034
+ if (!hasReadGuardHook && node_fs_1.default.existsSync(readGuardFile) && readGuardCommand) {
1035
+ settings.hooks[preToolEvent].push({
1036
+ matcher: 'Write|Edit',
1037
+ hooks: [
1038
+ {
1039
+ type: 'command',
1040
+ command: readGuardCommand,
1041
+ timeout: 5
1042
+ }
1043
+ ]
1044
+ });
1045
+ console.log(` ${green}✓${reset} Configured read-before-edit guard hook`);
1046
+ }
1047
+ else if (!hasReadGuardHook && !node_fs_1.default.existsSync(readGuardFile)) {
1048
+ console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`);
1049
+ }
1050
+ // Configure PostToolUse hook for read-time prompt injection scanning (#2201)
1051
+ // Scans content returned by the Read tool for injection patterns, including
1052
+ // summarisation-specific patterns that survive context compression.
1053
+ const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-read-injection-scanner')));
1054
+ const readInjectionScannerFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
1055
+ if (!hasReadInjectionScannerHook && node_fs_1.default.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) {
1056
+ settings.hooks[postToolEvent].push({
1057
+ matcher: 'Read',
1058
+ hooks: [
1059
+ {
1060
+ type: 'command',
1061
+ command: readInjectionScannerCommand,
1062
+ timeout: 5
1063
+ }
1064
+ ]
1065
+ });
1066
+ console.log(` ${green}✓${reset} Configured read injection scanner hook`);
1067
+ }
1068
+ else if (!hasReadInjectionScannerHook && !node_fs_1.default.existsSync(readInjectionScannerFile)) {
1069
+ console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`);
1070
+ }
1071
+ // Community hooks — registered on install but opt-in at runtime.
1072
+ // Each hook checks .planning/config.json for hooks.community: true
1073
+ // and exits silently (no-op) if not enabled. This lets users enable
1074
+ // them per-project by adding: "hooks": { "community": true }
1075
+ // Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
1076
+ // Detects file edits outside GSD workflow context and advises using
1077
+ // /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks
1078
+ // unsafe Bash commands that violate worktree-agent isolation.
1079
+ const workflowGuardCommand = isGlobal
1080
+ ? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts)
1081
+ : localCmd('gsd-workflow-guard.js');
1082
+ const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
1083
+ const workflowGuardHookEntry = settings.hooks[preToolEvent].find((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-workflow-guard')));
1084
+ const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
1085
+ const workflowGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-workflow-guard.js');
1086
+ if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) {
1087
+ workflowGuardHookEntry.matcher = workflowGuardMatcher;
1088
+ console.log(` ${green}✓${reset} Updated workflow guard hook matcher`);
1089
+ }
1090
+ else if (!hasWorkflowGuardHook && node_fs_1.default.existsSync(workflowGuardFile) && workflowGuardCommand) {
1091
+ settings.hooks[preToolEvent].push({
1092
+ matcher: workflowGuardMatcher,
1093
+ hooks: [
1094
+ {
1095
+ type: 'command',
1096
+ command: workflowGuardCommand,
1097
+ timeout: 5
1098
+ }
1099
+ ]
1100
+ });
1101
+ console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
1102
+ }
1103
+ else if (!hasWorkflowGuardHook && !node_fs_1.default.existsSync(workflowGuardFile)) {
1104
+ console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`);
1105
+ }
1106
+ // Configure PreToolUse hook for worktree absolute-path safety (#260)
1107
+ // Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve
1108
+ // outside the current worktree root. Prevents executor agents from
1109
+ // accidentally writing to the main checkout when running in isolation="worktree".
1110
+ const worktreePathGuardCommand = isGlobal
1111
+ ? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
1112
+ : localCmd('gsd-worktree-path-guard.js');
1113
+ const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-worktree-path-guard')));
1114
+ const worktreePathGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
1115
+ if (!hasWorktreePathGuardHook && node_fs_1.default.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
1116
+ settings.hooks[preToolEvent].push({
1117
+ matcher: 'Write|Edit|MultiEdit',
1118
+ hooks: [
1119
+ {
1120
+ type: 'command',
1121
+ command: worktreePathGuardCommand,
1122
+ timeout: 5
1123
+ }
1124
+ ]
1125
+ });
1126
+ console.log(` ${green}✓${reset} Configured worktree path guard hook`);
1127
+ }
1128
+ else if (!hasWorktreePathGuardHook && !node_fs_1.default.existsSync(worktreePathGuardFile)) {
1129
+ console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`);
1130
+ }
1131
+ // Configure commit validation hook (Conventional Commits enforcement, opt-in)
1132
+ const validateCommitCommand = isGlobal
1133
+ ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
1134
+ : localShellCmd('gsd-validate-commit.sh');
1135
+ const hasValidateCommitHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-validate-commit')));
1136
+ // Guard: only register if the .sh file was actually installed. If the npm package
1137
+ // omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
1138
+ // causes a hook error on every Bash tool invocation.
1139
+ const validateCommitFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-validate-commit.sh');
1140
+ if (!hasValidateCommitHook && node_fs_1.default.existsSync(validateCommitFile) && validateCommitCommand) {
1141
+ settings.hooks[preToolEvent].push({
1142
+ matcher: 'Bash',
1143
+ hooks: [
1144
+ {
1145
+ type: 'command',
1146
+ command: validateCommitCommand,
1147
+ timeout: 5
1148
+ }
1149
+ ]
1150
+ });
1151
+ console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
1152
+ }
1153
+ else if (!hasValidateCommitHook && !node_fs_1.default.existsSync(validateCommitFile)) {
1154
+ console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`);
1155
+ }
1156
+ else if (!hasValidateCommitHook && !validateCommitCommand) {
1157
+ console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
1158
+ }
1159
+ // Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
1160
+ // PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
1161
+ // on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
1162
+ // .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
1163
+ const graphifyUpdateCommand = isGlobal
1164
+ ? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
1165
+ : localShellCmd('gsd-graphify-update.sh');
1166
+ const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-graphify-update')));
1167
+ const graphifyUpdateFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
1168
+ if (!hasGraphifyUpdateHook && node_fs_1.default.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
1169
+ settings.hooks[postToolEvent].push({
1170
+ matcher: 'Bash',
1171
+ hooks: [
1172
+ {
1173
+ type: 'command',
1174
+ command: graphifyUpdateCommand,
1175
+ timeout: 5
1176
+ }
1177
+ ]
1178
+ });
1179
+ console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
1180
+ }
1181
+ else if (!hasGraphifyUpdateHook && !node_fs_1.default.existsSync(graphifyUpdateFile)) {
1182
+ console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
1183
+ }
1184
+ else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
1185
+ console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
1186
+ }
1187
+ // Configure session state orientation hook (opt-in)
1188
+ const sessionStateCommand = isGlobal
1189
+ ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
1190
+ : localShellCmd('gsd-session-state.sh');
1191
+ const hasSessionStateHook = settings.hooks.SessionStart.some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-session-state')));
1192
+ const sessionStateFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-session-state.sh');
1193
+ if (!hasSessionStateHook && node_fs_1.default.existsSync(sessionStateFile) && sessionStateCommand) {
1194
+ settings.hooks.SessionStart.push({
1195
+ hooks: [
1196
+ {
1197
+ type: 'command',
1198
+ command: sessionStateCommand
1199
+ }
1200
+ ]
1201
+ });
1202
+ console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
1203
+ }
1204
+ else if (!hasSessionStateHook && !node_fs_1.default.existsSync(sessionStateFile)) {
1205
+ console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`);
1206
+ }
1207
+ else if (!hasSessionStateHook && !sessionStateCommand) {
1208
+ console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`);
1209
+ }
1210
+ // Configure phase boundary detection hook (opt-in)
1211
+ const phaseBoundaryCommand = isGlobal
1212
+ ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
1213
+ : localShellCmd('gsd-phase-boundary.sh');
1214
+ const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-phase-boundary')));
1215
+ const phaseBoundaryFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
1216
+ if (!hasPhaseBoundaryHook && node_fs_1.default.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
1217
+ settings.hooks[postToolEvent].push({
1218
+ matcher: 'Write|Edit',
1219
+ hooks: [
1220
+ {
1221
+ type: 'command',
1222
+ command: phaseBoundaryCommand,
1223
+ timeout: 5
1224
+ }
1225
+ ]
1226
+ });
1227
+ console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
1228
+ }
1229
+ else if (!hasPhaseBoundaryHook && !node_fs_1.default.existsSync(phaseBoundaryFile)) {
1230
+ console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`);
1231
+ }
1232
+ else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
1233
+ console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
1234
+ }
1235
+ // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
1236
+ // Claude Code (since #770) and Qwen Code (since #788) both support these
1237
+ // three lifecycle events. Wire gsd-context-monitor so agents get context-
1238
+ // headroom warnings at subagent completion, model stop, and pre-compaction
1239
+ // (the most critical moment to surface headroom info).
1240
+ //
1241
+ // SubagentStop — subagent lifecycle completion (context headroom tracking)
1242
+ // Stop — model stop / final-response moment (context headroom)
1243
+ // PreCompact — fires before conversation compaction (most critical
1244
+ // moment to surface context headroom warnings)
1245
+ //
1246
+ // Note: UserPromptSubmit is NOT wired here. That event carries the raw
1247
+ // user prompt text, not a tool invocation, so gsd-prompt-guard (which
1248
+ // exits unless tool_name is Write/Edit) would be a silent no-op. A
1249
+ // dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
1250
+ // SubagentStop, Stop, PreCompact — route through the context monitor.
1251
+ // Guard is now descriptor-driven: only events present in extendedEvents are wired.
1252
+ {
1253
+ const runtimeLabel = runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? 'Claude Code' : runtime;
1254
+ for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
1255
+ if (!extendedEvents.includes(event))
1256
+ continue;
1257
+ if (!settings.hooks[event]) {
1258
+ settings.hooks[event] = [];
1259
+ }
1260
+ const alreadyHasContextMonitor = settings.hooks[event].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor')));
1261
+ if (!alreadyHasContextMonitor && node_fs_1.default.existsSync(contextMonitorFile) && contextMonitorCommand) {
1262
+ settings.hooks[event].push({
1263
+ hooks: [
1264
+ {
1265
+ type: 'command',
1266
+ command: contextMonitorCommand,
1267
+ timeout: 10
1268
+ }
1269
+ ]
1270
+ });
1271
+ console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
1272
+ }
1273
+ else if (!alreadyHasContextMonitor && !node_fs_1.default.existsSync(contextMonitorFile)) {
1274
+ console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
1275
+ }
1276
+ }
1277
+ }
1278
+ // ── end SubagentStop / Stop / PreCompact events ────────────────────────────
1279
+ // ── Gemini-only extended hook events (#776) ───────────────────────────────
1280
+ // Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
1281
+ // gsd previously did not register. Three high-value events are added here:
1282
+ //
1283
+ // BeforeAgent — fires after user submits a prompt, before the agent
1284
+ // plans. Wire gsd-context-monitor for context headroom
1285
+ // awareness at prompt time.
1286
+ // AfterAgent — fires once per turn after the model generates its final
1287
+ // response. Wire gsd-context-monitor to track headroom
1288
+ // after each agent turn completes.
1289
+ // BeforeModel — fires before each LLM call (per-turn, not per-session).
1290
+ // Wire gsd-context-monitor for per-turn context injection
1291
+ // — more precise than session-start-only injection.
1292
+ //
1293
+ // All three reuse gsd-context-monitor.js — no new hook files needed.
1294
+ // The `decision:"deny"` retry capability of AfterAgent is intentionally
1295
+ // left to the hook script to implement when triggered (gsd-context-monitor
1296
+ // exits 0 / advisory-only today; an active quality gate is a follow-on).
1297
+ //
1298
+ // Note: BeforeToolSelection is NOT wired. That event does not map to a
1299
+ // gsd hook use case at this time; deferred to a follow-on issue.
1300
+ //
1301
+ // Guard is now descriptor-driven: only events present in extendedEvents are wired.
1302
+ for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
1303
+ if (!extendedEvents.includes(geminiEvent))
1304
+ continue;
1305
+ if (!Array.isArray(settings.hooks[geminiEvent])) {
1306
+ settings.hooks[geminiEvent] = [];
1307
+ }
1308
+ const alreadyHasContextMonitor = settings.hooks[geminiEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor')));
1309
+ if (!alreadyHasContextMonitor && node_fs_1.default.existsSync(contextMonitorFile) && contextMonitorCommand) {
1310
+ settings.hooks[geminiEvent].push({
1311
+ hooks: [
1312
+ {
1313
+ type: 'command',
1314
+ command: contextMonitorCommand,
1315
+ timeout: 10
1316
+ }
1317
+ ]
1318
+ });
1319
+ console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
1320
+ }
1321
+ else if (!alreadyHasContextMonitor && !node_fs_1.default.existsSync(contextMonitorFile)) {
1322
+ console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
1323
+ }
1324
+ }
1325
+ // ── end Gemini-only extended hook events ──────────────────────────────────
1326
+ // ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
1327
+ // Claude Code fires FileChanged when a watched file changes on disk. Wire
1328
+ // gsd-config-reload.js to reload the gsd config context whenever the user
1329
+ // edits .planning/config.json mid-session, eliminating the need to restart.
1330
+ //
1331
+ // The matcher "config.json" watches for changes to any file named config.json
1332
+ // (Claude Code matches by filename, not full path). The hook exits silently
1333
+ // when the changed file is not the gsd config.
1334
+ //
1335
+ // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
1336
+ // verified; extend in a follow-on if empirically confirmed.
1337
+ if (extendedEvents.includes('FileChanged')) {
1338
+ if (!settings.hooks.FileChanged) {
1339
+ settings.hooks.FileChanged = [];
1340
+ }
1341
+ const configReloadFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-config-reload.js');
1342
+ const alreadyHasConfigReload = settings.hooks.FileChanged.some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-config-reload')));
1343
+ if (!alreadyHasConfigReload && node_fs_1.default.existsSync(configReloadFile) && configReloadCommand) {
1344
+ settings.hooks.FileChanged.push({
1345
+ matcher: 'config.json',
1346
+ hooks: [
1347
+ {
1348
+ type: 'command',
1349
+ command: configReloadCommand,
1350
+ timeout: 8
1351
+ }
1352
+ ]
1353
+ });
1354
+ console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
1355
+ }
1356
+ else if (!alreadyHasConfigReload && !node_fs_1.default.existsSync(configReloadFile)) {
1357
+ console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
1358
+ }
1359
+ else if (!alreadyHasConfigReload && !configReloadCommand) {
1360
+ console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
1361
+ }
1362
+ }
1363
+ // ── end FileChanged hook ────────────────────────────────────────────────────
1364
+ }
1365
+ /* eslint-enable @typescript-eslint/no-unsafe-member-access,
1366
+ @typescript-eslint/no-unsafe-call,
1367
+ @typescript-eslint/no-unsafe-assignment */
1368
+ }
1369
+ // ---------------------------------------------------------------------------
1370
+ // referencesHook
1371
+ //
1372
+ // Pure predicate — checks whether a hook entry object references a managed
1373
+ // hook by name. Covers all three registration shapes used by GSD:
1374
+ // • plain command string (standard form)
1375
+ // • args array (command+args / wrapped-launcher form used by windowless
1376
+ // launchers on Windows and some custom PATH-less environments) (#976)
1377
+ // • url field (type:"http" local-server routing form) (#1004)
1378
+ // Without covering all three, an http-form or args-form entry is invisible
1379
+ // and a stock string-command entry is appended on every install/update,
1380
+ // running the hook twice.
1381
+ //
1382
+ // Originally declared inside install()/finishInstall() as a local function;
1383
+ // promoted here so applySettingsJsonHooks() and finishInstall() share one
1384
+ // copy (ADR-857 phase 5f-1b).
1385
+ // ---------------------------------------------------------------------------
1386
+ function referencesHook(h, hookName) {
1387
+ const cmd = h['command'];
1388
+ const args = h['args'];
1389
+ const url = h['url'];
1390
+ return (typeof cmd === 'string' && cmd.includes(hookName)) ||
1391
+ (Array.isArray(args) && args.some(a => typeof a === 'string' && a.includes(hookName))) ||
1392
+ (typeof url === 'string' && url.includes(hookName));
1393
+ }
1394
+ module.exports = {
1395
+ // Cline
1396
+ buildClineRulesBody,
1397
+ buildClineAgentsMdBody,
1398
+ buildClinePreToolUseHook,
1399
+ mergeGsdAgentsMd,
1400
+ writeClineArtifacts,
1401
+ GSD_AGENTS_MD_MARKER,
1402
+ GSD_AGENTS_MD_CLOSE_MARKER,
1403
+ // Cursor
1404
+ buildCursorHookEntry,
1405
+ isManagedCursorHookEntry,
1406
+ reconcileCursorHooksJson,
1407
+ writeCursorHooksJson,
1408
+ removeCursorHooksJson,
1409
+ GSD_CURSOR_SESSION_HOOK_SCRIPT,
1410
+ GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
1411
+ GSD_CURSOR_HOOK_MARKER,
1412
+ // Copilot
1413
+ buildCopilotHookConfig,
1414
+ writeCopilotHookConfig,
1415
+ GSD_COPILOT_HOOK_FILE,
1416
+ // Codex hooks.json
1417
+ reconcileCodexHooksJsonEvent,
1418
+ reconcileCodexHooksJsonSessionStart,
1419
+ ensureCodexHooksJsonSessionStart,
1420
+ ensureCodexHooksJsonEvent,
1421
+ removeCodexHooksJsonEvent,
1422
+ removeCodexHooksJsonSessionStart,
1423
+ buildCodexHookWindowsShimIR,
1424
+ // Codex TOML
1425
+ buildCodexHookBlock,
1426
+ rewriteLegacyCodexHookBlock,
1427
+ // Shared
1428
+ buildHookCommand,
1429
+ applySettingsJsonHooks,
1430
+ referencesHook,
1431
+ rewriteLegacyManagedNodeHookCommands,
1432
+ normalizeNodePath,
1433
+ resolveNodeRunner,
1434
+ resolveBashRunner,
1435
+ // Atomic write seam (shared with bin/install.js so all writes participate
1436
+ // in install.js's _cleanTmpFiles() scoped temp-cleanup).
1437
+ atomicWriteFileSync,
1438
+ __atomicWrittenTmps,
1439
+ };