diff --git a/bin/install.js b/bin/install.js index a1004efa9..cfa1e10fb 100755 --- a/bin/install.js +++ b/bin/install.js @@ -627,12 +627,19 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost return `${resolvedTarget}/`; } -// normalizeNodePath, resolveNodeRunner, resolveBashRunner are now owned by -// the runtime-hooks-surface module. Import them here so install.js callers -// continue to work and so there is a single implementation of these helpers. +// normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are +// now owned by the runtime-hooks-surface module. Import them here so +// install.js callers continue to work and so there is a single implementation +// of these helpers. const normalizeNodePath = hooksSurface.normalizeNodePath; const resolveNodeRunner = hooksSurface.resolveNodeRunner; const resolveBashRunner = hooksSurface.resolveBashRunner; +// referencesHook: pure predicate over hook entry objects, shared between +// install() and finishInstall() (ADR-857 phase 5f-1b). +const referencesHook = hooksSurface.referencesHook; +// applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed +// hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b). +const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks; function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) { return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts); @@ -11089,479 +11096,24 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } - // Helper: detect whether a hook entry references a managed hook by name. - // Checks all three registration shapes: - // • plain command string (standard form) - // • args array (command+args / wrapped-launcher form used by windowless - // launchers on Windows and some custom PATH-less environments) (#976) - // • url field (type:"http" local-server routing form) (#1004) - // Without covering all three, an http-form or args-form entry is invisible - // and a stock string-command entry is appended on every install/update, - // running the hook twice. - function referencesHook(h, hookName) { - return (typeof h.command === 'string' && h.command.includes(hookName)) || - (Array.isArray(h.args) && h.args.some(a => typeof a === 'string' && a.includes(hookName))) || - (typeof h.url === 'string' && h.url.includes(hookName)); - } - - // Configure SessionStart hook for update checking (skip for opencode) - if (!isOpencode && !isKilo) { - if (!settings.hooks) { - settings.hooks = {}; - } - if (!settings.hooks.SessionStart) { - settings.hooks.SessionStart = []; - } - - const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-check-update')) - ); - - // Guard: only register if the hook file was actually installed (#1754). - // When hooks/dist/ is missing from the npm package (as in v1.32.0), the - // copy step produces no files but the registration step ran unconditionally, - // causing "hook error" on every tool invocation. - const checkUpdateFile = path.join(targetDir, 'hooks', 'gsd-check-update.js'); - if (!hasGsdUpdateHook && fs.existsSync(checkUpdateFile) && updateCheckCommand) { - settings.hooks.SessionStart.push({ - hooks: [ - { - type: 'command', - command: updateCheckCommand - } - ] - }); - console.log(` ${green}✓${reset} Configured update check hook`); - } else if (!hasGsdUpdateHook && !fs.existsSync(checkUpdateFile)) { - console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`); - } - - // Configure post-tool hook for context window monitoring - if (!settings.hooks[postToolEvent]) { - settings.hooks[postToolEvent] = []; - } - - const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor')) - ); - - const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js'); - if (!hasContextMonitorHook && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { - settings.hooks[postToolEvent].push({ - matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', - hooks: [ - { - type: 'command', - command: contextMonitorCommand, - timeout: 10 - } - ] - }); - console.log(` ${green}✓${reset} Configured context window monitor hook`); - } else if (!hasContextMonitorHook && !fs.existsSync(contextMonitorFile)) { - console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`); - } else { - // Migrate existing context monitor hooks: add matcher and timeout if missing - for (const entry of settings.hooks[postToolEvent]) { - if (entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))) { - let migrated = false; - if (!entry.matcher) { - entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task'; - migrated = true; - } - for (const h of entry.hooks) { - if (referencesHook(h, 'gsd-context-monitor') && !h.timeout) { - h.timeout = 10; - migrated = true; - } - } - if (migrated) { - console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`); - } - } - } - } - - // Configure PreToolUse hook for prompt injection detection - // Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks - const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse'; - if (!settings.hooks[preToolEvent]) { - settings.hooks[preToolEvent] = []; - } - - const hasPromptGuardHook = settings.hooks[preToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-prompt-guard')) - ); - - const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js'); - if (!hasPromptGuardHook && fs.existsSync(promptGuardFile) && promptGuardCommand) { - settings.hooks[preToolEvent].push({ - matcher: 'Write|Edit', - hooks: [ - { - type: 'command', - command: promptGuardCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured prompt injection guard hook`); - } else if (!hasPromptGuardHook && !fs.existsSync(promptGuardFile)) { - console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`); - } - - // Configure PreToolUse hook for read-before-edit guidance (#1628) - // Prevents infinite retry loops when non-Claude models attempt to edit - // files without reading them first. Advisory-only — does not block. - const hasReadGuardHook = settings.hooks[preToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-guard')) - ); - - const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js'); - if (!hasReadGuardHook && fs.existsSync(readGuardFile) && readGuardCommand) { - settings.hooks[preToolEvent].push({ - matcher: 'Write|Edit', - hooks: [ - { - type: 'command', - command: readGuardCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured read-before-edit guard hook`); - } else if (!hasReadGuardHook && !fs.existsSync(readGuardFile)) { - console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`); - } - - // Configure PostToolUse hook for read-time prompt injection scanning (#2201) - // Scans content returned by the Read tool for injection patterns, including - // summarisation-specific patterns that survive context compression. - const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-injection-scanner')) - ); - - const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js'); - if (!hasReadInjectionScannerHook && fs.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) { - settings.hooks[postToolEvent].push({ - matcher: 'Read', - hooks: [ - { - type: 'command', - command: readInjectionScannerCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured read injection scanner hook`); - } else if (!hasReadInjectionScannerHook && !fs.existsSync(readInjectionScannerFile)) { - console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`); - } - - // Community hooks — registered on install but opt-in at runtime. - // Each hook checks .planning/config.json for hooks.community: true - // and exits silently (no-op) if not enabled. This lets users enable - // them per-project by adding: "hooks": { "community": true } - - // Configure workflow guard hook (opt-in via hooks.workflow_guard: true) - // Detects file edits outside GSD workflow context and advises using - // /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks - // unsafe Bash commands that violate worktree-agent isolation. - const workflowGuardCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts) - : localCmd('gsd-workflow-guard.js'); - const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit'; - const workflowGuardHookEntry = settings.hooks[preToolEvent].find(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-workflow-guard')) - ); - const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry); - - const workflowGuardFile = path.join(targetDir, 'hooks', 'gsd-workflow-guard.js'); - if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) { - workflowGuardHookEntry.matcher = workflowGuardMatcher; - console.log(` ${green}✓${reset} Updated workflow guard hook matcher`); - } else if (!hasWorkflowGuardHook && fs.existsSync(workflowGuardFile) && workflowGuardCommand) { - settings.hooks[preToolEvent].push({ - matcher: workflowGuardMatcher, - hooks: [ - { - type: 'command', - command: workflowGuardCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`); - } else if (!hasWorkflowGuardHook && !fs.existsSync(workflowGuardFile)) { - console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`); - } - - // Configure PreToolUse hook for worktree absolute-path safety (#260) - // Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve - // outside the current worktree root. Prevents executor agents from - // accidentally writing to the main checkout when running in isolation="worktree". - const worktreePathGuardCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts) - : localCmd('gsd-worktree-path-guard.js'); - const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-worktree-path-guard')) - ); - const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js'); - if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) { - settings.hooks[preToolEvent].push({ - matcher: 'Write|Edit|MultiEdit', - hooks: [ - { - type: 'command', - command: worktreePathGuardCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured worktree path guard hook`); - } else if (!hasWorktreePathGuardHook && !fs.existsSync(worktreePathGuardFile)) { - console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`); - } - - // Configure commit validation hook (Conventional Commits enforcement, opt-in) - const validateCommitCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts) - : localShellCmd('gsd-validate-commit.sh'); - const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-validate-commit')) - ); - // Guard: only register if the .sh file was actually installed. If the npm package - // omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook - // causes a hook error on every Bash tool invocation. - const validateCommitFile = path.join(targetDir, 'hooks', 'gsd-validate-commit.sh'); - if (!hasValidateCommitHook && fs.existsSync(validateCommitFile) && validateCommitCommand) { - settings.hooks[preToolEvent].push({ - matcher: 'Bash', - hooks: [ - { - type: 'command', - command: validateCommitCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`); - } else if (!hasValidateCommitHook && !fs.existsSync(validateCommitFile)) { - console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`); - } else if (!hasValidateCommitHook && !validateCommitCommand) { - console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`); - } - - // Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347). - // PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick - // on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless - // .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true. - const graphifyUpdateCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts) - : localShellCmd('gsd-graphify-update.sh'); - const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-graphify-update')) - ); - const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh'); - if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) { - settings.hooks[postToolEvent].push({ - matcher: 'Bash', - hooks: [ - { - type: 'command', - command: graphifyUpdateCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`); - } else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) { - console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`); - } else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) { - console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`); - } - - // Configure session state orientation hook (opt-in) - const sessionStateCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts) - : localShellCmd('gsd-session-state.sh'); - const hasSessionStateHook = settings.hooks.SessionStart.some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-session-state')) - ); - const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh'); - if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) { - settings.hooks.SessionStart.push({ - hooks: [ - { - type: 'command', - command: sessionStateCommand - } - ] - }); - console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`); - } else if (!hasSessionStateHook && !fs.existsSync(sessionStateFile)) { - console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`); - } else if (!hasSessionStateHook && !sessionStateCommand) { - console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`); - } - - // Configure phase boundary detection hook (opt-in) - const phaseBoundaryCommand = isGlobal - ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts) - : localShellCmd('gsd-phase-boundary.sh'); - const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-phase-boundary')) - ); - const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh'); - if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) { - settings.hooks[postToolEvent].push({ - matcher: 'Write|Edit', - hooks: [ - { - type: 'command', - command: phaseBoundaryCommand, - timeout: 5 - } - ] - }); - console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`); - } else if (!hasPhaseBoundaryHook && !fs.existsSync(phaseBoundaryFile)) { - console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`); - } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) { - console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`); - } - - // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ── - // Claude Code (since #770) and Qwen Code (since #788) both support these - // three lifecycle events. Wire gsd-context-monitor so agents get context- - // headroom warnings at subagent completion, model stop, and pre-compaction - // (the most critical moment to surface headroom info). - // - // SubagentStop — subagent lifecycle completion (context headroom tracking) - // Stop — model stop / final-response moment (context headroom) - // PreCompact — fires before conversation compaction (most critical - // moment to surface context headroom warnings) - // - // Note: UserPromptSubmit is NOT wired here. That event carries the raw - // user prompt text, not a tool invocation, so gsd-prompt-guard (which - // exits unless tool_name is Write/Edit) would be a silent no-op. A - // dedicated handler for UserPromptSubmit is deferred to a follow-on issue. - if (isQwen || runtime === 'claude') { - const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code'; - // SubagentStop, Stop, PreCompact — route through the context monitor. - for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { - if (!settings.hooks[event]) { - settings.hooks[event] = []; - } - const alreadyHasContextMonitor = settings.hooks[event].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor')) - ); - if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { - settings.hooks[event].push({ - hooks: [ - { - type: 'command', - command: contextMonitorCommand, - timeout: 10 - } - ] - }); - console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`); - } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { - console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`); - } - } - } - // ── end SubagentStop / Stop / PreCompact events ──────────────────────────── - - // ── Gemini-only extended hook events (#776) ─────────────────────────────── - // Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that - // gsd previously did not register. Three high-value events are added here: - // - // BeforeAgent — fires after user submits a prompt, before the agent - // plans. Wire gsd-context-monitor for context headroom - // awareness at prompt time. - // AfterAgent — fires once per turn after the model generates its final - // response. Wire gsd-context-monitor to track headroom - // after each agent turn completes. - // BeforeModel — fires before each LLM call (per-turn, not per-session). - // Wire gsd-context-monitor for per-turn context injection - // — more precise than session-start-only injection. - // - // All three reuse gsd-context-monitor.js — no new hook files needed. - // The `decision:"deny"` retry capability of AfterAgent is intentionally - // left to the hook script to implement when triggered (gsd-context-monitor - // exits 0 / advisory-only today; an active quality gate is a follow-on). - // - // Note: BeforeToolSelection is NOT wired. That event does not map to a - // gsd hook use case at this time; deferred to a follow-on issue. - // - // Guard: isGemini is defined at the top of install() (line ~8696). - if (isGemini) { - for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { - if (!Array.isArray(settings.hooks[geminiEvent])) { - settings.hooks[geminiEvent] = []; - } - const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor')) - ); - if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { - settings.hooks[geminiEvent].push({ - hooks: [ - { - type: 'command', - command: contextMonitorCommand, - timeout: 10 - } - ] - }); - console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`); - } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { - console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`); - } - } - } - // ── end Gemini-only extended hook events ────────────────────────────────── - - // ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─ - // Claude Code fires FileChanged when a watched file changes on disk. Wire - // gsd-config-reload.js to reload the gsd config context whenever the user - // edits .planning/config.json mid-session, eliminating the need to restart. - // - // The matcher "config.json" watches for changes to any file named config.json - // (Claude Code matches by filename, not full path). The hook exits silently - // when the changed file is not the gsd config. - // - // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet - // verified; extend in a follow-on if empirically confirmed. - if (runtime === 'claude') { - if (!settings.hooks.FileChanged) { - settings.hooks.FileChanged = []; - } - const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js'); - const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry => - entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-config-reload')) - ); - if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) { - settings.hooks.FileChanged.push({ - matcher: 'config.json', - hooks: [ - { - type: 'command', - command: configReloadCommand, - timeout: 8 - } - ] - }); - console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`); - } else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) { - console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`); - } else if (!alreadyHasConfigReload && !configReloadCommand) { - console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`); - } - } - // ── end FileChanged hook ──────────────────────────────────────────────────── - } + // Register all GSD-managed hook entries into settings.hooks.* for runtimes + // that use the settings.json hook surface (ADR-857 phase 5f-1b). + // settings is mutated in place by applySettingsJsonHooks. + applySettingsJsonHooks(settings, { + runtime, + isGlobal, + targetDir, + postToolEvent, + updateCheckCommand, + contextMonitorCommand, + promptGuardCommand, + readGuardCommand, + readInjectionScannerCommand, + configReloadCommand, + hookOpts, + localCmd, + localShellCmd, + }); // ── Gemini hooksConfig.enabled check (#776) ─────────────────────────────── // Detect `hooksConfig.enabled: false` in the already-loaded settings object @@ -12614,6 +12166,8 @@ module.exports = { buildHookCommand, normalizeNodePath, resolveNodeRunner, + referencesHook, + applySettingsJsonHooks, rewriteLegacyManagedNodeHookCommands, buildCodexHookBlock, rewriteLegacyCodexHookBlock, diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index c92302c0b..1fc1a960e 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -1084,6 +1084,568 @@ function writeCopilotHookConfig(targetDir: string): string { return hookPath; } +// --------------------------------------------------------------------------- +// applySettingsJsonHooks +// +// MUTATES `settings` by reference — registers all GSD-managed hook entries +// into settings.hooks.* for runtimes that use a settings.json hook surface +// (Claude Code, Gemini, Antigravity, Qwen Code, and others). +// Skipped entirely for opencode and kilo (which have their own hook surface). +// +// Extracted from the `if (!isOpencode && !isKilo) { … }` block inside +// install() (ADR-857 phase 5f-1b). No behavior change — all guards, +// event-name dialect branches, and idempotency checks are preserved verbatim. +// +// @param settings - The settings object already read from disk. Mutated in place. +// @param opts - Closure values the block read from install()'s scope. +// runtime - runtime ID string (e.g. 'claude', 'gemini', 'qwen') +// isGlobal - true for global installs +// targetDir - absolute path to the runtime config dir +// postToolEvent - 'PostToolUse' | 'AfterTool' (pre-computed by caller) +// updateCheckCommand - command string or null +// contextMonitorCommand - command string or null +// promptGuardCommand - command string or null +// readGuardCommand - command string or null +// readInjectionScannerCommand - command string or null +// configReloadCommand - command string or null +// hookOpts - { portableHooks, runtime } passed to buildHookCommand +// localCmd - (hookFile: string) => string|null +// localShellCmd - (hookFile: string) => string|null +// --------------------------------------------------------------------------- + +interface ApplySettingsJsonHooksOpts { + runtime: string; + isGlobal: boolean; + targetDir: string; + postToolEvent: string; + updateCheckCommand: string | null; + contextMonitorCommand: string | null; + promptGuardCommand: string | null; + readGuardCommand: string | null; + readInjectionScannerCommand: string | null; + configReloadCommand: string | null; + hookOpts: BuildHookCommandOpts; + localCmd: (hookFile: string) => string | null; + localShellCmd: (hookFile: string) => string | null; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts): void { + /* eslint-disable @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-assignment */ + const { + runtime, + isGlobal, + targetDir, + postToolEvent, + updateCheckCommand, + contextMonitorCommand, + promptGuardCommand, + readGuardCommand, + readInjectionScannerCommand, + configReloadCommand, + hookOpts, + localCmd, + localShellCmd, + } = opts; + + // Derived from runtime — same as install() top-of-function declarations. + const isOpencode = runtime === 'opencode'; + const isKilo = runtime === 'kilo'; + const isGemini = runtime === 'gemini'; + const isQwen = runtime === 'qwen'; + + // Configure SessionStart hook for update checking (skip for opencode / kilo) + if (!isOpencode && !isKilo) { + if (!settings.hooks) { + settings.hooks = {}; + } + if (!settings.hooks.SessionStart) { + settings.hooks.SessionStart = []; + } + + const hasGsdUpdateHook = settings.hooks.SessionStart.some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-check-update')) + ); + + // Guard: only register if the hook file was actually installed (#1754). + // When hooks/dist/ is missing from the npm package (as in v1.32.0), the + // copy step produces no files but the registration step ran unconditionally, + // causing "hook error" on every tool invocation. + const checkUpdateFile = path.join(targetDir, 'hooks', 'gsd-check-update.js'); + if (!hasGsdUpdateHook && fs.existsSync(checkUpdateFile) && updateCheckCommand) { + settings.hooks.SessionStart.push({ + hooks: [ + { + type: 'command', + command: updateCheckCommand + } + ] + }); + console.log(` ${green}✓${reset} Configured update check hook`); + } else if (!hasGsdUpdateHook && !fs.existsSync(checkUpdateFile)) { + console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`); + } + + // Configure post-tool hook for context window monitoring + if (!settings.hooks[postToolEvent]) { + settings.hooks[postToolEvent] = []; + } + + const hasContextMonitorHook = settings.hooks[postToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-context-monitor')) + ); + + const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js'); + if (!hasContextMonitorHook && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { + settings.hooks[postToolEvent].push({ + matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', + hooks: [ + { + type: 'command', + command: contextMonitorCommand, + timeout: 10 + } + ] + }); + console.log(` ${green}✓${reset} Configured context window monitor hook`); + } else if (!hasContextMonitorHook && !fs.existsSync(contextMonitorFile)) { + console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`); + } else { + // Migrate existing context monitor hooks: add matcher and timeout if missing + for (const entry of settings.hooks[postToolEvent]) { + if (entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-context-monitor'))) { + let migrated = false; + if (!entry.matcher) { + entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task'; + migrated = true; + } + for (const h of entry.hooks) { + if (referencesHook(h as Record, 'gsd-context-monitor') && !h.timeout) { + h.timeout = 10; + migrated = true; + } + } + if (migrated) { + console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`); + } + } + } + } + + // Configure PreToolUse hook for prompt injection detection + // Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks + const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse'; + if (!settings.hooks[preToolEvent]) { + settings.hooks[preToolEvent] = []; + } + + const hasPromptGuardHook = settings.hooks[preToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-prompt-guard')) + ); + + const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js'); + if (!hasPromptGuardHook && fs.existsSync(promptGuardFile) && promptGuardCommand) { + settings.hooks[preToolEvent].push({ + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: promptGuardCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured prompt injection guard hook`); + } else if (!hasPromptGuardHook && !fs.existsSync(promptGuardFile)) { + console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`); + } + + // Configure PreToolUse hook for read-before-edit guidance (#1628) + // Prevents infinite retry loops when non-Claude models attempt to edit + // files without reading them first. Advisory-only — does not block. + const hasReadGuardHook = settings.hooks[preToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-read-guard')) + ); + + const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js'); + if (!hasReadGuardHook && fs.existsSync(readGuardFile) && readGuardCommand) { + settings.hooks[preToolEvent].push({ + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: readGuardCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured read-before-edit guard hook`); + } else if (!hasReadGuardHook && !fs.existsSync(readGuardFile)) { + console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`); + } + + // Configure PostToolUse hook for read-time prompt injection scanning (#2201) + // Scans content returned by the Read tool for injection patterns, including + // summarisation-specific patterns that survive context compression. + const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-read-injection-scanner')) + ); + + const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js'); + if (!hasReadInjectionScannerHook && fs.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) { + settings.hooks[postToolEvent].push({ + matcher: 'Read', + hooks: [ + { + type: 'command', + command: readInjectionScannerCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured read injection scanner hook`); + } else if (!hasReadInjectionScannerHook && !fs.existsSync(readInjectionScannerFile)) { + console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`); + } + + // Community hooks — registered on install but opt-in at runtime. + // Each hook checks .planning/config.json for hooks.community: true + // and exits silently (no-op) if not enabled. This lets users enable + // them per-project by adding: "hooks": { "community": true } + + // Configure workflow guard hook (opt-in via hooks.workflow_guard: true) + // Detects file edits outside GSD workflow context and advises using + // /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks + // unsafe Bash commands that violate worktree-agent isolation. + const workflowGuardCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts) + : localCmd('gsd-workflow-guard.js'); + const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit'; + const workflowGuardHookEntry = settings.hooks[preToolEvent].find((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-workflow-guard')) + ); + const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry); + + const workflowGuardFile = path.join(targetDir, 'hooks', 'gsd-workflow-guard.js'); + if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) { + workflowGuardHookEntry.matcher = workflowGuardMatcher; + console.log(` ${green}✓${reset} Updated workflow guard hook matcher`); + } else if (!hasWorkflowGuardHook && fs.existsSync(workflowGuardFile) && workflowGuardCommand) { + settings.hooks[preToolEvent].push({ + matcher: workflowGuardMatcher, + hooks: [ + { + type: 'command', + command: workflowGuardCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`); + } else if (!hasWorkflowGuardHook && !fs.existsSync(workflowGuardFile)) { + console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`); + } + + // Configure PreToolUse hook for worktree absolute-path safety (#260) + // Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve + // outside the current worktree root. Prevents executor agents from + // accidentally writing to the main checkout when running in isolation="worktree". + const worktreePathGuardCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts) + : localCmd('gsd-worktree-path-guard.js'); + const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-worktree-path-guard')) + ); + const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js'); + if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) { + settings.hooks[preToolEvent].push({ + matcher: 'Write|Edit|MultiEdit', + hooks: [ + { + type: 'command', + command: worktreePathGuardCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured worktree path guard hook`); + } else if (!hasWorktreePathGuardHook && !fs.existsSync(worktreePathGuardFile)) { + console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`); + } + + // Configure commit validation hook (Conventional Commits enforcement, opt-in) + const validateCommitCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts) + : localShellCmd('gsd-validate-commit.sh'); + const hasValidateCommitHook = settings.hooks[preToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-validate-commit')) + ); + // Guard: only register if the .sh file was actually installed. If the npm package + // omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook + // causes a hook error on every Bash tool invocation. + const validateCommitFile = path.join(targetDir, 'hooks', 'gsd-validate-commit.sh'); + if (!hasValidateCommitHook && fs.existsSync(validateCommitFile) && validateCommitCommand) { + settings.hooks[preToolEvent].push({ + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: validateCommitCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`); + } else if (!hasValidateCommitHook && !fs.existsSync(validateCommitFile)) { + console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`); + } else if (!hasValidateCommitHook && !validateCommitCommand) { + console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`); + } + + // Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347). + // PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick + // on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless + // .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true. + const graphifyUpdateCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts) + : localShellCmd('gsd-graphify-update.sh'); + const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-graphify-update')) + ); + const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh'); + if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) { + settings.hooks[postToolEvent].push({ + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: graphifyUpdateCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`); + } else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) { + console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`); + } else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) { + console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`); + } + + // Configure session state orientation hook (opt-in) + const sessionStateCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts) + : localShellCmd('gsd-session-state.sh'); + const hasSessionStateHook = settings.hooks.SessionStart.some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-session-state')) + ); + const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh'); + if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) { + settings.hooks.SessionStart.push({ + hooks: [ + { + type: 'command', + command: sessionStateCommand + } + ] + }); + console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`); + } else if (!hasSessionStateHook && !fs.existsSync(sessionStateFile)) { + console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`); + } else if (!hasSessionStateHook && !sessionStateCommand) { + console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`); + } + + // Configure phase boundary detection hook (opt-in) + const phaseBoundaryCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts) + : localShellCmd('gsd-phase-boundary.sh'); + const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-phase-boundary')) + ); + const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh'); + if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) { + settings.hooks[postToolEvent].push({ + matcher: 'Write|Edit', + hooks: [ + { + type: 'command', + command: phaseBoundaryCommand, + timeout: 5 + } + ] + }); + console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`); + } else if (!hasPhaseBoundaryHook && !fs.existsSync(phaseBoundaryFile)) { + console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`); + } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) { + console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`); + } + + // ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ── + // Claude Code (since #770) and Qwen Code (since #788) both support these + // three lifecycle events. Wire gsd-context-monitor so agents get context- + // headroom warnings at subagent completion, model stop, and pre-compaction + // (the most critical moment to surface headroom info). + // + // SubagentStop — subagent lifecycle completion (context headroom tracking) + // Stop — model stop / final-response moment (context headroom) + // PreCompact — fires before conversation compaction (most critical + // moment to surface context headroom warnings) + // + // Note: UserPromptSubmit is NOT wired here. That event carries the raw + // user prompt text, not a tool invocation, so gsd-prompt-guard (which + // exits unless tool_name is Write/Edit) would be a silent no-op. A + // dedicated handler for UserPromptSubmit is deferred to a follow-on issue. + if (isQwen || runtime === 'claude') { + const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code'; + // SubagentStop, Stop, PreCompact — route through the context monitor. + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + if (!settings.hooks[event]) { + settings.hooks[event] = []; + } + const alreadyHasContextMonitor = settings.hooks[event].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-context-monitor')) + ); + if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { + settings.hooks[event].push({ + hooks: [ + { + type: 'command', + command: contextMonitorCommand, + timeout: 10 + } + ] + }); + console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`); + } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { + console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`); + } + } + } + // ── end SubagentStop / Stop / PreCompact events ──────────────────────────── + + // ── Gemini-only extended hook events (#776) ─────────────────────────────── + // Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that + // gsd previously did not register. Three high-value events are added here: + // + // BeforeAgent — fires after user submits a prompt, before the agent + // plans. Wire gsd-context-monitor for context headroom + // awareness at prompt time. + // AfterAgent — fires once per turn after the model generates its final + // response. Wire gsd-context-monitor to track headroom + // after each agent turn completes. + // BeforeModel — fires before each LLM call (per-turn, not per-session). + // Wire gsd-context-monitor for per-turn context injection + // — more precise than session-start-only injection. + // + // All three reuse gsd-context-monitor.js — no new hook files needed. + // The `decision:"deny"` retry capability of AfterAgent is intentionally + // left to the hook script to implement when triggered (gsd-context-monitor + // exits 0 / advisory-only today; an active quality gate is a follow-on). + // + // Note: BeforeToolSelection is NOT wired. That event does not map to a + // gsd hook use case at this time; deferred to a follow-on issue. + // + // Guard: isGemini is derived from runtime at the top of this function. + if (isGemini) { + for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + if (!Array.isArray(settings.hooks[geminiEvent])) { + settings.hooks[geminiEvent] = []; + } + const alreadyHasContextMonitor = settings.hooks[geminiEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-context-monitor')) + ); + if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { + settings.hooks[geminiEvent].push({ + hooks: [ + { + type: 'command', + command: contextMonitorCommand, + timeout: 10 + } + ] + }); + console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`); + } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { + console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`); + } + } + } + // ── end Gemini-only extended hook events ────────────────────────────────── + + // ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─ + // Claude Code fires FileChanged when a watched file changes on disk. Wire + // gsd-config-reload.js to reload the gsd config context whenever the user + // edits .planning/config.json mid-session, eliminating the need to restart. + // + // The matcher "config.json" watches for changes to any file named config.json + // (Claude Code matches by filename, not full path). The hook exits silently + // when the changed file is not the gsd config. + // + // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet + // verified; extend in a follow-on if empirically confirmed. + if (runtime === 'claude') { + if (!settings.hooks.FileChanged) { + settings.hooks.FileChanged = []; + } + const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js'); + const alreadyHasConfigReload = settings.hooks.FileChanged.some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-config-reload')) + ); + if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) { + settings.hooks.FileChanged.push({ + matcher: 'config.json', + hooks: [ + { + type: 'command', + command: configReloadCommand, + timeout: 8 + } + ] + }); + console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`); + } else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) { + console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`); + } else if (!alreadyHasConfigReload && !configReloadCommand) { + console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`); + } + } + // ── end FileChanged hook ──────────────────────────────────────────────────── + } + /* eslint-enable @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-assignment */ +} + +// --------------------------------------------------------------------------- +// referencesHook +// +// Pure predicate — checks whether a hook entry object references a managed +// hook by name. Covers all three registration shapes used by GSD: +// • plain command string (standard form) +// • args array (command+args / wrapped-launcher form used by windowless +// launchers on Windows and some custom PATH-less environments) (#976) +// • url field (type:"http" local-server routing form) (#1004) +// Without covering all three, an http-form or args-form entry is invisible +// and a stock string-command entry is appended on every install/update, +// running the hook twice. +// +// Originally declared inside install()/finishInstall() as a local function; +// promoted here so applySettingsJsonHooks() and finishInstall() share one +// copy (ADR-857 phase 5f-1b). +// --------------------------------------------------------------------------- + +function referencesHook(h: Record, hookName: string): boolean { + const cmd = h['command']; + const args = h['args']; + const url = h['url']; + return (typeof cmd === 'string' && cmd.includes(hookName)) || + (Array.isArray(args) && args.some(a => typeof a === 'string' && a.includes(hookName))) || + (typeof url === 'string' && url.includes(hookName)); +} + // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- @@ -1128,6 +1690,8 @@ export = { // Shared buildHookCommand, + applySettingsJsonHooks, + referencesHook, rewriteLegacyManagedNodeHookCommands, normalizeNodePath, resolveNodeRunner, diff --git a/tests/bug-1754-js-hook-guard.test.cjs b/tests/bug-1754-js-hook-guard.test.cjs index b403db4fb..d9b76cbb4 100644 --- a/tests/bug-1754-js-hook-guard.test.cjs +++ b/tests/bug-1754-js-hook-guard.test.cjs @@ -20,6 +20,8 @@ const fs = require('fs'); const path = require('path'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. +const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); const JS_HOOKS = [ { name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' }, @@ -34,7 +36,12 @@ describe('bug #1754: .js hook registration guards', () => { let src; before(() => { - src = fs.readFileSync(INSTALL_SRC, 'utf-8'); + // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. + // Concatenate both sources so structural assertions find patterns in either file. + const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); + let hooksSurfaceSrc = ''; + try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } + src = installSrc + '\n' + hooksSurfaceSrc; }); for (const { name, registrationAnchor } of JS_HOOKS) { @@ -80,10 +87,9 @@ describe('bug #1754: .js hook registration guards', () => { // Count existsSync calls in the hook registration section. // There should be guards for all JS hooks plus the existing SH hooks. // This test ensures new hooks added in the future follow the same pattern. - const registrationSection = src.slice( - src.indexOf('// Configure SessionStart hook'), - src.indexOf('return { settingsPath, settings, statuslineCommand') - ); + // ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the + // full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing. + const registrationSection = src; // Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js')) const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []); diff --git a/tests/bug-1817-sh-hook-guard.test.cjs b/tests/bug-1817-sh-hook-guard.test.cjs index 4378a8923..48f58d2bb 100644 --- a/tests/bug-1817-sh-hook-guard.test.cjs +++ b/tests/bug-1817-sh-hook-guard.test.cjs @@ -20,6 +20,8 @@ const fs = require('fs'); const path = require('path'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. +const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); const SH_HOOKS = [ { name: 'gsd-validate-commit.sh', settingsVar: 'validateCommitCommand' }, @@ -31,8 +33,13 @@ describe('bug #1817: .sh hook registration guards', () => { let src; // Read once — all tests in this suite share the same source snapshot. + // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. + // Concatenate both sources so structural assertions find patterns in either file. try { - src = fs.readFileSync(INSTALL_SRC, 'utf-8'); + const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); + let hooksSurfaceSrc = ''; + try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } + src = installSrc + '\n' + hooksSurfaceSrc; } catch { src = ''; } diff --git a/tests/bug-260-worktree-path-guard.test.cjs b/tests/bug-260-worktree-path-guard.test.cjs index 02ec3cd2b..8cb9f1212 100644 --- a/tests/bug-260-worktree-path-guard.test.cjs +++ b/tests/bug-260-worktree-path-guard.test.cjs @@ -28,6 +28,8 @@ const { cleanup } = require('./helpers.cjs'); const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-worktree-path-guard.js'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. +const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); /** * Resolve symlinks in a path so that we compare the same canonical form @@ -384,7 +386,12 @@ describe('install.js guard for gsd-worktree-path-guard.js', () => { let src; before(() => { - src = fs.readFileSync(INSTALL_SRC, 'utf-8'); + // ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts. + // Concatenate both sources so structural assertions find patterns in either file. + const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); + let hooksSurfaceSrc = ''; + try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } + src = installSrc + '\n' + hooksSurfaceSrc; }); test('install.js has hasWorktreePathGuardHook variable', () => { diff --git a/tests/sh-hook-paths.test.cjs b/tests/sh-hook-paths.test.cjs index 16d712316..cd479188d 100644 --- a/tests/sh-hook-paths.test.cjs +++ b/tests/sh-hook-paths.test.cjs @@ -28,6 +28,10 @@ const fs = require('fs'); const path = require('path'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +// ADR-857 phase 5f-1b: the sh-hook command-construction code moved from install.js +// into applySettingsJsonHooks in src/runtime-hooks-surface.cts. Source-scan checks +// that reference commandVar patterns must target the new canonical source. +const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); // buildHookCommand was extracted to gsd-core/bin/lib/runtime-hooks-surface.cjs // (ADR-857 phase 5f-1) and re-exported via install.js. Import through install.js @@ -52,7 +56,11 @@ describe('bugs #2045 #2046: .sh hook paths must be absolute and quoted', () => { let src; try { - src = fs.readFileSync(INSTALL_SRC, 'utf-8'); + // ADR-857 phase 5f-1b: command-construction code moved to runtime-hooks-surface.cts. + // Concatenate both sources so structural assertions find patterns in either file. + const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8'); + const hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); + src = installSrc + '\n' + hooksSurfaceSrc; } catch { src = ''; } diff --git a/tests/workflow-guard-registration.test.cjs b/tests/workflow-guard-registration.test.cjs index a9c312ce3..83895963d 100644 --- a/tests/workflow-guard-registration.test.cjs +++ b/tests/workflow-guard-registration.test.cjs @@ -22,6 +22,10 @@ const path = require('path'); const { createTempDir, cleanup } = require('./helpers.cjs'); const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js'); +// ADR-857 phase 5f-1b: settings-json hook registration moved from install.js into +// applySettingsJsonHooks in src/runtime-hooks-surface.cts. Source-scan checks must +// include both files so structural invariants are verified against the correct source. +const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts'); // Hooks whose registration lives in runtime-hooks-surface module, not install.js. // These are excluded from the install.js source-scan and validated behaviorally. @@ -30,9 +34,18 @@ const MODULE_OWNED_HOOKS = new Set([ 'gsd-cursor-post-tool.js', ]); +// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts. +// Concatenate both sources so structural assertions find patterns in either file. +function readInstallSources() { + const installSrc = fs.readFileSync(INSTALL_JS, 'utf-8'); + let hooksSurfaceSrc = ''; + try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ } + return installSrc + '\n' + hooksSurfaceSrc; +} + describe('workflow-guard hook registration (#1767)', () => { test('install.js constructs a command path variable for gsd-workflow-guard.js', () => { - const content = fs.readFileSync(INSTALL_JS, 'utf-8'); + const content = readInstallSources(); const lines = content.split('\n'); // Every registered JS hook has a command variable constructed via // buildHookCommand() or string concatenation. Filter out references @@ -52,7 +65,7 @@ describe('workflow-guard hook registration (#1767)', () => { }); test('install.js has a hasWorkflowGuardHook dedup check', () => { - const content = fs.readFileSync(INSTALL_JS, 'utf-8'); + const content = readInstallSources(); // Every registered hook has a dedup check: hasXxxHook = settings.hooks[...].some(...) const hasDedup = content.includes('hasWorkflowGuardHook') || content.includes('hasWorkflowGuard'); @@ -63,7 +76,7 @@ describe('workflow-guard hook registration (#1767)', () => { }); test('install.js pushes workflow-guard entry with correct matcher', () => { - const content = fs.readFileSync(INSTALL_JS, 'utf-8'); + const content = readInstallSources(); // Extract the workflow-guard registration section. It should install the // Bash-aware matcher and upgrade old edit-only entries on reinstall. const workflowGuardSection = content.match( @@ -87,7 +100,7 @@ describe('workflow-guard hook registration (#1767)', () => { describe('hook registration completeness anti-pattern guard', () => { test('every JS hook in gsdHooks (except module-owned) has a command construction in install.js', () => { - const content = fs.readFileSync(INSTALL_JS, 'utf-8'); + const content = readInstallSources(); // Use the typed export instead of source-grep regex (branch #455: retire source-grep) const { GSD_UNINSTALL_HOOKS } = require('../bin/install.js'); assert.ok(Array.isArray(GSD_UNINSTALL_HOOKS), 'GSD_UNINSTALL_HOOKS must be exported from install.js');