Files
msd-core/src/runtime-hooks-surface.cts
Tom Boucher 94872662e9 feat(#1082): complete phase 5 — descriptor-drive all install surfaces + materialize the InstallPlan — ADR-857/1016/58 (#1080)
* feat(#1077): phase 5f-2 — drive the hookEvents dialect (PostToolUse/AfterTool) from the descriptor

postToolEvent (bin/install.js) and preToolEvent (applySettingsJsonHooks in
runtime-hooks-surface.cts) now select the event-name dialect from
registry.runtimes[id].runtime.hookEvents instead of the hardcoded
(runtime === 'gemini' || runtime === 'antigravity') check: hookEvents === 'gemini'
→ AfterTool/BeforeTool; else → PostToolUse/PreToolUse. hookEvents threaded into the
applySettingsJsonHooks opts bag. Equivalence-preserving (Codex-verified): hookEvents
'gemini' is exactly {gemini, antigravity}, 'claude' the rest; undefined → claude
dialect (matches the old else).

The per-event SET guards (isQwen||claude → SubagentStop/Stop/PreCompact;
runtime==='claude' → FileChanged; isGemini → Gemini agent-events) stay HARDCODED —
hookEvents (2-value) is too coarse to drive them (the event set differs within
hookEvents='claude'); per-event-set drive tracked in #1076.

Registry-parity test (enh-1077): asserts BOTH post-tool (AfterTool/PostToolUse) AND
pre-tool (BeforeTool/PreToolUse) dialects are a pure function of hookEvents, for
gemini/antigravity/claude/augment — non-vacuous (catches a broken hookEvents thread).

Closes #1077

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1077): build hooks/dist in before() so dialect-drive test passes in scoped CI

hooks/dist is gitignored and absent in scoped/windows CI jobs that do not
pre-run build:hooks. Without it, install() finds no hook files and all
AfterTool/BeforeTool/PostToolUse/PreToolUse event arrays come back empty,
failing every hook-presence assertion. Added an idempotent ensureHooksDist()
called in a top-level before() — mirrors the pattern from
bug-376-claude-js-hook-gsd-rewriter.test.cjs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1055): add installSurface/writesSharedSettings/permissionWriter/extendedHookEvents to runtime descriptors

Purely additive: four new fields on all 16 runtime capability.json descriptors,
validator extended with three new closed-vocab sets, registry regenerated.
Test fixtures (VALID_RUNTIME_CAP and makeRuntimeCap) updated to include the new
required fields so all 255 capability-registry tests continue to pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1076): drive per-event hook guards from extendedHookEvents descriptor

Replace hardcoded runtime-name checks (isQwen||runtime==='claude',
runtime==='claude', isGemini) in applySettingsJsonHooks with a single
descriptor-driven extendedEvents array derived from the new opts field.
Remove isQwen and isGemini derivations (no remaining uses after the three
guard blocks are migrated). Wire extendedHookEvents from the capability
registry in bin/install.js call site. Add behavioral regression test
(enh-1076-extended-hook-events-drive.test.cjs) confirming the drive is
purely descriptor-based and runtime-name-agnostic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1055): drive resolveRuntimeConfigIntent from the runtime descriptor; retire hand-kept REGISTRY

- Rewrites src/runtime-config-adapter-registry.cts to require capability-registry.cjs
  and read installSurface / writesSharedSettings / permissionWriter from
  runtimes[id].runtime; deletes the hand-kept REGISTRY const (ADR-857 phase 5g drive 2).
- ALLOWED_CONFIG_RUNTIMES is now derived from descriptor entries that have installSurface.
- Fixes the configFormat parity gate in scripts/gen-capability-registry.cjs to read
  installSurface directly from capMap descriptor bodies, breaking the require cycle
  (adapter now requires the generated registry; gen-script must not require the adapter).
- Adds golden-master test tests/enh-1055-config-intent-descriptor-drive.test.cjs (41 tests)
  pinning all 16 runtimes' return shapes and the TypeError-on-unknown contract.
- Updates scripts/lint-test-file-count.allowlist.json (config module, +1 file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1076): make hooksSurface descriptor load-bearing for the settings-json hook-skip

