diff --git a/.changeset/776-gemini-hook-events.md b/.changeset/776-gemini-hook-events.md new file mode 100644 index 000000000..7f03043df --- /dev/null +++ b/.changeset/776-gemini-hook-events.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 829 +--- +Gemini installs now register three additional hook events — `BeforeAgent`, `AfterAgent`, and `BeforeModel` — wired to `gsd-context-monitor.js` for per-turn context headroom tracking. Previously only `SessionStart`, `BeforeTool`, and `AfterTool` were registered. The installer also detects `hooksConfig.enabled: false` in the user's Gemini `settings.json` and emits a clear warning, surfacing the silent failure mode where all hooks are registered but never execute. (#776) diff --git a/bin/install.js b/bin/install.js index 8a88ad2de..c706ff23c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -8239,9 +8239,10 @@ function uninstall(isGlobal, runtime = 'claude') { // Remove GSD hooks from settings — per-hook granularity to preserve // user hooks that share an entry with a GSD hook (#1755 followup). // Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop, - // PreCompact) — safe to iterate for all runtimes; non-Qwen installs - // simply find no entries and skip. - for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact']) { + // PreCompact) and the 3 Gemini-only events added in #776 (BeforeAgent, + // AfterAgent, BeforeModel) — safe to iterate for all runtimes; non-Qwen + // and non-Gemini installs simply find no entries and skip. + for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) { if (settings.hooks && settings.hooks[eventName]) { const before = JSON.stringify(settings.hooks[eventName]); settings.hooks[eventName] = settings.hooks[eventName] @@ -11215,8 +11216,74 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } // ── end Qwen-only extended hook 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 => h.command && h.command.includes('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 ────────────────────────────────── } + // ── Gemini hooksConfig.enabled check (#776) ─────────────────────────────── + // Detect `hooksConfig.enabled: false` in the already-loaded settings object + // and emit a clear warning. When this field is false the Gemini CLI silently + // disables ALL hook execution — gsd hooks are registered but will never run. + // The check is read-only (warning only; we do not mutate hooksConfig). + // Note: we use the in-memory `settings` object (already read from disk and + // cleaned up by validateHookFields/cleanupOrphanedHooks above) rather than + // re-reading settings.json, avoiding a TOCTOU window between the two reads. + if (isGemini && settings && settings.hooksConfig && settings.hooksConfig.enabled === false) { + console.warn( + ` ${yellow}⚠${reset} Warning: hooksConfig.enabled is false in your Gemini settings.json.\n` + + ` gsd-core hooks are registered but will NOT run until you set\n` + + ` hooksConfig.enabled: true in ${path.join(targetDir, 'settings.json')}.` + ); + } + // ── end hooksConfig.enabled check ──────────────────────────────────────── + // Compute the update-banner hook command alongside the others so // installAllRuntimes can register it at finalize time when the user opts // in (#2795). Computed here (not in finishInstall) so the same buildHookCommand diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 6d10fbbf5..595d45f0b 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -107,6 +107,21 @@ The installer also enriches the generated TOML commands with two native Gemini c GEMINI_CONFIG_DIR=~/.gemini-alt npx @opengsd/gsd-core@latest --gemini --global ``` +**Hook coverage** + +GSD registers the following hook events automatically on install: + +| Event | Hook | Purpose | +|---|---|---| +| `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation | +| `BeforeTool` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | +| `AfterTool` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | +| `BeforeAgent` | `gsd-context-monitor.js` | Context headroom awareness before the agent begins planning each prompt | +| `AfterAgent` | `gsd-context-monitor.js` | Context headroom tracking after each agent turn's final response | +| `BeforeModel` | `gsd-context-monitor.js` | Per-turn context injection before each LLM call | + +> **`hooksConfig.enabled: false` warning.** If your Gemini `settings.json` contains `hooksConfig.enabled: false`, the Gemini CLI silently disables all hook execution — GSD hooks are registered but will never run. The installer detects this and emits a warning. To enable hooks, set `hooksConfig.enabled: true` in `~/.gemini/settings.json` (or the directory matching your `GEMINI_CONFIG_DIR`). + --- ### Gemini CLI — native extension install (#775) diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index abec29430..d5859805f 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -121,6 +121,7 @@ "install": { "files": [ "bug-410-install-defaults-test-mode-guard.test.cjs", + "enh-776-install-gemini-hook-events.test.cjs", "install-minimal-hooks.test.cjs", "install-path-detection.test.cjs", "install-regressions.test.cjs", diff --git a/tests/enh-776-install-gemini-hook-events.test.cjs b/tests/enh-776-install-gemini-hook-events.test.cjs new file mode 100644 index 000000000..23a259cf4 --- /dev/null +++ b/tests/enh-776-install-gemini-hook-events.test.cjs @@ -0,0 +1,406 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #776: Adopt new Gemini hook events + detect hooksConfig.enabled:false. + * + * Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that gsd + * previously did not register. This suite asserts that a Gemini install + * registers the 3 new high-value events: + * - BeforeAgent — fires before the agent plans (context headroom tracking) + * - AfterAgent — fires after final response generation (context tracking) + * - BeforeModel — fires before each LLM call (per-turn context awareness) + * + * All three are wired to gsd-context-monitor.js — the same hook used for + * AfterTool — so context headroom warnings surface at these lifecycle moments. + * + * Also asserts: + * - Claude Code installs do NOT gain these Gemini-only events (strict scope guard). + * - Reinstalls are idempotent (no hook duplication). + * - Uninstall removes the new event registrations. + * - hooksConfig.enabled:false warning is emitted during a Gemini install. + * + * Source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { install, uninstall, validateHookFields } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract all hook commands registered under `eventName` from settings. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +// Stub JS hook files that the installer checks with fs.existsSync() so hook +// registration guards pass even when hooks/dist/ isn't built. +// +// For Gemini, the installer migration baseline includes 'hooks/' in its surface +// list (unlike Qwen which excludes it). Pre-install stubs placed in .gemini/hooks/ +// are classified as 'bundled-gsd-hook' and auto-removed by the migration before +// registration can succeed. The workaround: run a first install (which writes the +// gsd-file-manifest.json), then add the stubs, then run install again. On the +// second install the manifest marks the hook files as managed, so migration keeps +// them and registration guards (fs.existsSync) pass. +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', +]; + +function stubHooksIntoTarget(targetDir) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const hookFile of STUB_HOOKS) { + const src = path.join(HOOKS_SRC, hookFile); + const dest = path.join(hooksDest, hookFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } else { + // Minimal stub so existsSync passes + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +/** + * Two-pass Gemini install. + * + * Pass 1: install() with no hook stubs — writes gsd-file-manifest.json. + * Migration runs on an empty hooks/ so nothing gets auto-removed. + * Pass 2: stub hooks into the target dir (now manifest-tracked on next scan), + * then run install() again. Migration now classifies the hooks as + * managed-unchanged and preserves them; registration guards pass. + * + * Returns the settings from pass 2. + */ +function twoPassGeminiInstall(tmpDir) { + const targetDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(targetDir, { recursive: true }); + + // Pass 1 — no hook stubs yet; writes the manifest + const result1 = install(false, 'gemini'); + persistSettings(result1.settingsPath, result1.settings); + + // Inject stubs so the registration guards (fs.existsSync) pass on pass 2 + stubHooksIntoTarget(targetDir); + + // Pass 2 — manifest exists, migration keeps stubs, registration succeeds + process.chdir(tmpDir); + const result2 = install(false, 'gemini'); + return result2; +} + +/** + * Persist in-memory settings to disk, simulating what finishInstall() does + * (finishInstall is not exported). Required for tests that call install() + * twice and need the second call to read the first call's hook registrations. + */ +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Gemini — new events are registered ───────────────────────────── + +describe('enh-776: Gemini install registers 3 new hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-gemini-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + // Two-pass install: first pass writes manifest, second pass with stubs + // so registration guards (fs.existsSync) pass. + const result = twoPassGeminiInstall(tmpDir); + settings = result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Gemini install must return a non-null settings object'); + }); + + test('BeforeAgent event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.ok(cmds.length > 0, + `Expected BeforeAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('AfterAgent event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.ok(cmds.length > 0, + `Expected AfterAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('BeforeModel event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.ok(cmds.length > 0, + `Expected BeforeModel hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('BeforeAgent / AfterAgent / BeforeModel all use gsd-context-monitor', () => { + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(settings, event); + assert.ok( + cmds.some(c => c.includes('gsd-context-monitor')), + `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` + ); + } + }); +}); + +// ─── Suite 2: Non-Gemini installs do NOT get the new events ────────────────── +// +// Two runtimes are particularly important to guard: +// Claude — the canonical non-Gemini runtime +// Antigravity — shares Gemini-style BeforeTool/AfterTool naming and uses +// isGemini-adjacent logic; a future accidental +// `isGemini || isAntigravity` change must be caught here. + +describe('enh-776: Claude install does NOT register Gemini-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Claude install does not register BeforeAgent', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register AfterAgent', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register BeforeModel', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); + }); +}); + +describe('enh-776: Antigravity install does NOT register Gemini-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-antigravity-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'antigravity'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Antigravity install does not register BeforeAgent', () => { + const cmds = hooksForEvent(settings, 'BeforeAgent'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Antigravity install does not register AfterAgent', () => { + const cmds = hooksForEvent(settings, 'AfterAgent'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`); + }); + + test('Antigravity install does not register BeforeModel', () => { + const cmds = hooksForEvent(settings, 'BeforeModel'); + assert.strictEqual(cmds.length, 0, + `Antigravity should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── + +describe('enh-776: Gemini install is idempotent across persisted reinstalls', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate hook entries', () => { + // Two-pass to get hooks installed. + const result2 = twoPassGeminiInstall(tmpDir); + const s2 = result2.settings; + + // Assert hooks ARE registered after pass 2 (guards against false-pass where + // hooks never registered and idempotency passes trivially at count=0). + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook after two-pass install; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + + persistSettings(result2.settingsPath, s2); + + // Third install: reads the persisted settings.json — dedup guards apply + process.chdir(tmpDir); + const result3 = install(false, 'gemini'); + const s3 = result3.settings; + + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(s3, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 4: Uninstall removes the new event registrations ────────────────── + +describe('enh-776: Gemini uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + // Two-pass install and persist so uninstall has a settings.json to clean + const result = twoPassGeminiInstall(tmpDir); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'gemini'); + const settingsPath = path.join(tmpDir, '.gemini', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 5: hooksConfig.enabled:false warning ─────────────────────────────── + +describe('enh-776: hooksConfig.enabled:false warning during Gemini install', () => { + let tmpDir; + let previousCwd; + let stderrLines; + let originalWarn; + + beforeEach(() => { + tmpDir = createTempDir('gsd-776-hookscfg-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(targetDir, { recursive: true }); + + // Capture console.warn output + stderrLines = []; + originalWarn = console.warn; + console.warn = (...args) => { stderrLines.push(args.join(' ')); }; + }); + + afterEach(() => { + console.warn = originalWarn; + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('emits a warning when hooksConfig.enabled is false', () => { + // Write a settings.json with hooksConfig.enabled: false BEFORE install + // (the check in install() reads the existing settings.json on disk). + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: false } }, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + warnText.includes('hooksConfig.enabled is false'), + `Expected hooksConfig.enabled warning; got console.warn output:\n${warnText}` + ); + }); + + test('does NOT emit the hooksConfig warning when hooksConfig.enabled is true', () => { + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: true } }, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + !warnText.includes('hooksConfig.enabled is false'), + `Should NOT warn when hooksConfig.enabled is true; got:\n${warnText}` + ); + }); + + test('does NOT emit the hooksConfig warning when hooksConfig is absent', () => { + const targetDir = path.join(tmpDir, '.gemini'); + const settingsPath = path.join(targetDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({}, null, 2) + '\n', 'utf8'); + + install(false, 'gemini'); + const warnText = stderrLines.join('\n'); + assert.ok( + !warnText.includes('hooksConfig.enabled is false'), + `Should NOT warn when hooksConfig is absent; got:\n${warnText}` + ); + }); +});