'use strict'; /** * Runtime Hooks Surface Module — hook-surface writer functions extracted from * bin/install.js (ADR-857 phase 5f-1). * * Owns the lifecycle writer functions for hook surfaces managed by GSD on four * runtimes: * Cline: writeClineArtifacts + supporting helpers/constants * Cursor: buildCursorHookEntry, isManagedCursorHookEntry, * reconcileCursorHooksJson, writeCursorHooksJson, removeCursorHooksJson * Copilot: buildCopilotHookConfig, writeCopilotHookConfig * Codex hooks.json: ensureCodexHooksJsonSessionStart, ensureCodexHooksJsonEvent, * reconcileCodexHooksJsonEvent, reconcileCodexHooksJsonSessionStart, * removeCodexHooksJsonEvent, removeCodexHooksJsonSessionStart, * buildCodexHookWindowsShimIR, buildCodexHookBlock, rewriteLegacyCodexHookBlock * Shared: buildHookCommand, rewriteLegacyManagedNodeHookCommands * * BEHAVIOR-PRESERVING RELOCATION: all logic is copied verbatim from * bin/install.js. No behavior change, no descriptor reads, no new IO. * * #2876 (epic #2866 Phase 7): bin/install.js previously re-exported every * symbol from this module, but a repo-wide audit found zero production * consumers of those re-exports — no `require('../bin/install.js'). * writeCursorHooksJson` (or any sibling) exists outside a doc comment * anywhere in the tree. The re-exports were test-suite-only pass-throughs; * tests now require this module directly instead. */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; // #2544: the single source of truth for the CommonJS module-type marker. The // two helpers below are thin boolean-returning shims over these — see the // marker section for why this file no longer carries its own copy. import { ensureCommonJsMarker as ensureCommonJsMarkerOwned, removeCommonJsMarker as removeCommonJsMarkerOwned, } from './commonjs-marker.cjs'; import { CURSOR_HOOK_EVENTS, CURSOR_EVENT_SCRIPT_MAP, resolveManagedHookEvents, resolveHookScripts, buildHookBusEntries, } from './host-integration-adapters/imperative-hook-bus.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import shellCmdProjection = require('./shell-command-projection.cjs'); const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, escapeTomlDoubleQuotedString, } = shellCmdProjection as { isManagedHookBasename: (scriptPath: string, opts?: { surface?: string }) => boolean; isManagedHookCommand: (cmd: string | null | undefined, opts?: { surface?: string; includeLegacyAliases?: boolean; configDir?: string }) => boolean; projectLegacySettingsHookCommand: (opts: { absoluteRunner: string; scriptPath: string; scriptToken: string; runtime: string; platform: string }) => string | null; projectManagedHookCommand: (opts: { absoluteRunner: string; scriptPath: string; runtime: string; platform: string; hookShell?: string }) => string | null; projectPortableHookBaseDir: (opts: { configDir: string; homeDir: string }) => string; projectCodexHookTomlCommand: (opts: { absoluteRunner: string; scriptPath: string; platform: string }) => string; shellHookOmitsBashRunner: (opts: { platform: string; runtime: string; isShellHook: boolean }) => boolean; escapeTomlDoubleQuotedString: (value: unknown) => string; }; // --------------------------------------------------------------------------- // Terminal color constants (mirrors install.js for console output parity) // --------------------------------------------------------------------------- const green = '\x1b[32m'; const yellow = '\x1b[33m'; const reset = '\x1b[0m'; // --------------------------------------------------------------------------- // Codex config.toml constants (subset needed by this module) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Copilot hook constants // --------------------------------------------------------------------------- const GSD_COPILOT_HOOK_FILE = 'gsd-session.json'; const GSD_COPILOT_SESSION_MSG_PRESENT = 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.'; const GSD_COPILOT_SESSION_MSG_ABSENT = 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.'; const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`; const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' + `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` + `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`; // #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static, // deterministic advisory (no branching/no-op-style, matching sessionStart's // tone) so the emitted hooks/gsd-session.json stays golden-trackable — no // node-runner invocation, no filesystem probing beyond what sessionStart // already does. const GSD_COPILOT_PRE_TOOL_MSG = 'GSD: confirm this tool use is in scope for the active phase before proceeding.'; const GSD_COPILOT_PRE_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`; const GSD_COPILOT_PRE_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`; const GSD_COPILOT_POST_TOOL_MSG = 'GSD: review the tool result against the active phase before continuing.'; const GSD_COPILOT_POST_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`; const GSD_COPILOT_POST_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`; const GSD_COPILOT_PROMPT_SUBMIT_MSG = 'GSD: check this request against .planning/STATE.md scope before acting.'; const GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`; const GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`; const GSD_COPILOT_SESSION_END_MSG = 'GSD: update .planning/STATE.md with the session outcome before ending.'; const GSD_COPILOT_SESSION_END_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`; const GSD_COPILOT_SESSION_END_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`; // --------------------------------------------------------------------------- // Cursor hook constants // --------------------------------------------------------------------------- const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js'; const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js'; const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js'; const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js'; const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js'; const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js'; const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; // The full set of Cursor hook events GSD manages — sourced from the adapter // (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary // stays closed and first-party. Used by reconcileCursorHooksJson (the // reconciliation scope is always the full set). The install path // (writeCursorHooksJson) resolves a descriptor-driven subset via // resolveManagedHookEvents(opts.managedHookEvents). const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS; // --------------------------------------------------------------------------- // Cline / AGENTS.md constants // --------------------------------------------------------------------------- const GSD_AGENTS_MD_MARKER = ''; const GSD_AGENTS_MD_CLOSE_MARKER = ''; // --------------------------------------------------------------------------- // Descriptor-driven runtime title lookup (ADR-1239 / #2092) // --------------------------------------------------------------------------- /** * Console-log label for a runtime, sourced from the capability registry's * `title` field (capabilities//capability.json). Folded from a * hardcoded `runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? * 'Claude Code' : runtime` ternary — cosmetic (log text) only, but resolves * to the same 'Qwen Code' / 'Claude Code' values for those two runtimes. * Falls back to the raw runtime id if the registry can't be loaded or the * runtime has no title. */ function _capabilityTitle(runtime: string): string { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const reg = require('./capability-registry.cjs') as { runtimes?: Record; }; return reg?.runtimes?.[runtime]?.title || runtime; } catch { return runtime; } } // --------------------------------------------------------------------------- // atomicWriteFileSync — shared canonical implementation. // // __atomicWrittenTmps is exported so bin/install.js can merge it into its // _cleanTmpFiles() scan, ensuring that atomic writes performed by this // module (Cursor hooks.json, Codex hooks.json shims) participate in the // same temp-file cleanup as writes performed directly by install.js. // // Every temp path written is recorded in the Set so _cleanTmpFiles() can // scope cleanup to files this installer process actually created, avoiding // accidental deletion of unrelated tools' temp files. // --------------------------------------------------------------------------- let __atomicWriteCounter = 0; // Set — absolute paths of .tmp-- files this process created. const __atomicWrittenTmps: Set = new Set(); function atomicWriteFileSync(target: string, data: string, options: fs.WriteFileOptions): void { __atomicWriteCounter += 1; const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`; __atomicWrittenTmps.add(tmp); try { fs.writeFileSync(tmp, data, options); shellCmdProjection.retryRenameSync(tmp, target); // Successful rename: the tmp path no longer exists, but leave it in the // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow // lingers (e.g. a rename succeeded but left a stale entry on some FS). } catch (e) { try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } throw e; } } // --------------------------------------------------------------------------- // CommonJS package.json marker for staged .js hook scripts (#2717) // // Node resolves the nearest package.json walking up from a .js file. When a // runtime's config root (e.g. ~/.cursor, ~/.codeium/windsurf, ~/.codex) — or any // parent — declares {"type":"module"}, Node loads GSD's staged CommonJS hook // scripts as ESM and every require() fails with "require is not defined", // silently disabling that runtime's lifecycle hooks. // // installSharedHooksBundle writes this marker for the 12 runtimes that go // through the shared hooks bundle, but cursor/windsurf (skipSharedHooksInstall) // and codex (the !isCodex gate) stage their .js hooks via the dedicated paths // below and never reached it. These helpers decouple the marker write from the // shared bundle so any code path that stages .js hooks can ensure the marker // lands in the SAME directory as the scripts (#2717). // // The marker content is byte-identical to installSharedHooksBundle's // (bin/install.js installSharedHooksBundle): {"type":"commonjs"}\n. // // #2544: the two helpers below no longer carry their own copy of the write and // remove rules — they DELEGATE to src/commonjs-marker.cts, which #2544 makes the // single place both rules are enforced. Keeping a second copy here was not // merely redundant; the copies had drifted apart on exactly the two properties // that matter: // // - ownership probe: `fs.existsSync` FOLLOWS symlinks and reports `false` for // a DANGLING one, so a dangling `package.json` symlink classified as absent // and the write below followed the link outside the directory GSD owns. // `classifyMarker` uses `lstat` + `isFile()`, so a symlink or a directory at // the marker path is classified `foreign` and left strictly alone. // - create: a plain `writeFileSync` leaves the classify->write window open. // `ensureCommonJsMarker` creates with `flag: 'wx'` (O_EXCL), so anything // that appears at the path in between fails with EEXIST instead of being // followed or overwritten. // // The exported signatures are unchanged (both still return a boolean), so every // caller and the #2717 tests are unaffected. // --------------------------------------------------------------------------- /** * Ensure a `package.json` forcing CommonJS mode exists in `dir` (the directory * holding GSD-staged `.js` hook scripts). Idempotent: a no-op if the marker is * already present with GSD's content. Never clobbers a distinct user-authored * package.json (it leaves such a file in place; the user owns it). * * @param dir - absolute path to the directory holding the staged .js hooks * @returns `true` if GSD's marker is present after the call (written or already there) */ function ensureCommonJsMarker(dir: string): boolean { // 'written' | 'unchanged' -> the marker is ours and present. // 'preserved-foreign' -> a file GSD does not own is there; left untouched. // 'failed' -> environmental (EACCES/EROFS/ENOSPC); best-effort. const outcome = ensureCommonJsMarkerOwned(dir); return outcome === 'written' || outcome === 'unchanged'; } /** * Remove the CommonJS marker from `dir` on uninstall — but ONLY if it carries * GSD's exact marker content. A user-authored package.json is never deleted. * * @param dir - absolute path to the directory that held the staged .js hooks * @returns `true` if a GSD-owned marker was removed */ function removeCommonJsMarkerIfGsdOwned(dir: string): boolean { return removeCommonJsMarkerOwned(dir); } // --------------------------------------------------------------------------- // parseTomlValue + findMultilineBasicStringClose // (needed by rewriteLegacyCodexHookBlock — pure TOML helpers, no state) // --------------------------------------------------------------------------- function findMultilineBasicStringClose(line: string, startIndex: number): number { let i = startIndex; while (i < line.length) { if (line.startsWith('"""', i) && (i === 0 || line[i - 1] !== '\\')) { return i; } i += 1; } return -1; } function parseTomlValue(text: string, i: number): { value: unknown; end: number } { // Skip leading whitespace. while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { i += 1; } if (i >= text.length) { throw new Error('expected value, got end of input'); } const ch = text[i]; // Basic string if (ch === '"') { if (text.startsWith('"""', i)) { const close = findMultilineBasicStringClose(text, i + 3); if (close === -1) { throw new Error('unterminated multi-line basic string'); } const raw = text.slice(i + 3, close); return { value: raw.replace(/^\r?\n/, ''), end: close + 3 }; } let j = i + 1; let out = ''; while (j < text.length) { const c = text[j]; if (c === '\\') { const next = text[j + 1]; if (next === 'n') { out += '\n'; j += 2; continue; } if (next === 't') { out += '\t'; j += 2; continue; } if (next === 'r') { out += '\r'; j += 2; continue; } if (next === '\\') { out += '\\'; j += 2; continue; } if (next === '"') { out += '"'; j += 2; continue; } if (next === '/') { out += '/'; j += 2; continue; } out += next === undefined ? '' : next; j += 2; continue; } if (c === '"') { return { value: out, end: j + 1 }; } out += c; j += 1; } throw new Error('unterminated basic string'); } // Literal string if (ch === '\'') { if (text.startsWith("'''", i)) { const close = text.indexOf("'''", i + 3); if (close === -1) throw new Error('unterminated multi-line literal string'); return { value: text.slice(i + 3, close).replace(/^\r?\n/, ''), end: close + 3 }; } const close = text.indexOf('\'', i + 1); if (close === -1) throw new Error('unterminated literal string'); return { value: text.slice(i + 1, close), end: close + 1 }; } // Boolean if (text.startsWith('true', i)) return { value: true, end: i + 4 }; if (text.startsWith('false', i)) return { value: false, end: i + 5 }; // Number (integer or float, simplified) 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)/); if (numMatch) { const raw = numMatch[0]; const cleaned = raw.replace(/_/g, ''); const num = Number(cleaned); return { value: isNaN(num) ? cleaned : num, end: i + raw.length }; } // Datetime (simplified passthrough) 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})?)?/); if (dtMatch) { return { value: dtMatch[0], end: i + dtMatch[0].length }; } throw new Error(`parseTomlValue: unexpected character '${ch}' at position ${i}`); } // --------------------------------------------------------------------------- // normalizeNodePath / resolveNodeRunner / resolveBashRunner // (needed by buildHookCommand — verbatim from install.js) // --------------------------------------------------------------------------- interface NodeNormOpts { env?: NodeJS.ProcessEnv; existsSync?: (p: string) => boolean; } function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string { if (!execPath) return execPath; const env = (opts && opts.env) || process.env; const existsSync = (opts && opts.existsSync) || fs.existsSync; const normalizedForMatch = shellCmdProjection.posixNormalize(execPath); if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) { const candidates: string[] = []; if (env.FNM_DIR) { candidates.push(`${env.FNM_DIR}/aliases/default/node.exe`); candidates.push(`${env.FNM_DIR}/aliases/default/bin/node`); } if (env.APPDATA) { candidates.push(`${env.APPDATA}/fnm/aliases/default/node.exe`); } for (const candidate of candidates) { if (candidate && existsSync(candidate)) return candidate; } return execPath; } // Homebrew (macOS Intel /usr/local, Apple Silicon /opt/homebrew, Linuxbrew // /home/linuxbrew/.linuxbrew, and any custom HOMEBREW_PREFIX) pins node at // /Cellar/node(<@ver>)?//bin/node, then deletes prior versions on // `brew upgrade node`. Rewrite to the stable /bin/node symlink, which // survives the upgrade. Derive from the path itself (more reliable // than HOMEBREW_PREFIX env — the path IS the install location) so every layout // is covered by one branch instead of one per known prefix (#2185). const homebrewMatch = normalizedForMatch.match( /^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i, ); if (homebrewMatch) { return `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`; } // mise pins a concrete node version at /installs/node//bin/node // (Windows: /installs/node//node.exe). Node realpaths // process.execPath to that versioned path, and `mise up` prunes old versions, // so a baked hook command 404s after any node bump — the same ephemeral-path // failure #977 fixed for fnm. The stable alias is the sibling shim // (/shims/node), which always resolves to the active version, like the // Homebrew symlink survives `brew upgrade node`. Derive from execPath // so a custom MISE_DATA_DIR layout still works, and only rewrite when the shim // exists — otherwise fall back to the raw execPath unchanged. const miseMatch = normalizedForMatch.match( /^(.*)\/installs\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/, ); if (miseMatch) { const shim = `${miseMatch[1]}/shims/node${miseMatch[2] || ''}`; if (existsSync(shim)) return shim; } // volta pins a concrete node image at /tools/image/node//bin/node // (Windows: /tools/image/node//node.exe — volta's own layout // puts node.exe at the image root, no bin/). `volta uninstall node@` prunes // that image, so a baked hook command 404s — the same ephemeral-path failure // #977 fixed for fnm and #1619 for mise. The stable alias is the shim // /bin/node, a symlink to volta-shim that always resolves to the // active pin. Derive from execPath rather than the env so a custom // VOLTA_HOME and the Windows %LOCALAPPDATA%\Volta default both work (#2185's // reasoning), and only rewrite when the shim exists — otherwise fall back to // the raw execPath unchanged. const voltaMatch = normalizedForMatch.match( /^(.*)\/tools\/image\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/, ); if (voltaMatch) { const shim = `${voltaMatch[1]}/bin/node${voltaMatch[2] || ''}`; if (existsSync(shim)) return shim; } return execPath; } function resolveNodeRunner(opts?: NodeNormOpts): string | null { const execPath = typeof process.execPath === 'string' ? process.execPath : ''; if (!execPath) return null; const stablePath = normalizeNodePath(execPath, opts); return JSON.stringify(shellCmdProjection.posixNormalize(stablePath)); } interface BashRunnerOpts { platform?: string; env?: NodeJS.ProcessEnv; existsSync?: (p: string) => boolean; } function resolveBashRunner(opts?: BashRunnerOpts): string | null { const platform = (opts && opts.platform) || process.platform; if (platform !== 'win32') return 'bash'; const env = (opts && opts.env) || process.env; const exists = (opts && opts.existsSync) || fs.existsSync; const candidates: string[] = []; if (env.GSD_BASH_PATH) candidates.push(env.GSD_BASH_PATH); if (env.ProgramFiles) candidates.push(path.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe')); if (env['ProgramFiles(x86)']) candidates.push(path.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe')); if (env.SystemDrive) { candidates.push(path.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe')); candidates.push(path.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe')); } for (const candidate of candidates) { if (candidate && exists(candidate)) { return JSON.stringify(shellCmdProjection.posixNormalize(candidate)); } } return null; } // --------------------------------------------------------------------------- // Shared: rewriteLegacyManagedNodeHookCommands // --------------------------------------------------------------------------- interface HookEntry { command?: string; args?: unknown[]; } interface HookGroup { hooks?: HookEntry[]; } interface SettingsHooks { [event: string]: HookGroup[]; } interface Settings { hooks?: SettingsHooks; } interface RewriteOpts { platform?: string; runtime?: string; } function rewriteLegacyManagedNodeHookCommands(settings: Settings, absoluteRunner: string, opts?: RewriteOpts): boolean { if (!settings || !settings.hooks || !absoluteRunner) return false; if (!opts) opts = {}; const platform = opts.platform || process.platform; let changed = false; for (const entries of Object.values(settings.hooks)) { if (!Array.isArray(entries)) continue; for (const entry of entries) { if (!entry || !Array.isArray(entry.hooks)) continue; for (const h of entry.hooks) { if (!h || typeof h.command !== 'string') continue; if (Array.isArray(h.args) && h.args.length > 0) continue; let trimmed = h.command.trim(); const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed); if (hadPowerShellCallOperator) { trimmed = trimmed.replace(/^&\s+/, '').trim(); } const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) || trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/); if (!m) continue; let _runnerToken: string, scriptToken: string, scriptPath: string; if (/^node\s+/.test(trimmed)) { _runnerToken = 'node'; scriptToken = m[1]; scriptPath = m[2] || m[3] || m[4] || ''; } else { _runnerToken = m[1]; const runnerPath = shellCmdProjection.posixNormalize(m[2] || m[3] || m[4] || ''); const stableRunner = normalizeNodePath(runnerPath); if (stableRunner === runnerPath && platform !== 'win32') continue; scriptToken = m[5]; scriptPath = m[6] || m[7] || m[8] || ''; } if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue; const projectedCommand = projectLegacySettingsHookCommand({ absoluteRunner, scriptPath, scriptToken, runtime: opts.runtime || 'generic', platform, }); if (!projectedCommand) continue; if (h.command === projectedCommand) continue; h.command = projectedCommand; changed = true; } } } return changed; } // --------------------------------------------------------------------------- // Shared: reconcileManagedShellHookCommands (#3329) // --------------------------------------------------------------------------- /** * Rewrite already-registered managed `.sh` hook `command` strings to the shape * the current installer would generate (#3329). * * applySettingsJsonHooks registers the four `.sh` managed hooks only-if-absent, * so an entry registered by an older installer keeps its old command forever — * `/gsd-update` (which re-invokes the installer) never re-derived it. On * Claude/win32 that left the pre-#580/#3393 bash-runner-prefixed commands * (`bash "