- Adds hooksSurface?: string to ApplySettingsJsonHooksOpts and destructuring in
  applySettingsJsonHooks (src/runtime-hooks-surface.cts).
- Replaces the hardcoded !isOpencode && !isKilo hook-skip guard with
  hooksSurface !== 'none'; removes the now-unused isOpencode/isKilo derivations
  (ADR-857 phase 5g drive 3).
- Passes hooksSurface from the runtime descriptor at the applySettingsJsonHooks
  call site in bin/install.js using the established
  _capabilityRegistry?.runtimes?.[runtime]?.runtime?.hooksSurface idiom.
- Extends tests/enh-1076-extended-hook-events-drive.test.cjs with two new suites
  proving: (a) hooksSurface:'none' writes no hooks regardless of runtime name;
  (b) hooksSurface:'settings-json' writes hooks even for 'opencode' (previously
  hardcoded to skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: record installSurface/writesSharedSettings/permissionWriter/extendedHookEvents descriptor axes in ADR-1016

Add Decision 7a documenting the four axes added in the 5f-completion pass,
update axis counts from "six" to "twelve", note 5f-completion drives as done
in Decision 8's ladder, update Out of scope to reflect #1055/#1076 are done
and 5g (InstallPlan capstone) remains the only open phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1055): parity gate must fire on configFormat↔installSurface mismatch (read installSurface at the descriptor level)

The test fixture makeRuntimeCapMap did not include installSurface in the runtime object,
so the gate's typeof r.installSurface !== 'string' guard always skipped the entry and never threw.
Added installSurface as an optional third parameter to makeRuntimeCapMap and passed the correct
installSurface values ('settings-json' for claude, 'codex-toml' for codex) to the two THROWS tests.
The gate implementation already reads r.installSurface correctly from the descriptor level.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1076): add installSurface↔hooksSurface + extendedHookEvents↔hookEvents consistency gates with rejection tests

GATE A: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES map in validateRuntimeBody enforces that a
runtime's hooksSurface is valid for its installSurface (e.g. profile-marker-only only allows none,
codex-toml only allows codex-hooks-json). Derived from the 16 real runtime descriptors.

GATE B: validateRuntimeBody checks that if extendedHookEvents contains Gemini agent-events
(BeforeAgent/AfterAgent/BeforeModel), hookEvents must be 'gemini'; if it contains Claude-family
events (SubagentStop/Stop/PreCompact/FileChanged), hookEvents must be 'claude'.

Added 10 rejection tests in suite 27 covering each gate + each new field validator.
All 16 real runtimes satisfy both gates (verified before coding).
Exports: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, VALID_INSTALL_SURFACES,
VALID_EXTENDED_HOOK_EVENTS, VALID_PERMISSION_WRITERS, validateRuntimeBody.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1076): strengthen hooksSurface-drive assertions; defensive hooksSurface fallback; drop vacuous dup

1. bin/install.js: add explicit literal fallback for hooksSurface when the committed
   capability registry fails to load (opencode/kilo → 'none', all others → 'settings-json').
   The descriptor is always the source of truth in normal operation.

2. enh-1076 Suite 7: change SessionStart assertion from key-presence (hasOwnProperty)
   to at least-one-command (hasHooksFor), so the test fails if hooks are initialized-but-empty.
   ensureHooksDist() in before() guarantees hook files exist.

3. enh-1055 Test 2: remove vacuous duplicate suite that re-asserted intent.runtime === row.runtime
   already fully covered by Test 1's deepStrictEqual over all four fields.

4. capability-registry.test.cjs: fix stale comments in the grok-skip test that claimed the
   parity gate uses the adapter registry; gate reads purely from the descriptor (installSurface
   absent → typeof r.installSurface !== 'string' → soft-skip).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1082): materialize the InstallPlan — collect install-level descriptor axes into resolveInstallPlan; route install()/finishInstall() through it (ADR-58/5g)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: record 5g InstallPlan materialization (ADR-58 Accepted, ADR-1016 phase-5 complete)

ADR-1016 Decision 8 step 7 updated to DONE: resolveInstallPlan(runtime) in
runtime-config-adapter-registry collects install-level descriptor axes into
the typed InstallPlan consumed by install()/finishInstall(). Out-of-scope
section updated: 5g capstone is complete, phase 5 fully materialized.
ADR-1016 line ~20 updated: InstallPlan IS now materialized (both halves).
ADR-58 Implementation note added (2026-06-11): realized in
runtime-config-adapter-registry (co-located with adapter-selection).
CONTEXT.md Runtime Config Adapter Registry entry extended to document
resolveInstallPlan and both-halves realization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1082): update install drift guard to the resolveInstallPlan seam (5g)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:36:47 -04:00

1723 lines
72 KiB
TypeScript

'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.
*
* bin/install.js re-exports every symbol from this module so existing
* consumers that do require('../bin/install.js').writeCursorHooksJson
* (etc.) continue to work unchanged.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import shellCmdProjection = require('./shell-command-projection.cjs');
const {
isManagedHookBasename,
isManagedHookCommand,
projectLegacySettingsHookCommand,
projectManagedHookCommand,
projectPortableHookBaseDir,
projectCodexHookTomlCommand,
shellHookOmitsBashRunner,
} = 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 }) => 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;
};
// ---------------------------------------------------------------------------
// 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}"}' }`;
// ---------------------------------------------------------------------------
// 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_HOOK_MARKER = 'gsd-managed';
// ---------------------------------------------------------------------------
// Cline / AGENTS.md constants
// ---------------------------------------------------------------------------
const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
// ---------------------------------------------------------------------------
// 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<string> — absolute paths of .tmp-<pid>-<n> files this process created.
const __atomicWrittenTmps: Set<string> = 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);
fs.renameSync(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;
}
}
// ---------------------------------------------------------------------------
// 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 = execPath.replace(/\\/g, '/');
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;
}
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
return '/usr/local/bin/node';
}
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
return '/opt/homebrew/bin/node';
}
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(stablePath.replace(/\\/g, '/'));
}
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(candidate.replace(/\\/g, '/'));
}
}
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 = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
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;
}
// ---------------------------------------------------------------------------
// Codex TOML hook block builder
// ---------------------------------------------------------------------------
interface BuildCodexHookBlockOpts {
absoluteRunner?: string | null;
eol?: string;
platform?: string;
}
function buildCodexHookBlock(targetDir: string, opts?: BuildCodexHookBlockOpts): string | null {
const absoluteRunner = opts && opts.absoluteRunner;
if (!absoluteRunner) return null;
const eol = (opts && opts.eol) || '\n';
const platform = (opts && opts.platform) || process.platform;
const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
const commandValue = projectCodexHookTomlCommand({
absoluteRunner,
scriptPath: updateCheckScript,
platform,
});
return `${eol}# GSD Hooks${eol}` +
`[[hooks.SessionStart]]${eol}` +
`${eol}` +
`[[hooks.SessionStart.hooks]]${eol}` +
`type = "command"${eol}` +
`command = "${commandValue}"${eol}`;
}
// ---------------------------------------------------------------------------
// Codex TOML legacy-hook rewriter
// ---------------------------------------------------------------------------
interface RewriteLegacyResult {
content: string;
changed: boolean;
}
function rewriteLegacyCodexHookBlock(content: string, absoluteRunner: string | null, opts?: { platform?: string }): RewriteLegacyResult {
if (!content || !absoluteRunner) return { content, changed: false };
const platform = (opts && opts.platform) || process.platform;
let changed = false;
const updated = content.replace(
/^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm,
(full: string, prefix: string, scriptToken: string, suffix: string) => {
const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
let scriptPath = scriptToken;
if (quoted) {
try {
scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
} catch {
scriptPath = quoted[1];
}
}
if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full;
const desiredCommand = projectCodexHookTomlCommand({
absoluteRunner,
scriptPath,
platform,
});
const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
if (currentCommand === desiredCommand) return full;
changed = true;
return `${prefix}${desiredCommand}${suffix}`;
},
);
return { content: updated, changed };
}
// ---------------------------------------------------------------------------
// Codex hooks.json: reconcileCodexHooksJsonEvent
// ---------------------------------------------------------------------------
interface ReconcileCodexOpts {
managedCommand?: string | null;
commandWindows?: string | null;
matcher?: string | null;
timeout?: number | null;
}
interface ReconcileResult {
changed: boolean;
wrote: boolean;
path: string;
}
function reconcileCodexHooksJsonEvent(targetDir: string, eventName: string, opts: ReconcileCodexOpts = {}): ReconcileResult {
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
let parsed: Record<string, unknown> = {};
let currentContent: string | null = null;
if (fs.existsSync(hooksJsonPath)) {
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
currentContent = raw;
if (raw.trim()) {
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
throw new Error(`hooks.json parse failed: ${err && (err as Error).message ? (err as Error).message : String(err)}`);
}
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
const usesNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
const hookTable = usesNestedHooksObject ? (parsed['hooks'] as Record<string, unknown>) : parsed;
const eventEntries = Array.isArray(hookTable[eventName]) ? (hookTable[eventName] as unknown[]) : [];
let removedLegacy = false;
const sanitizedEntries: unknown[] = [];
for (const entry of eventEntries) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const entryObj = entry as Record<string, unknown>;
const originalHooks = Array.isArray(entryObj['hooks']) ? (entryObj['hooks'] as unknown[]) : [];
if (originalHooks.length === 0) {
sanitizedEntries.push(entry);
continue;
}
const keptHooks = originalHooks.filter((hook) => {
const cmd = hook && typeof hook === 'object' ? (hook as Record<string, unknown>)['command'] : null;
const managed = isManagedHookCommand(cmd as string | null | undefined, {
surface: 'codex-hooks-json',
includeLegacyAliases: true,
configDir: targetDir,
});
if (managed) removedLegacy = true;
return !managed;
});
if (keptHooks.length === 0) continue;
const nextEntry = { ...entryObj, hooks: keptHooks };
sanitizedEntries.push(nextEntry);
}
if (managedCommand) {
const hookEntry: Record<string, unknown> = { type: 'command', command: managedCommand };
if (commandWindows) hookEntry['commandWindows'] = commandWindows;
if (timeout !== undefined) hookEntry['timeout'] = timeout;
const newEntry: Record<string, unknown> = { hooks: [hookEntry] };
if (matcher !== undefined) newEntry['matcher'] = matcher;
sanitizedEntries.push(newEntry);
}
if (sanitizedEntries.length > 0) {
hookTable[eventName] = sanitizedEntries;
} else {
delete hookTable[eventName];
}
if (usesNestedHooksObject) parsed['hooks'] = hookTable;
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const changed = currentContent !== nextContent;
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
if (shouldWrite) {
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
}
return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
}
// ---------------------------------------------------------------------------
// reconcileCodexHooksJsonSessionStart
// ---------------------------------------------------------------------------
interface ReconcileSessionStartOpts {
managedCommand?: string | null;
commandWindows?: string | null;
}
function reconcileCodexHooksJsonSessionStart(targetDir: string, opts: ReconcileSessionStartOpts = {}): ReconcileResult {
return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
}
// ---------------------------------------------------------------------------
// buildCodexHookWindowsShimIR
// ---------------------------------------------------------------------------
interface ShimIR {
invocation: { interpreter: string; target: string };
cmdPath: string;
hookCommand: string;
eol: { cmd: string };
passthroughArgs: boolean;
render: { cmd: () => string };
}
function buildCodexHookWindowsShimIR(scriptAbsPath: string, absoluteRunnerToken: string | null): ShimIR | null {
if (!absoluteRunnerToken) return null;
let interpreter: string;
try {
interpreter = JSON.parse(absoluteRunnerToken) as string;
} catch {
interpreter = absoluteRunnerToken;
}
const targetAbs = scriptAbsPath.replace(/\\/g, '/');
const scriptQuoted = JSON.stringify(targetAbs);
const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
const runnerQuoted = JSON.stringify(interpreter);
return {
invocation: { interpreter, target: scriptAbsPath },
cmdPath,
hookCommand,
eol: { cmd: '\r\n' },
passthroughArgs: true,
render: {
cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
},
};
}
// ---------------------------------------------------------------------------
// ensureCodexHooksJsonSessionStart
// ---------------------------------------------------------------------------
interface EnsureCodexSessionStartOpts {
absoluteRunner?: string | null;
platform?: NodeJS.Platform;
}
function ensureCodexHooksJsonSessionStart(targetDir: string, opts: EnsureCodexSessionStartOpts = {}): ReconcileResult {
const platform = opts.platform || process.platform;
const absoluteRunner = opts.absoluteRunner || null;
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
let managedCommand: string | undefined;
if (platform === 'win32') {
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
try {
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
} catch (shimWriteErr) {
const reason = shimWriteErr && (shimWriteErr as Error).message ? (shimWriteErr as Error).message : String(shimWriteErr);
console.warn(
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
`Fix the write error (permissions? disk full?) and re-run the installer. ` +
`Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`,
);
return { changed: false, wrote: false, path: hooksJsonPath };
}
managedCommand = shimIR.hookCommand;
} else {
managedCommand = projectManagedHookCommand({
absoluteRunner,
scriptPath,
runtime: 'codex',
platform,
}) ?? undefined;
}
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
const commandWindows = platform === 'win32'
? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
: undefined;
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
}
// ---------------------------------------------------------------------------
// ensureCodexHooksJsonEvent
// ---------------------------------------------------------------------------
interface EnsureCodexEventOpts {
absoluteRunner?: string | null;
platform?: NodeJS.Platform;
}
function ensureCodexHooksJsonEvent(targetDir: string, eventName: string, opts: EnsureCodexEventOpts = {}): ReconcileResult {
const platform = opts.platform || process.platform;
const absoluteRunner = opts.absoluteRunner || null;
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
let managedCommand: string | undefined;
if (platform === 'win32') {
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
try {
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
} catch (shimWriteErr) {
const reason = shimWriteErr && (shimWriteErr as Error).message ? (shimWriteErr as Error).message : String(shimWriteErr);
console.warn(
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
`Fix the write error (permissions? disk full?) and re-run the installer.`,
);
return { changed: false, wrote: false, path: hooksJsonPath };
}
managedCommand = shimIR.hookCommand;
} else {
managedCommand = projectManagedHookCommand({
absoluteRunner,
scriptPath,
runtime: 'codex',
platform,
}) ?? undefined;
}
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
}
// ---------------------------------------------------------------------------
// removeCodexHooksJsonEvent / removeCodexHooksJsonSessionStart
// ---------------------------------------------------------------------------
function removeCodexHooksJsonEvent(targetDir: string, eventName: string): ReconcileResult {
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
}
function removeCodexHooksJsonSessionStart(targetDir: string): ReconcileResult {
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
}
// ---------------------------------------------------------------------------
// Shared: buildHookCommand
// ---------------------------------------------------------------------------
interface BuildHookCommandOpts {
portableHooks?: boolean;
platform?: string;
runtime?: string;
env?: NodeJS.ProcessEnv;
existsSync?: (p: string) => boolean;
}
function buildHookCommand(configDir: string, hookName: string, opts?: BuildHookCommandOpts): string | null {
if (!opts) opts = {};
const platform = opts.platform || process.platform;
const runtime = opts.runtime || 'generic';
const isShellHook = hookName.endsWith('.sh');
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
if (opts.portableHooks) {
const portableBaseDir = projectPortableHookBaseDir({
configDir,
homeDir: os.homedir(),
});
return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
}
return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
}
const nodeRunner = resolveNodeRunner();
const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
if (runner === null) return null;
if (opts.portableHooks) {
const portableBaseDir = projectPortableHookBaseDir({
configDir,
homeDir: os.homedir(),
});
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
runtime: opts.runtime || 'generic',
platform,
});
}
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
return projectManagedHookCommand({
absoluteRunner: runner,
scriptPath: hooksPath,
runtime,
platform,
});
}
// ---------------------------------------------------------------------------
// Cline helpers
// ---------------------------------------------------------------------------
function buildClineRulesBody(): string {
return [
'# GSD Core — Git. Ship. Done.',
'',
'- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
' the user runs a `/gsd-*` command.',
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
'- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
'- Do not apply GSD workflows unless the user explicitly asks for them.',
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
' step to the user using Cline\'s ask_user tool after completing it.',
].join('\n') + '\n';
}
function buildClineAgentsMdBody(): string {
return buildClineRulesBody();
}
function buildClinePreToolUseHook(): string {
return `#!/usr/bin/env node
'use strict';
/* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
* Protocol: JSON on stdin -> JSON decision on stdout.
* Honored fields: { cancel, errorMessage, contextModification }.
* Fails open: any error allows the operation. */
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { raw += c; });
process.stdin.on('end', () => {
const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
let input;
try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
try {
const tool = String(
input.toolName || input.tool_name || input.tool ||
(input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
).toLowerCase();
const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
// Collect only PATH-bearing field values (not free-form content), so a doc
// that merely mentions ".planning/" in its body is never falsely blocked.
const paths = [];
const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
const walk = (v, depth) => {
if (depth > 5 || paths.length > 64) return;
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
if (v && typeof v === 'object') {
for (const k of Object.keys(v)) {
const val = v[k];
if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
else walk(val, depth + 1);
}
}
};
walk(input, 0);
const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
if (isWrite && paths.some(isPlanningPath)) {
return process.stdout.write(JSON.stringify({
cancel: true,
errorMessage:
'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
}));
}
} catch { /* fall through to allow */ }
return allow();
});
`;
}
function mergeGsdAgentsMd(filePath: string, gsdContent: string): void {
const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
if (!fs.existsSync(filePath)) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, gsdBlock + '\n');
return;
}
const existing = fs.readFileSync(filePath, 'utf8');
const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
if (openIndex !== -1 && closeIndex !== -1) {
const before = existing.substring(0, openIndex).trimEnd();
const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
let newContent = '';
if (before) newContent += before + '\n\n';
newContent += gsdBlock;
if (after) newContent += '\n\n' + after;
newContent += '\n';
fs.writeFileSync(filePath, newContent);
return;
}
fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
}
// ---------------------------------------------------------------------------
// writeClineArtifacts
// ---------------------------------------------------------------------------
function writeClineArtifacts(targetDir: string, isGlobalInstall: boolean): string[] {
const written: string[] = [];
const clinerulesDir = path.join(targetDir, '.clinerules');
try {
if (fs.existsSync(clinerulesDir)) {
const st = fs.lstatSync(clinerulesDir);
if (st.isFile() || st.isSymbolicLink()) {
fs.unlinkSync(clinerulesDir);
console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
}
}
} catch { /* best-effort migration */ }
fs.mkdirSync(clinerulesDir, { recursive: true });
fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
written.push('.clinerules/gsd.md');
console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
const hooksDir = path.join(clinerulesDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const hookPath = path.join(hooksDir, 'PreToolUse');
fs.writeFileSync(hookPath, buildClinePreToolUseHook());
try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
written.push('.clinerules/hooks/PreToolUse');
console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
if (isGlobalInstall) {
try {
const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
} catch (err) {
console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${(err as Error).message}`);
}
}
return written;
}
// ---------------------------------------------------------------------------
// Cursor hook functions
// ---------------------------------------------------------------------------
function buildCursorHookEntry(scriptPath: string): Record<string, unknown> {
return {
type: 'command',
command: scriptPath.replace(/\\/g, '/'),
[GSD_CURSOR_HOOK_MARKER]: true,
};
}
function isManagedCursorHookEntry(entry: unknown): boolean {
return Boolean(entry && typeof entry === 'object' && (entry as Record<string, unknown>)[GSD_CURSOR_HOOK_MARKER]);
}
interface CursorManagedEntries {
sessionStart?: Record<string, unknown> | null;
postToolUse?: Record<string, unknown> | null;
[event: string]: Record<string, unknown> | null | undefined;
}
function reconcileCursorHooksJson(hooksJsonPath: string, managedEntries: CursorManagedEntries | null): ReconcileResult {
let parsed: Record<string, unknown> = {};
let currentContent: string | null = null;
if (fs.existsSync(hooksJsonPath)) {
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
currentContent = raw;
if (raw.trim()) {
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (err) {
throw new Error(`Cursor hooks.json parse failed: ${err && (err as Error).message ? (err as Error).message : String(err)}`);
}
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
const hasNestedHooksObject =
parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
if (!hasNestedHooksObject) {
const eventKeys = ['sessionStart', 'postToolUse'];
const lifted: Record<string, unknown> = {};
for (const k of eventKeys) {
if (Array.isArray(parsed[k])) {
lifted[k] = parsed[k];
delete parsed[k];
}
}
parsed['hooks'] = lifted;
}
if (!parsed['version']) parsed['version'] = 1;
const hookTable = parsed['hooks'] as Record<string, unknown>;
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
const entries = managedEntries || {};
for (const event of MANAGED_EVENTS) {
const existing = Array.isArray(hookTable[event]) ? (hookTable[event] as unknown[]) : [];
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
const newEntry = entries[event] || null;
if (newEntry) {
hookTable[event] = [...userOwned, newEntry];
} else {
if (userOwned.length > 0) {
hookTable[event] = userOwned;
} else {
delete hookTable[event];
}
}
}
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
const changed = currentContent !== nextContent;
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
if (shouldWrite) {
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
}
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
}
interface WriteCursorHooksJsonOpts {
absoluteRunner?: string | null;
platform?: string;
}
function writeCursorHooksJson(targetDir: string, src: string, opts?: WriteCursorHooksJsonOpts): { hooksJsonPath: string; changed: boolean } {
opts = opts || {};
const hooksDir = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
const srcHooksDir = path.join(src, 'hooks');
const installedScripts = new Set<string>();
for (const script of hookScripts) {
const srcPath = path.join(srcHooksDir, script);
const destPath = path.join(hooksDir, script);
if (fs.existsSync(srcPath)) {
let content = fs.readFileSync(srcPath, 'utf8');
content = content.replace(/gsd:/gi, 'gsd-');
fs.writeFileSync(destPath, content);
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
installedScripts.add(script);
}
}
const hookOpts: BuildHookCommandOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
: null;
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
: null;
const managedEntries: CursorManagedEntries = {};
if (sessionStartCmd) {
managedEntries['sessionStart'] = {
type: 'command',
command: sessionStartCmd,
[GSD_CURSOR_HOOK_MARKER]: true,
};
}
if (postToolCmd) {
managedEntries['postToolUse'] = {
type: 'command',
command: postToolCmd,
[GSD_CURSOR_HOOK_MARKER]: true,
};
}
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
return { hooksJsonPath, changed: result.changed };
}
function removeCursorHooksJson(targetDir: string): { changed: boolean } {
const hooksJsonPath = path.join(targetDir, 'hooks.json');
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
const result = reconcileCursorHooksJson(hooksJsonPath, null);
if (result.changed) {
try {
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
const parsed = JSON.parse(contentRaw) as Record<string, unknown>;
const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
? (parsed['hooks'] as Record<string, unknown>)
: {};
const hasAnyEvents = Object.keys(hookTable).some(
(k) => Array.isArray(hookTable[k]) && (hookTable[k] as unknown[]).length > 0,
);
if (!hasAnyEvents) {
fs.unlinkSync(hooksJsonPath);
return { changed: true };
}
} catch { /* best-effort: leave the file */ }
}
return { changed: result.changed };
}
// ---------------------------------------------------------------------------
// Copilot hook functions
// ---------------------------------------------------------------------------
function buildCopilotHookConfig(): Record<string, unknown> {
return {
version: 1,
hooks: {
sessionStart: [
{
type: 'command',
bash: GSD_COPILOT_SESSION_HOOK_BASH,
powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
timeoutSec: 10,
},
],
},
};
}
function writeCopilotHookConfig(targetDir: string): string {
const hooksDir = path.join(targetDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
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 runtimes whose hooksSurface descriptor field is 'none'
// (opencode and kilo, which have their own hook surface).
//
// Extracted from the `if (!isOpencode && !isKilo) { … }` block inside
// install() (ADR-857 phase 5f-1b). The hook-skip guard is now descriptor-driven
// (ADR-857 phase 5g drive 3): pass opts.hooksSurface from the runtime descriptor
// instead of deriving isOpencode/isKilo from the runtime name.
//
// @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')
// hooksSurface - descriptor hooksSurface field ('settings-json'|'none'|…); if !== 'none', hooks are written
// isGlobal - true for global installs
// targetDir - absolute path to the runtime config dir
// postToolEvent - 'PostToolUse' | 'AfterTool' (pre-computed by caller from descriptor)
// hookEvents - registry hookEvents dialect ('gemini'|'claude'|undefined)
// 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;
/** ADR-857 phase 5f-2: hookEvents dialect from the registry descriptor ('gemini'|'claude'|undefined). */
hookEvents?: string;
/** ADR-857 phase 5f-3: extended hook event names from the registry descriptor. */
extendedHookEvents?: string[];
/** ADR-857 phase 5g drive 3: hooksSurface from the runtime descriptor ('settings-json'|'none'|…). */
hooksSurface?: 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,
hookEvents,
extendedHookEvents,
hooksSurface,
updateCheckCommand,
contextMonitorCommand,
promptGuardCommand,
readGuardCommand,
readInjectionScannerCommand,
configReloadCommand,
hookOpts,
localCmd,
localShellCmd,
} = opts;
// ADR-857 phase 5f-3: extended hook events are now driven by the registry
// descriptor field rather than hardcoded runtime-name checks.
const extendedEvents = Array.isArray(extendedHookEvents) ? extendedHookEvents : [];
// ADR-857 phase 5g drive 3: hook-skip guard is driven by the hooksSurface
// descriptor field. Only runtimes with hooksSurface === 'settings-json'
// register settings.json hooks; runtimes with hooksSurface === 'none'
// (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
// the old !isOpencode && !isKilo check.
if (hooksSurface !== 'none') {
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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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
// ADR-857 phase 5f-2: drive dialect from opts.hookEvents (registry descriptor).
// hookEvents='gemini' → BeforeTool; all others → PreToolUse.
// Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} (same as old check).
const preToolEvent = hookEvents === 'gemini' ? '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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<string, unknown>, '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.
// SubagentStop, Stop, PreCompact — route through the context monitor.
// Guard is now descriptor-driven: only events present in extendedEvents are wired.
{
const runtimeLabel = runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? 'Claude Code' : runtime;
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
if (!extendedEvents.includes(event)) continue;
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<string, unknown>, '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 is now descriptor-driven: only events present in extendedEvents are wired.
for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
if (!extendedEvents.includes(geminiEvent)) continue;
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<string, unknown>, '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 (extendedEvents.includes('FileChanged')) {
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<string, unknown>, '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<string, unknown>, 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
// ---------------------------------------------------------------------------
export = {
// Cline
buildClineRulesBody,
buildClineAgentsMdBody,
buildClinePreToolUseHook,
mergeGsdAgentsMd,
writeClineArtifacts,
GSD_AGENTS_MD_MARKER,
GSD_AGENTS_MD_CLOSE_MARKER,
// Cursor
buildCursorHookEntry,
isManagedCursorHookEntry,
reconcileCursorHooksJson,
writeCursorHooksJson,
removeCursorHooksJson,
GSD_CURSOR_SESSION_HOOK_SCRIPT,
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
GSD_CURSOR_HOOK_MARKER,
// Copilot
buildCopilotHookConfig,
writeCopilotHookConfig,
GSD_COPILOT_HOOK_FILE,
// Codex hooks.json
reconcileCodexHooksJsonEvent,
reconcileCodexHooksJsonSessionStart,
ensureCodexHooksJsonSessionStart,
ensureCodexHooksJsonEvent,
removeCodexHooksJsonEvent,
removeCodexHooksJsonSessionStart,
buildCodexHookWindowsShimIR,
// Codex TOML
buildCodexHookBlock,
rewriteLegacyCodexHookBlock,
// Shared
buildHookCommand,
applySettingsJsonHooks,
referencesHook,
rewriteLegacyManagedNodeHookCommands,
normalizeNodePath,
resolveNodeRunner,
resolveBashRunner,
// Atomic write seam (shared with bin/install.js so all writes participate
// in install.js's _cleanTmpFiles() scoped temp-cleanup).
atomicWriteFileSync,
__atomicWrittenTmps,
};