@empirical-gauzza/cli 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,550 +1,869 @@
1
- 'use strict';
2
-
3
- const os = require('os');
4
- const path = require('path');
5
- const { log } = require('../ui');
6
- const { ensureDir, writeFile, readJsonIfExists } = require('../fsUtils');
7
- const { normalizePluginNames } = require('./nativePlugin');
8
-
9
- const CLAUDE_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
10
- 'use strict';
11
-
12
- var spawnSync = require('child_process').spawnSync;
13
- var fs = require('fs');
14
- var os = require('os');
15
- var path = require('path');
16
-
17
- // Diagnostic breadcrumb log: this hook's own stdout is only visible to Claude Code's SessionStart
18
- // machinery, not to a human debugging "did this actually fire" after the fact. Every run appends
19
- // one line here (bounded — see trimLogIfLarge) regardless of success/failure, so firing can be
20
- // verified by checking file growth/timestamps after starting or resuming a session, without
21
- // depending on server-side analytics that don't currently tag hook-originated calls.
22
- var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
23
- var MAX_LOG_BYTES = 200 * 1024;
24
-
25
- function appendLog(line) {
26
- try {
27
- fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
28
- fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
29
- var stat = fs.statSync(LOG_PATH);
30
- if (stat.size > MAX_LOG_BYTES) {
31
- var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
32
- fs.writeFileSync(LOG_PATH, tail);
33
- }
34
- } catch (e) {
35
- // Diagnostics must never break session start.
36
- }
37
- }
38
-
39
- // On Windows, npm's global "empirical" command is a .cmd shim, and .cmd/.bat files can only be
40
- // executed via the OS shell (cmd.exe), never as a native process — this is an OS constraint, not
41
- // a Node quirk. But spawnSync's shell:true path joins argv into a single command-line string for
42
- // cmd.exe to reparse, and Node does not escape special shell characters (spaces, commas, etc.) in
43
- // that join for you. A --match value like "project context, conventions, recent decisions, user
44
- // preferences" gets mangled by that reparse, silently truncating the search query and returning
45
- // far fewer/worse matches than the same query run without a shell in between. The fix: resolve
46
- // the shim's REAL target (a plain .js file) and invoke that directly via node.exe with shell:false
47
- // — node.exe is a true native executable, so no shell/reparsing step exists to mangle args.
48
- function resolveEmpiricalEntryPoint() {
49
- if (process.platform !== 'win32') {
50
- // POSIX global installs are usually a symlink straight to the JS entry (or a shebang script)
51
- // — spawnSync already runs these directly without a shell, so no mangling risk exists there.
52
- return null;
53
- }
54
- try {
55
- // Parse the .cmd shim itself rather than hardcoding an npm package scope: the published
56
- // package has already been renamed once (was the empirical scope, now a different scope
57
- // entirely - the old one is 404 on the registry), so hardcoding either name is a guaranteed
58
- // future break. npm-generated shims all reference their target relative to dp0 (the shim's
59
- // own directory) as a node_modules-rooted path ending in bin then a .js file - extracting
60
- // that pattern works regardless of what the package is scoped or renamed to.
61
- var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
62
- if (where.status !== 0 || !where.stdout) return null;
63
- var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
64
- if (!cmdPath || !fs.existsSync(cmdPath)) return null;
65
- var cmdContent = fs.readFileSync(cmdPath, 'utf8');
66
- var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
67
- if (!match) return null;
68
- var candidate = path.join(path.dirname(cmdPath), match[1]);
69
- return fs.existsSync(candidate) ? candidate : null;
70
- } catch (e) {
71
- return null;
72
- }
73
- }
74
-
75
- function commandExists(cmd) {
76
- var probe = process.platform === 'win32'
77
- ? spawnSync('where', [cmd], { stdio: 'ignore', shell: true })
78
- : spawnSync('which', [cmd], { stdio: 'ignore' });
79
- return probe.status === 0;
80
- }
81
-
82
- function runMemoryQuery(args) {
83
- var entryPoint = resolveEmpiricalEntryPoint();
84
- if (entryPoint) {
85
- appendLog('resolved direct entry point: ' + entryPoint);
86
- return spawnSync(process.execPath, [entryPoint].concat(args), {
87
- encoding: 'utf8',
88
- timeout: 10000,
89
- shell: false
90
- });
91
- }
92
-
93
- // Fallback: could not resolve a direct entry point (non-Windows, or resolution failed) — run
94
- // the "empirical" command as before. On POSIX this is shell:false and safe. On Windows this is
95
- // shell:true and CAN still mangle args as described above; it only triggers if the direct-entry
96
- // resolution above failed, so degraded (not absent) results are the acceptable worst case here.
97
- appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
98
- return spawnSync('empirical', args, {
99
- encoding: 'utf8',
100
- timeout: 10000,
101
- shell: process.platform === 'win32'
102
- });
103
- }
104
-
105
- function main() {
106
- appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
107
-
108
- if (!commandExists('empirical')) {
109
- appendLog('empirical CLI not found on PATH, exiting');
110
- process.exit(0);
111
- }
112
-
113
- var result = runMemoryQuery([
114
- 'memory', 'query',
115
- '--match', 'project context, conventions, recent decisions, user preferences',
116
- '--top-k', '8'
117
- ]);
118
-
119
- if (!result || result.status !== 0 || !result.stdout) {
120
- appendLog('memory query failed or returned no stdout (status=' + (result && result.status) + ', stderr=' + (result && result.stderr ? String(result.stderr).slice(0, 300) : '') + ')');
121
- process.exit(0);
122
- }
123
-
124
- var results;
125
- try {
126
- results = JSON.parse(result.stdout);
127
- } catch (e) {
128
- appendLog('failed to parse memory query stdout as JSON: ' + e.message);
129
- process.exit(0);
130
- }
131
-
132
- if (!Array.isArray(results) || results.length === 0) {
133
- appendLog('memory query returned zero results');
134
- process.exit(0);
135
- }
136
-
137
- appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
138
-
139
- var lines = results.slice(0, 8).map(function (r) {
140
- var category = r && r.category ? r.category : 'memory';
141
- var summary = r && r.summary ? r.summary : '';
142
- return '- [' + category + '] ' + summary;
143
- });
144
-
145
- console.log('Empirical memory context for this session:\\n' + lines.join('\\n'));
146
- process.exit(0);
147
- }
148
-
149
- main();
150
- `;
151
-
152
- // EMPIRICAL_HOOK_STOP_SOURCE: fires once per assistant turn (Claude: Stop; Codex: Stop, turn-
153
- // scoped per codex-rs/hooks/src/engine/dispatcher.rs; Copilot: agentStop, "the main agent
154
- // finishes a turn" per GitHub's own hooks reference). This is the write-side counterpart to the
155
- // SessionStart hook above (which only covers recall, once per session boundary) - deliberately
156
- // simple: it does NOT parse the transcript to detect whether memory-worthy activity happened
157
- // this turn (transcript formats differ across all three platforms and getting that heuristic
158
- // wrong silently - the exact class of bug this session spent hours finding - is worse than an
159
- // unconditional reminder). It never calls the empirical CLI and makes no network call, so the
160
- // "wasteful per-turn hook" concern that killed the old per-turn UserPromptSubmit hook (see
161
- // installClaudeHooks below) does not apply here - this is a single static stdout write.
162
- // ALWAYS exits 0: per Claude Code's documented Stop hook contract, exit 2 forces the agent to
163
- // keep responding (a blocking decision) - a bug in a hook that's supposed to be an informational
164
- // nudge must never be able to trap a session in a forced-continuation loop, so this is
165
- // unconditional regardless of any error encountered.
166
- const EMPIRICAL_HOOK_STOP_SOURCE = `#!/usr/bin/env node
167
- 'use strict';
168
-
169
- var fs = require('fs');
170
- var os = require('os');
171
- var path = require('path');
172
-
173
- var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
174
- var MAX_LOG_BYTES = 200 * 1024;
175
-
176
- function appendLog(line) {
177
- try {
178
- fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
179
- fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
180
- var stat = fs.statSync(LOG_PATH);
181
- if (stat.size > MAX_LOG_BYTES) {
182
- var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
183
- fs.writeFileSync(LOG_PATH, tail);
184
- }
185
- } catch (e) {
186
- // Diagnostics must never break the hook.
187
- }
188
- }
189
-
190
- function readStdinPayload() {
191
- try {
192
- var raw = fs.readFileSync(0, 'utf8');
193
- return raw ? JSON.parse(raw) : {};
194
- } catch (e) {
195
- return {};
196
- }
197
- }
198
-
199
- function main() {
200
- var payload = readStdinPayload();
201
-
202
- // Defensive loop guard: if a Stop hook's own forced-continuation output somehow triggers
203
- // another Stop event before the agent produces new output, stop_hook_active (Claude's documented
204
- // field name for this) prevents a reminder-print-loop. This hook never sets decision:block
205
- // itself, but checking costs nothing and protects against any future change that does.
206
- if (payload && payload.stop_hook_active === true) {
207
- appendLog('stop_hook_active=true, skipping reminder');
208
- process.exit(0);
209
- }
210
-
211
- appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
212
-
213
- console.log('[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now via the empirical-memory skill or \`empirical memory record\` - do not wait to be asked.');
214
- process.exit(0);
215
- }
216
-
217
- main();
218
- `;
219
-
220
- // Claude-only variant of the Stop reminder above. hookSpecificOutput.additionalContext (stdout,
221
- // exit 0) is Claude Code's non-blocking Stop-hook feedback channel: the text is fed into context
222
- // and the conversation continues, same outcome the earlier exit-2 approach achieved, but the
223
- // transcript UI renders it as a plain system reminder instead of a red "Stop hook error" line.
224
- // EMPIRICAL_HOOK_STOP_SOURCE above still uses plain console.log + exit 0, which Claude Code treats
225
- // as non-blocking output that's logged but never reaches the model - that was this hook's original
226
- // bug (fixed for Claude here, unconfirmed/unfixed for Codex/Copilot's own Stop/agentStop semantics).
227
- const CLAUDE_HOOK_STOP_SOURCE = `#!/usr/bin/env node
228
- 'use strict';
229
-
230
- var fs = require('fs');
231
- var os = require('os');
232
- var path = require('path');
233
-
234
- var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
235
- var MAX_LOG_BYTES = 200 * 1024;
236
-
237
- function appendLog(line) {
238
- try {
239
- fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
240
- fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
241
- var stat = fs.statSync(LOG_PATH);
242
- if (stat.size > MAX_LOG_BYTES) {
243
- var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
244
- fs.writeFileSync(LOG_PATH, tail);
245
- }
246
- } catch (e) {
247
- // Diagnostics must never break the hook.
248
- }
249
- }
250
-
251
- function readStdinPayload() {
252
- try {
253
- var raw = fs.readFileSync(0, 'utf8');
254
- return raw ? JSON.parse(raw) : {};
255
- } catch (e) {
256
- return {};
257
- }
258
- }
259
-
260
- function main() {
261
- var payload = readStdinPayload();
262
-
263
- // stop_hook_active === true means Claude is re-invoking this hook because a PRIOR Stop hook
264
- // (possibly this same one) already surfaced additionalContext and forced a continuation this
265
- // turn. additionalContext causes the same forced-continuation behavior decision:block does -
266
- // confirmed empirically: without this guard the hook re-fired every ~2-3s in an infinite loop,
267
- // since each additionalContext output triggers another Stop event. Must exit 0 with no output
268
- // here, unconditionally, or the loop continues.
269
- if (payload && payload.stop_hook_active === true) {
270
- appendLog('stop_hook_active=true, skipping reminder (already surfaced once this turn)');
271
- process.exit(0);
272
- }
273
-
274
- appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + '), surfacing reminder via additionalContext');
275
-
276
- process.stdout.write(JSON.stringify({
277
- hookSpecificOutput: {
278
- hookEventName: 'Stop',
279
- additionalContext: '[Empirical] Record memory-worthy work now via empirical-memory skill.'
280
- }
281
- }) + '\\n');
282
- process.exit(0);
283
- }
284
-
285
- main();
286
- `;
287
-
288
- // UserPromptSubmit: fires before each turn is processed (Claude, Codex, and Copilot all support
289
- // this event under the same name per each platform's docs/source). This is the recall-side
290
- // counterpart to Stop's write-side reminder - deliberately a STATIC message, not a live
291
- // `empirical memory query` call: a prior per-turn hook in this codebase was removed for making an
292
- // unconditional network call on every single prompt (see the comment above installClaudeHooks),
293
- // and re-adding that cost here on three platforms at once was an explicit no per product decision.
294
- // The message itself is generated per-install from the actual plugin set being installed, not one
295
- // fixed string for everyone - e.g. someone who declined empirical-scar-tissue at install time
296
- // never sees a scar-tissue reminder, since that capability isn't actually available to them.
297
- function buildPromptReminderMessage(pluginNames) {
298
- const names = normalizePluginNames(pluginNames);
299
- const lines = [
300
- 'Before responding: if this task needs prior context (research, debugging, a spec or status update, a handoff), query Empirical memory first via the empirical-memory skill or empirical memory query - do not assume you already have enough context.'
301
- ];
302
- if (names.includes('empirical-work-history')) {
303
- lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
304
- }
305
- if (names.includes('empirical-scar-tissue')) {
306
- lines.push('Check for scar tissue before repeating a past mistake in this area.');
307
- }
308
- if (names.includes('empirical-todo')) {
309
- lines.push('If there is an open todo for this project, check it before starting new work.');
310
- }
311
- return '[Empirical reminder] ' + lines.join(' ');
312
- }
313
-
314
- // Builds the full UserPromptSubmit hook script source with the tailored message baked in at
315
- // generation time (via JSON.stringify, so the message is embedded as a single already-escaped
316
- // string literal - substituted through `${}`, not written as raw template-literal source text,
317
- // which is what caused the earlier comment-escaping bug in this same file. Never do the latter).
318
- function buildUserPromptSubmitHookSource(pluginNames) {
319
- const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames));
320
- return `#!/usr/bin/env node
321
- 'use strict';
322
-
323
- var fs = require('fs');
324
- var os = require('os');
325
- var path = require('path');
326
-
327
- var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'user-prompt-submit.log');
328
- var MAX_LOG_BYTES = 200 * 1024;
329
-
330
- function appendLog(line) {
331
- try {
332
- fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
333
- fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
334
- var stat = fs.statSync(LOG_PATH);
335
- if (stat.size > MAX_LOG_BYTES) {
336
- var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
337
- fs.writeFileSync(LOG_PATH, tail);
338
- }
339
- } catch (e) {
340
- // Diagnostics must never break the hook.
341
- }
342
- }
343
-
344
- appendLog('user-prompt-submit hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
345
- console.log(${messageLiteral});
346
- process.exit(0);
347
- `;
348
- }
349
-
350
- function getEmpiricalHooksDir() {
351
- return path.join(os.homedir(), '.empirical', 'hooks');
352
- }
353
-
354
- function buildHookCommand(scriptPath) {
355
- return `"${process.execPath}" "${scriptPath}"`;
356
- }
357
-
358
- function removeEmpiricalHookEntries(entries, scriptPath) {
359
- if (!Array.isArray(entries)) return [];
360
- return entries
361
- .map((entry) => {
362
- if (!entry || !Array.isArray(entry.hooks)) return entry;
363
- const filteredHooks = entry.hooks.filter(
364
- (h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
365
- );
366
- return { ...entry, hooks: filteredHooks };
367
- })
368
- .filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
369
- }
370
-
371
- function installClaudeHooks(pluginNames) {
372
- const hooksDir = getEmpiricalHooksDir();
373
- ensureDir(hooksDir);
374
-
375
- // A prior version of this hook made an unconditional `empirical memory query` network call on
376
- // every single prompt and was removed for being wasteful (see git history). This is a
377
- // deliberate reintroduction at the SAME script path, but with fundamentally different content:
378
- // a static, install-time-generated reminder with no network call at all - the cost concern that
379
- // killed the old version does not apply to a plain stdout write.
380
- const userPromptScriptPath = path.join(hooksDir, 'claude-user-prompt-submit.js');
381
- const sessionStartScriptPath = path.join(hooksDir, 'claude-session-start.js');
382
- const stopScriptPath = path.join(hooksDir, 'claude-stop.js');
383
- writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
384
- writeFile(stopScriptPath, CLAUDE_HOOK_STOP_SOURCE);
385
- writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
386
-
387
- const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
388
- const settings = readJsonIfExists(settingsPath) || {};
389
- if (!settings.hooks || typeof settings.hooks !== 'object') {
390
- settings.hooks = {};
391
- }
392
-
393
- settings.hooks.UserPromptSubmit = removeEmpiricalHookEntries(settings.hooks.UserPromptSubmit, userPromptScriptPath);
394
- settings.hooks.UserPromptSubmit.push({
395
- hooks: [{ type: 'command', command: buildHookCommand(userPromptScriptPath), timeout: 10 }]
396
- });
397
-
398
- settings.hooks.SessionStart = removeEmpiricalHookEntries(settings.hooks.SessionStart, sessionStartScriptPath);
399
- for (const matcher of ['startup', 'resume', 'compact']) {
400
- settings.hooks.SessionStart.push({
401
- matcher,
402
- hooks: [{ type: 'command', command: buildHookCommand(sessionStartScriptPath), timeout: 15 }]
403
- });
404
- }
405
-
406
- settings.hooks.Stop = removeEmpiricalHookEntries(settings.hooks.Stop, stopScriptPath);
407
- settings.hooks.Stop.push({
408
- hooks: [{ type: 'command', command: buildHookCommand(stopScriptPath), timeout: 10 }]
409
- });
410
-
411
- writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
412
- log(' - Claude SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
413
- }
414
-
415
- // Codex CLI ships a real hooks system (confirmed against openai/codex source — codex-rs/hooks,
416
- // codex-rs/config/src/hook_config.rs — not assumed from docs alone, since third-party writeups
417
- // disagreed on Windows support). It reads <CODEX_HOME>/hooks.json (JSON, not the config.toml
418
- // used for MCP/model settings — a separate file, so this never touches config.toml) shaped as
419
- // { hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command, commandWindows,
420
- // timeout }] }], ... } }. CODEX_HOME defaults to ~/.codex when unset, same convention as
421
- // ~/.claude for Claude Code.
422
- function getCodexHomeDir() {
423
- return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
424
- }
425
-
426
- function removeEmpiricalHooksJsonEntries(entries, scriptPath) {
427
- if (!Array.isArray(entries)) return [];
428
- return entries
429
- .map((entry) => {
430
- if (!entry || !Array.isArray(entry.hooks)) return entry;
431
- const filteredHooks = entry.hooks.filter(
432
- (h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
433
- );
434
- return { ...entry, hooks: filteredHooks };
435
- })
436
- .filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
437
- }
438
-
439
- function installCodexHooks(pluginNames) {
440
- const hooksDir = getEmpiricalHooksDir();
441
- ensureDir(hooksDir);
442
-
443
- const sessionStartScriptPath = path.join(hooksDir, 'codex-session-start.js');
444
- const stopScriptPath = path.join(hooksDir, 'codex-stop.js');
445
- const userPromptScriptPath = path.join(hooksDir, 'codex-user-prompt-submit.js');
446
- writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
447
- writeFile(stopScriptPath, EMPIRICAL_HOOK_STOP_SOURCE);
448
- writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
449
-
450
- const hooksJsonPath = path.join(getCodexHomeDir(), 'hooks.json');
451
- const hooksFile = readJsonIfExists(hooksJsonPath) || {};
452
- if (!hooksFile.hooks || typeof hooksFile.hooks !== 'object') {
453
- hooksFile.hooks = {};
454
- }
455
-
456
- hooksFile.hooks.SessionStart = removeEmpiricalHooksJsonEntries(hooksFile.hooks.SessionStart, sessionStartScriptPath);
457
- const sessionStartCommand = buildHookCommand(sessionStartScriptPath);
458
- for (const matcher of ['startup', 'resume']) {
459
- hooksFile.hooks.SessionStart.push({
460
- matcher,
461
- hooks: [{ type: 'command', command: sessionStartCommand, commandWindows: sessionStartCommand, timeout: 15 }]
462
- });
463
- }
464
-
465
- hooksFile.hooks.Stop = removeEmpiricalHooksJsonEntries(hooksFile.hooks.Stop, stopScriptPath);
466
- const stopCommand = buildHookCommand(stopScriptPath);
467
- hooksFile.hooks.Stop.push({
468
- hooks: [{ type: 'command', command: stopCommand, commandWindows: stopCommand, timeout: 10 }]
469
- });
470
-
471
- // Confirmed against codex-rs/config/src/hook_config.rs's HookEventsToml serde renames: the
472
- // config-file key is PascalCase "UserPromptSubmit", same casing convention as SessionStart/Stop.
473
- hooksFile.hooks.UserPromptSubmit = removeEmpiricalHooksJsonEntries(hooksFile.hooks.UserPromptSubmit, userPromptScriptPath);
474
- const userPromptCommand = buildHookCommand(userPromptScriptPath);
475
- hooksFile.hooks.UserPromptSubmit.push({
476
- hooks: [{ type: 'command', command: userPromptCommand, commandWindows: userPromptCommand, timeout: 10 }]
477
- });
478
-
479
- writeFile(hooksJsonPath, `${JSON.stringify(hooksFile, null, 2)}\n`);
480
- log(' - Codex SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
481
- }
482
-
483
- // GitHub Copilot CLI's hook config is one JSON file per hook under ~/.copilot/hooks/ (any
484
- // filename — confirmed via GitHub's own docs at docs.github.com/en/copilot/reference/hooks-
485
- // reference), so unlike Claude/Codex this never needs to merge into a shared file: Empirical
486
- // owns this file outright and can just overwrite it. Event key for turn-end is "agentStop"
487
- // ("the main agent finishes a turn" per the same docs) — not "stop"/"Stop" like Claude/Codex.
488
- // Schema: { version: 1, hooks: { sessionStart: [...], agentStop: [...] } }.
489
- function installCopilotHooks(pluginNames) {
490
- const hooksDir = getEmpiricalHooksDir();
491
- ensureDir(hooksDir);
492
-
493
- const sessionStartScriptPath = path.join(hooksDir, 'copilot-session-start.js');
494
- const stopScriptPath = path.join(hooksDir, 'copilot-stop.js');
495
- const userPromptScriptPath = path.join(hooksDir, 'copilot-user-prompt-submit.js');
496
- writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
497
- writeFile(stopScriptPath, EMPIRICAL_HOOK_STOP_SOURCE);
498
- writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
499
-
500
- const copilotHooksDir = path.join(os.homedir(), '.copilot', 'hooks');
501
- ensureDir(copilotHooksDir);
502
-
503
- // Prior versions of this installer wrote a single-purpose "empirical-session-start.json" —
504
- // remove it now that both events live in one unified file, so a stale duplicate registration
505
- // never lingers alongside the new one.
506
- const legacyHookFilePath = path.join(copilotHooksDir, 'empirical-session-start.json');
507
- try {
508
- const fs = require('fs');
509
- if (fs.existsSync(legacyHookFilePath)) fs.unlinkSync(legacyHookFilePath);
510
- } catch (_error) {
511
- // best-effort cleanup only
512
- }
513
-
514
- const hookFilePath = path.join(copilotHooksDir, 'empirical-hooks.json');
515
- const hookFileContent = {
516
- version: 1,
517
- hooks: {
518
- sessionStart: [
519
- { type: 'command', command: buildHookCommand(sessionStartScriptPath), timeoutSec: 15 }
520
- ],
521
- // "userPromptSubmitted" per GitHub's hooks reference ("The user submits a prompt") - not
522
- // "userPromptSubmit" (that camelCase spelling is Claude/Codex's convention, not Copilot's).
523
- userPromptSubmitted: [
524
- { type: 'command', command: buildHookCommand(userPromptScriptPath), timeoutSec: 10 }
525
- ],
526
- agentStop: [
527
- { type: 'command', command: buildHookCommand(stopScriptPath), timeoutSec: 10 }
528
- ]
529
- }
530
- };
531
-
532
- writeFile(hookFilePath, `${JSON.stringify(hookFileContent, null, 2)}\n`);
533
- log(' - Copilot SessionStart + userPromptSubmitted + agentStop hooks installed for deterministic memory lookups and reminders.');
534
- }
535
-
536
- module.exports = {
537
- CLAUDE_HOOK_SESSION_START_SOURCE,
538
- EMPIRICAL_HOOK_STOP_SOURCE,
539
- CLAUDE_HOOK_STOP_SOURCE,
540
- buildPromptReminderMessage,
541
- buildUserPromptSubmitHookSource,
542
- getEmpiricalHooksDir,
543
- buildHookCommand,
544
- removeEmpiricalHookEntries,
545
- installClaudeHooks,
546
- getCodexHomeDir,
547
- removeEmpiricalHooksJsonEntries,
548
- installCodexHooks,
549
- installCopilotHooks
550
- };
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { log } = require('../ui');
6
+ const { ensureDir, writeFile, readJsonIfExists } = require('../fsUtils');
7
+ const { normalizePluginNames } = require('./nativePlugin');
8
+
9
+ const CLAUDE_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
10
+ 'use strict';
11
+
12
+ var spawnSync = require('child_process').spawnSync;
13
+ var fs = require('fs');
14
+ var os = require('os');
15
+ var path = require('path');
16
+
17
+ // Diagnostic breadcrumb log: this hook's own stdout is only visible to Claude Code's SessionStart
18
+ // machinery, not to a human debugging "did this actually fire" after the fact. Every run appends
19
+ // one line here (bounded — see trimLogIfLarge) regardless of success/failure, so firing can be
20
+ // verified by checking file growth/timestamps after starting or resuming a session, without
21
+ // depending on server-side analytics that don't currently tag hook-originated calls.
22
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
23
+ var MAX_LOG_BYTES = 200 * 1024;
24
+
25
+ function appendLog(line) {
26
+ try {
27
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
28
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
29
+ var stat = fs.statSync(LOG_PATH);
30
+ if (stat.size > MAX_LOG_BYTES) {
31
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
32
+ fs.writeFileSync(LOG_PATH, tail);
33
+ }
34
+ } catch (e) {
35
+ // Diagnostics must never break session start.
36
+ }
37
+ }
38
+
39
+ // On Windows, npm's global "empirical" command is a .cmd shim, and .cmd/.bat files can only be
40
+ // executed via the OS shell (cmd.exe), never as a native process — this is an OS constraint, not
41
+ // a Node quirk. But spawnSync's shell:true path joins argv into a single command-line string for
42
+ // cmd.exe to reparse, and Node does not escape special shell characters (spaces, commas, etc.) in
43
+ // that join for you. A --match value like "project context, conventions, recent decisions, user
44
+ // preferences" gets mangled by that reparse, silently truncating the search query and returning
45
+ // far fewer/worse matches than the same query run without a shell in between. The fix: resolve
46
+ // the shim's REAL target (a plain .js file) and invoke that directly via node.exe with shell:false
47
+ // — node.exe is a true native executable, so no shell/reparsing step exists to mangle args.
48
+ function resolveEmpiricalEntryPoint() {
49
+ if (process.platform !== 'win32') {
50
+ // POSIX global installs are usually a symlink straight to the JS entry (or a shebang script)
51
+ // — spawnSync already runs these directly without a shell, so no mangling risk exists there.
52
+ return null;
53
+ }
54
+ try {
55
+ // Parse the .cmd shim itself rather than hardcoding an npm package scope: the published
56
+ // package has already been renamed once (was the empirical scope, now a different scope
57
+ // entirely - the old one is 404 on the registry), so hardcoding either name is a guaranteed
58
+ // future break. npm-generated shims all reference their target relative to dp0 (the shim's
59
+ // own directory) as a node_modules-rooted path ending in bin then a .js file - extracting
60
+ // that pattern works regardless of what the package is scoped or renamed to.
61
+ var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
62
+ if (where.status !== 0 || !where.stdout) return null;
63
+ var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
64
+ if (!cmdPath || !fs.existsSync(cmdPath)) return null;
65
+ var cmdContent = fs.readFileSync(cmdPath, 'utf8');
66
+ var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
67
+ if (!match) return null;
68
+ var candidate = path.join(path.dirname(cmdPath), match[1]);
69
+ return fs.existsSync(candidate) ? candidate : null;
70
+ } catch (e) {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function commandExists(cmd) {
76
+ var probe = process.platform === 'win32'
77
+ ? spawnSync('where', [cmd], { stdio: 'ignore', shell: true })
78
+ : spawnSync('which', [cmd], { stdio: 'ignore' });
79
+ return probe.status === 0;
80
+ }
81
+
82
+ function runMemoryQuery(args) {
83
+ var entryPoint = resolveEmpiricalEntryPoint();
84
+ if (entryPoint) {
85
+ appendLog('resolved direct entry point: ' + entryPoint);
86
+ return spawnSync(process.execPath, [entryPoint].concat(args), {
87
+ encoding: 'utf8',
88
+ timeout: 10000,
89
+ shell: false
90
+ });
91
+ }
92
+
93
+ // Fallback: could not resolve a direct entry point (non-Windows, or resolution failed) — run
94
+ // the "empirical" command as before. On POSIX this is shell:false and safe. On Windows this is
95
+ // shell:true and CAN still mangle args as described above; it only triggers if the direct-entry
96
+ // resolution above failed, so degraded (not absent) results are the acceptable worst case here.
97
+ appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
98
+ return spawnSync('empirical', args, {
99
+ encoding: 'utf8',
100
+ timeout: 10000,
101
+ shell: process.platform === 'win32'
102
+ });
103
+ }
104
+
105
+ function main() {
106
+ appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
107
+
108
+ if (!commandExists('empirical')) {
109
+ appendLog('empirical CLI not found on PATH, exiting');
110
+ process.exit(0);
111
+ }
112
+
113
+ var result = runMemoryQuery([
114
+ 'memory', 'query',
115
+ '--match', 'project context, conventions, recent decisions, user preferences',
116
+ '--top-k', '8'
117
+ ]);
118
+
119
+ if (!result || result.status !== 0 || !result.stdout) {
120
+ appendLog('memory query failed or returned no stdout (status=' + (result && result.status) + ', stderr=' + (result && result.stderr ? String(result.stderr).slice(0, 300) : '') + ')');
121
+ process.exit(0);
122
+ }
123
+
124
+ var results;
125
+ try {
126
+ results = JSON.parse(result.stdout);
127
+ } catch (e) {
128
+ appendLog('failed to parse memory query stdout as JSON: ' + e.message);
129
+ process.exit(0);
130
+ }
131
+
132
+ if (!Array.isArray(results) || results.length === 0) {
133
+ appendLog('memory query returned zero results');
134
+ process.exit(0);
135
+ }
136
+
137
+ appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
138
+
139
+ var lines = results.slice(0, 8).map(function (r) {
140
+ var category = r && r.category ? r.category : 'memory';
141
+ var summary = r && r.summary ? r.summary : '';
142
+ return '- [' + category + '] ' + summary;
143
+ });
144
+
145
+ console.log('Empirical memory context for this session:\\n' + lines.join('\\n'));
146
+ process.exit(0);
147
+ }
148
+
149
+ // Hooks must never surface as a hard failure to the host CLI (Codex/Claude/Copilot all render a
150
+ // nonzero exit as a red "hook failed" error) - an uncaught exception anywhere above (e.g. an
151
+ // environment that denies process spawn or fs access, such as Codex's experimental Windows
152
+ // sandbox) must degrade to a silent no-op, not a visible crash.
153
+ try {
154
+ main();
155
+ } catch (e) {
156
+ try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
157
+ process.exit(0);
158
+ }
159
+ `;
160
+
161
+ // Codex-only variant of the SessionStart hook above. Confirmed live (2026-08-08, via a real
162
+ // `codex exec` run) that Codex requires valid JSON on stdout for a hook's output to actually
163
+ // register/display - plain text (what CLAUDE_HOOK_SESSION_START_SOURCE emits, which Claude Code
164
+ // handles fine) makes Codex report the hook as failed. Uses the same hookSpecificOutput.
165
+ // additionalContext shape Claude's own JSON-output hooks use (see CLAUDE_HOOK_STOP_SOURCE below) -
166
+ // confirmed accepted by Codex's SessionStart handler specifically. Otherwise byte-identical to
167
+ // CLAUDE_HOOK_SESSION_START_SOURCE; keep any future logic changes in sync between the two.
168
+ const CODEX_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
169
+ 'use strict';
170
+
171
+ var spawnSync = require('child_process').spawnSync;
172
+ var fs = require('fs');
173
+ var os = require('os');
174
+ var path = require('path');
175
+
176
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
177
+ var MAX_LOG_BYTES = 200 * 1024;
178
+
179
+ function appendLog(line) {
180
+ try {
181
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
182
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
183
+ var stat = fs.statSync(LOG_PATH);
184
+ if (stat.size > MAX_LOG_BYTES) {
185
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
186
+ fs.writeFileSync(LOG_PATH, tail);
187
+ }
188
+ } catch (e) {
189
+ // Diagnostics must never break session start.
190
+ }
191
+ }
192
+
193
+ function resolveEmpiricalEntryPoint() {
194
+ if (process.platform !== 'win32') {
195
+ return null;
196
+ }
197
+ try {
198
+ var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
199
+ if (where.status !== 0 || !where.stdout) return null;
200
+ var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
201
+ if (!cmdPath || !fs.existsSync(cmdPath)) return null;
202
+ var cmdContent = fs.readFileSync(cmdPath, 'utf8');
203
+ var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
204
+ if (!match) return null;
205
+ var candidate = path.join(path.dirname(cmdPath), match[1]);
206
+ return fs.existsSync(candidate) ? candidate : null;
207
+ } catch (e) {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ function commandExists(cmd) {
213
+ var probe = process.platform === 'win32'
214
+ ? spawnSync('where', [cmd], { stdio: 'ignore', shell: true })
215
+ : spawnSync('which', [cmd], { stdio: 'ignore' });
216
+ return probe.status === 0;
217
+ }
218
+
219
+ function runMemoryQuery(args) {
220
+ var entryPoint = resolveEmpiricalEntryPoint();
221
+ if (entryPoint) {
222
+ appendLog('resolved direct entry point: ' + entryPoint);
223
+ return spawnSync(process.execPath, [entryPoint].concat(args), {
224
+ encoding: 'utf8',
225
+ timeout: 10000,
226
+ shell: false
227
+ });
228
+ }
229
+
230
+ appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
231
+ return spawnSync('empirical', args, {
232
+ encoding: 'utf8',
233
+ timeout: 10000,
234
+ shell: process.platform === 'win32'
235
+ });
236
+ }
237
+
238
+ function main() {
239
+ appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
240
+
241
+ if (!commandExists('empirical')) {
242
+ appendLog('empirical CLI not found on PATH, exiting');
243
+ process.exit(0);
244
+ }
245
+
246
+ var result = runMemoryQuery([
247
+ 'memory', 'query',
248
+ '--match', 'project context, conventions, recent decisions, user preferences',
249
+ '--top-k', '8'
250
+ ]);
251
+
252
+ if (!result || result.status !== 0 || !result.stdout) {
253
+ appendLog('memory query failed or returned no stdout (status=' + (result && result.status) + ', stderr=' + (result && result.stderr ? String(result.stderr).slice(0, 300) : '') + ')');
254
+ process.exit(0);
255
+ }
256
+
257
+ var results;
258
+ try {
259
+ results = JSON.parse(result.stdout);
260
+ } catch (e) {
261
+ appendLog('failed to parse memory query stdout as JSON: ' + e.message);
262
+ process.exit(0);
263
+ }
264
+
265
+ if (!Array.isArray(results) || results.length === 0) {
266
+ appendLog('memory query returned zero results');
267
+ process.exit(0);
268
+ }
269
+
270
+ appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
271
+
272
+ var lines = results.slice(0, 8).map(function (r) {
273
+ var category = r && r.category ? r.category : 'memory';
274
+ var summary = r && r.summary ? r.summary : '';
275
+ return '- [' + category + '] ' + summary;
276
+ });
277
+
278
+ console.log(JSON.stringify({
279
+ hookSpecificOutput: {
280
+ hookEventName: 'SessionStart',
281
+ additionalContext: 'Empirical memory context for this session:\\n' + lines.join('\\n')
282
+ }
283
+ }));
284
+ process.exit(0);
285
+ }
286
+
287
+ try {
288
+ main();
289
+ } catch (e) {
290
+ try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
291
+ process.exit(0);
292
+ }
293
+ `;
294
+
295
+ // EMPIRICAL_HOOK_STOP_SOURCE: fires once per assistant turn (Claude: Stop; Codex: Stop, turn-
296
+ // scoped per codex-rs/hooks/src/engine/dispatcher.rs; Copilot: agentStop, "the main agent
297
+ // finishes a turn" per GitHub's own hooks reference). This is the write-side counterpart to the
298
+ // SessionStart hook above (which only covers recall, once per session boundary) - deliberately
299
+ // simple: it does NOT parse the transcript to detect whether memory-worthy activity happened
300
+ // this turn (transcript formats differ across all three platforms and getting that heuristic
301
+ // wrong silently - the exact class of bug this session spent hours finding - is worse than an
302
+ // unconditional reminder). It never calls the empirical CLI and makes no network call, so the
303
+ // "wasteful per-turn hook" concern that killed the old per-turn UserPromptSubmit hook (see
304
+ // installClaudeHooks below) does not apply here - this is a single static stdout write.
305
+ // ALWAYS exits 0: per Claude Code's documented Stop hook contract, exit 2 forces the agent to
306
+ // keep responding (a blocking decision) - a bug in a hook that's supposed to be an informational
307
+ // nudge must never be able to trap a session in a forced-continuation loop, so this is
308
+ // unconditional regardless of any error encountered.
309
+ const EMPIRICAL_HOOK_STOP_SOURCE = `#!/usr/bin/env node
310
+ 'use strict';
311
+
312
+ var fs = require('fs');
313
+ var os = require('os');
314
+ var path = require('path');
315
+
316
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
317
+ var MAX_LOG_BYTES = 200 * 1024;
318
+
319
+ function appendLog(line) {
320
+ try {
321
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
322
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
323
+ var stat = fs.statSync(LOG_PATH);
324
+ if (stat.size > MAX_LOG_BYTES) {
325
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
326
+ fs.writeFileSync(LOG_PATH, tail);
327
+ }
328
+ } catch (e) {
329
+ // Diagnostics must never break the hook.
330
+ }
331
+ }
332
+
333
+ function readStdinPayload() {
334
+ try {
335
+ var raw = fs.readFileSync(0, 'utf8');
336
+ return raw ? JSON.parse(raw) : {};
337
+ } catch (e) {
338
+ return {};
339
+ }
340
+ }
341
+
342
+ function main() {
343
+ var payload = readStdinPayload();
344
+
345
+ // Defensive loop guard: if a Stop hook's own forced-continuation output somehow triggers
346
+ // another Stop event before the agent produces new output, stop_hook_active (Claude's documented
347
+ // field name for this) prevents a reminder-print-loop. This hook never sets decision:block
348
+ // itself, but checking costs nothing and protects against any future change that does.
349
+ if (payload && payload.stop_hook_active === true) {
350
+ appendLog('stop_hook_active=true, skipping reminder');
351
+ process.exit(0);
352
+ }
353
+
354
+ appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
355
+
356
+ console.log('[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now via the empirical-memory skill or \`empirical memory record\` - do not wait to be asked.');
357
+ process.exit(0);
358
+ }
359
+
360
+ // See EMPIRICAL_HOOK_SESSION_START_SOURCE's identical guard above: a crash here must degrade to a
361
+ // silent no-op, never a visible "hook failed" error in the host CLI.
362
+ try {
363
+ main();
364
+ } catch (e) {
365
+ try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
366
+ process.exit(0);
367
+ }
368
+ `;
369
+
370
+ // Codex-only variant of the Stop reminder above. Confirmed live (2026-08-08, via a real `codex
371
+ // exec` run) that Codex's Stop hook specifically requires a { systemMessage: "..." } JSON shape
372
+ // on stdout - plain text (what EMPIRICAL_HOOK_STOP_SOURCE emits, used by Copilot) makes Codex
373
+ // report the hook as failed. Distinct from Claude's own JSON shape (hookSpecificOutput.
374
+ // additionalContext, see CLAUDE_HOOK_STOP_SOURCE below) - each host has its own schema here.
375
+ const CODEX_HOOK_STOP_SOURCE = `#!/usr/bin/env node
376
+ 'use strict';
377
+
378
+ var fs = require('fs');
379
+ var os = require('os');
380
+ var path = require('path');
381
+
382
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
383
+ var MAX_LOG_BYTES = 200 * 1024;
384
+
385
+ function appendLog(line) {
386
+ try {
387
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
388
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
389
+ var stat = fs.statSync(LOG_PATH);
390
+ if (stat.size > MAX_LOG_BYTES) {
391
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
392
+ fs.writeFileSync(LOG_PATH, tail);
393
+ }
394
+ } catch (e) {
395
+ // Diagnostics must never break the hook.
396
+ }
397
+ }
398
+
399
+ function readStdinPayload() {
400
+ try {
401
+ var raw = fs.readFileSync(0, 'utf8');
402
+ return raw ? JSON.parse(raw) : {};
403
+ } catch (e) {
404
+ return {};
405
+ }
406
+ }
407
+
408
+ function main() {
409
+ var payload = readStdinPayload();
410
+
411
+ if (payload && payload.stop_hook_active === true) {
412
+ appendLog('stop_hook_active=true, skipping reminder');
413
+ process.exit(0);
414
+ }
415
+
416
+ appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
417
+
418
+ console.log(JSON.stringify({
419
+ systemMessage: '[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now via the empirical-memory skill or \`empirical memory record\` - do not wait to be asked.'
420
+ }));
421
+ process.exit(0);
422
+ }
423
+
424
+ try {
425
+ main();
426
+ } catch (e) {
427
+ try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
428
+ process.exit(0);
429
+ }
430
+ `;
431
+
432
+ // Claude-only variant of the Stop reminder above. hookSpecificOutput.additionalContext (stdout,
433
+ // exit 0) is Claude Code's non-blocking Stop-hook feedback channel: the text is fed into context
434
+ // and the conversation continues, same outcome the earlier exit-2 approach achieved, but the
435
+ // transcript UI renders it as a plain system reminder instead of a red "Stop hook error" line.
436
+ // EMPIRICAL_HOOK_STOP_SOURCE above still uses plain console.log + exit 0, which Claude Code treats
437
+ // as non-blocking output that's logged but never reaches the model - that was this hook's original
438
+ // bug (fixed for Claude here, unconfirmed/unfixed for Codex/Copilot's own Stop/agentStop semantics).
439
+ const CLAUDE_HOOK_STOP_SOURCE = `#!/usr/bin/env node
440
+ 'use strict';
441
+
442
+ var fs = require('fs');
443
+ var os = require('os');
444
+ var path = require('path');
445
+
446
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
447
+ var MAX_LOG_BYTES = 200 * 1024;
448
+
449
+ function appendLog(line) {
450
+ try {
451
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
452
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
453
+ var stat = fs.statSync(LOG_PATH);
454
+ if (stat.size > MAX_LOG_BYTES) {
455
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
456
+ fs.writeFileSync(LOG_PATH, tail);
457
+ }
458
+ } catch (e) {
459
+ // Diagnostics must never break the hook.
460
+ }
461
+ }
462
+
463
+ function readStdinPayload() {
464
+ try {
465
+ var raw = fs.readFileSync(0, 'utf8');
466
+ return raw ? JSON.parse(raw) : {};
467
+ } catch (e) {
468
+ return {};
469
+ }
470
+ }
471
+
472
+ function main() {
473
+ var payload = readStdinPayload();
474
+
475
+ // stop_hook_active === true means Claude is re-invoking this hook because a PRIOR Stop hook
476
+ // (possibly this same one) already surfaced additionalContext and forced a continuation this
477
+ // turn. additionalContext causes the same forced-continuation behavior decision:block does -
478
+ // confirmed empirically: without this guard the hook re-fired every ~2-3s in an infinite loop,
479
+ // since each additionalContext output triggers another Stop event. Must exit 0 with no output
480
+ // here, unconditionally, or the loop continues.
481
+ if (payload && payload.stop_hook_active === true) {
482
+ appendLog('stop_hook_active=true, skipping reminder (already surfaced once this turn)');
483
+ process.exit(0);
484
+ }
485
+
486
+ appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + '), surfacing reminder via additionalContext');
487
+
488
+ process.stdout.write(JSON.stringify({
489
+ hookSpecificOutput: {
490
+ hookEventName: 'Stop',
491
+ additionalContext: '[Empirical] Record memory-worthy work now via empirical-memory skill.'
492
+ }
493
+ }) + '\\n');
494
+ process.exit(0);
495
+ }
496
+
497
+ // See EMPIRICAL_HOOK_SESSION_START_SOURCE's identical guard: a crash here must degrade to a
498
+ // silent no-op, never a visible "hook failed" error in the host CLI.
499
+ try {
500
+ main();
501
+ } catch (e) {
502
+ try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
503
+ process.exit(0);
504
+ }
505
+ `;
506
+
507
+ // UserPromptSubmit: fires before each turn is processed (Claude, Codex, and Copilot all support
508
+ // this event under the same name per each platform's docs/source). This is the recall-side
509
+ // counterpart to Stop's write-side reminder - deliberately a STATIC message, not a live
510
+ // `empirical memory query` call: a prior per-turn hook in this codebase was removed for making an
511
+ // unconditional network call on every single prompt (see the comment above installClaudeHooks),
512
+ // and re-adding that cost here on three platforms at once was an explicit no per product decision.
513
+ // The message itself is generated per-install from the actual plugin set being installed, not one
514
+ // fixed string for everyone - e.g. someone who declined empirical-scar-tissue at install time
515
+ // never sees a scar-tissue reminder, since that capability isn't actually available to them.
516
+ function buildPromptReminderMessage(pluginNames) {
517
+ const names = normalizePluginNames(pluginNames);
518
+ const lines = [
519
+ 'Before responding: if this task needs prior context (research, debugging, a spec or status update, a handoff), query Empirical memory first via the empirical-memory skill or empirical memory query - do not assume you already have enough context.'
520
+ ];
521
+ if (names.includes('empirical-work-history')) {
522
+ lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
523
+ }
524
+ if (names.includes('empirical-scar-tissue')) {
525
+ lines.push('Check for scar tissue before repeating a past mistake in this area.');
526
+ }
527
+ if (names.includes('empirical-todo')) {
528
+ lines.push('If there is an open todo for this project, check it before starting new work.');
529
+ }
530
+ return '[Empirical reminder] ' + lines.join(' ');
531
+ }
532
+
533
+ // Builds the full UserPromptSubmit hook script source with the tailored message baked in at
534
+ // generation time (via JSON.stringify, so the message is embedded as a single already-escaped
535
+ // string literal - substituted through `${}`, not written as raw template-literal source text,
536
+ // which is what caused the earlier comment-escaping bug in this same file. Never do the latter).
537
+ // `platform`: 'codex' emits Codex's required hookSpecificOutput.additionalContext JSON shape
538
+ // (confirmed live 2026-08-08 via a real `codex exec` run - plain text made Codex report the hook
539
+ // as failed); any other value (default) keeps the original plain console.log for Claude/Copilot,
540
+ // which is confirmed working for both.
541
+ function buildUserPromptSubmitHookSource(pluginNames, platform) {
542
+ const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames));
543
+ const outputStatement = platform === 'codex'
544
+ ? `console.log(JSON.stringify({
545
+ hookSpecificOutput: {
546
+ hookEventName: 'UserPromptSubmit',
547
+ additionalContext: ${messageLiteral}
548
+ }
549
+ }));`
550
+ : `console.log(${messageLiteral});`;
551
+ return `#!/usr/bin/env node
552
+ 'use strict';
553
+
554
+ var fs = require('fs');
555
+ var os = require('os');
556
+ var path = require('path');
557
+
558
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'user-prompt-submit.log');
559
+ var MAX_LOG_BYTES = 200 * 1024;
560
+
561
+ function appendLog(line) {
562
+ try {
563
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
564
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
565
+ var stat = fs.statSync(LOG_PATH);
566
+ if (stat.size > MAX_LOG_BYTES) {
567
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
568
+ fs.writeFileSync(LOG_PATH, tail);
569
+ }
570
+ } catch (e) {
571
+ // Diagnostics must never break the hook.
572
+ }
573
+ }
574
+
575
+ // A crash here must degrade to a silent no-op, never a visible "hook failed" error in the host CLI.
576
+ try {
577
+ appendLog('user-prompt-submit hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
578
+ ${outputStatement}
579
+ } catch (e) {
580
+ try { appendLog('uncaught error: ' + (e && e.message)); } catch (_e) { /* best effort */ }
581
+ }
582
+ process.exit(0);
583
+ `;
584
+ }
585
+
586
+ function getEmpiricalHooksDir() {
587
+ return path.join(os.homedir(), '.empirical', 'hooks');
588
+ }
589
+
590
+ function buildHookCommand(scriptPath) {
591
+ return `"${process.execPath}" "${scriptPath}"`;
592
+ }
593
+
594
+ function removeEmpiricalHookEntries(entries, scriptPath) {
595
+ if (!Array.isArray(entries)) return [];
596
+ return entries
597
+ .map((entry) => {
598
+ if (!entry || !Array.isArray(entry.hooks)) return entry;
599
+ const filteredHooks = entry.hooks.filter(
600
+ (h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
601
+ );
602
+ return { ...entry, hooks: filteredHooks };
603
+ })
604
+ .filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
605
+ }
606
+
607
+ function installClaudeHooks(pluginNames) {
608
+ const hooksDir = getEmpiricalHooksDir();
609
+ ensureDir(hooksDir);
610
+
611
+ // A prior version of this hook made an unconditional `empirical memory query` network call on
612
+ // every single prompt and was removed for being wasteful (see git history). This is a
613
+ // deliberate reintroduction at the SAME script path, but with fundamentally different content:
614
+ // a static, install-time-generated reminder with no network call at all - the cost concern that
615
+ // killed the old version does not apply to a plain stdout write.
616
+ const userPromptScriptPath = path.join(hooksDir, 'claude-user-prompt-submit.js');
617
+ const sessionStartScriptPath = path.join(hooksDir, 'claude-session-start.js');
618
+ const stopScriptPath = path.join(hooksDir, 'claude-stop.js');
619
+ writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
620
+ writeFile(stopScriptPath, CLAUDE_HOOK_STOP_SOURCE);
621
+ writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
622
+
623
+ const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
624
+ const settings = readJsonIfExists(settingsPath) || {};
625
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
626
+ settings.hooks = {};
627
+ }
628
+
629
+ settings.hooks.UserPromptSubmit = removeEmpiricalHookEntries(settings.hooks.UserPromptSubmit, userPromptScriptPath);
630
+ settings.hooks.UserPromptSubmit.push({
631
+ hooks: [{ type: 'command', command: buildHookCommand(userPromptScriptPath), timeout: 10 }]
632
+ });
633
+
634
+ settings.hooks.SessionStart = removeEmpiricalHookEntries(settings.hooks.SessionStart, sessionStartScriptPath);
635
+ for (const matcher of ['startup', 'resume', 'compact']) {
636
+ settings.hooks.SessionStart.push({
637
+ matcher,
638
+ hooks: [{ type: 'command', command: buildHookCommand(sessionStartScriptPath), timeout: 15 }]
639
+ });
640
+ }
641
+
642
+ settings.hooks.Stop = removeEmpiricalHookEntries(settings.hooks.Stop, stopScriptPath);
643
+ settings.hooks.Stop.push({
644
+ hooks: [{ type: 'command', command: buildHookCommand(stopScriptPath), timeout: 10 }]
645
+ });
646
+
647
+ writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
648
+ log(' - Claude SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
649
+ }
650
+
651
+ // Codex CLI ships a real hooks system (confirmed against openai/codex source — codex-rs/hooks,
652
+ // codex-rs/config/src/hook_config.rs — not assumed from docs alone, since third-party writeups
653
+ // disagreed on Windows support). It reads <CODEX_HOME>/hooks.json (JSON, not the config.toml
654
+ // used for MCP/model settings — a separate file, so this never touches config.toml) shaped as
655
+ // { hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command, commandWindows,
656
+ // timeout }] }], ... } }. CODEX_HOME defaults to ~/.codex when unset, same convention as
657
+ // ~/.claude for Claude Code.
658
+ function getCodexHomeDir() {
659
+ return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
660
+ }
661
+
662
+ function removeEmpiricalHooksJsonEntries(entries, scriptPath) {
663
+ if (!Array.isArray(entries)) return [];
664
+ return entries
665
+ .map((entry) => {
666
+ if (!entry || !Array.isArray(entry.hooks)) return entry;
667
+ const filteredHooks = entry.hooks.filter(
668
+ (h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
669
+ );
670
+ return { ...entry, hooks: filteredHooks };
671
+ })
672
+ .filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
673
+ }
674
+
675
+ // Codex's Windows sandbox (confirmed via `codex doctor` -> "filesystem sandbox: restricted" and
676
+ // ~/.codex/.sandbox/sandbox.*.log's "processed N write roots (read roots delegated)" / "applying
677
+ // read ACLs" at session start) scopes filesystem access per-session to project-specific roots
678
+ // plus, necessarily, CODEX_HOME itself (Codex reads hooks.json, its state db, and its plugin
679
+ // cache from there on every session regardless of project). The shared `~/.empirical/hooks`
680
+ // directory used by Claude/Copilot is neither - so under this sandbox, node.exe most likely
681
+ // cannot even read a hook script living there, which is why hooks fail identically (exit 1)
682
+ // across SessionStart/UserPromptSubmit/Stop regardless of what each script actually does,
683
+ // including the trivial UserPromptSubmit script that spawns no subprocess at all. Claude Code
684
+ // hooks are unaffected (confirmed working from the shared dir) and Copilot has shown no such
685
+ // failure, so only Codex's copies are relocated here - Claude/Copilot keep using
686
+ // getEmpiricalHooksDir() unchanged.
687
+ function getCodexHooksScriptsDir() {
688
+ return path.join(getCodexHomeDir(), 'empirical-hooks');
689
+ }
690
+
691
+ // Confirmed live (2026-08-08, via a real `codex exec` run): Codex's Windows hook launcher fails
692
+ // to parse a quoted executable path (e.g. `"C:\Program Files\nodejs\node.exe" "<script>"`,
693
+ // otherwise a perfectly standard Windows command line - Claude Code and every direct shell/
694
+ // spawnSync invocation handle it fine) and reports the hook as a nonzero-exit failure before any
695
+ // of the script's own JS ever runs. Windows 8.3 short path names never contain spaces, so
696
+ // resolving both the node executable and the script path to their short form and joining them
697
+ // unquoted sidesteps the parser entirely. Computed once at install time (not per hook
698
+ // invocation), so a synchronous shell call here is an acceptable cost.
699
+ function getWindowsShortPath(longPath) {
700
+ if (process.platform !== 'win32') return null;
701
+ try {
702
+ const { execSync } = require('child_process');
703
+ const out = execSync(`cmd /c for %I in ("${longPath}") do @echo %~sI`, { encoding: 'utf8', timeout: 5000 });
704
+ const short = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).pop();
705
+ const fs = require('fs');
706
+ return short && fs.existsSync(short) ? short : null;
707
+ } catch (e) {
708
+ return null;
709
+ }
710
+ }
711
+
712
+ // Builds Codex's commandWindows value using short paths so neither the executable nor the script
713
+ // path ever needs quoting. Falls back to the normal quoted buildHookCommand() form if short-path
714
+ // resolution fails for any reason (e.g. 8.3 name generation disabled on that volume) - degraded
715
+ // (same behavior as before this fix) rather than broken.
716
+ function buildCodexWindowsHookCommand(scriptPath) {
717
+ const shortNode = getWindowsShortPath(process.execPath);
718
+ const shortScript = getWindowsShortPath(scriptPath);
719
+ if (shortNode && shortScript) {
720
+ return `${shortNode} ${shortScript}`;
721
+ }
722
+ return buildHookCommand(scriptPath);
723
+ }
724
+
725
+ function installCodexHooks(pluginNames) {
726
+ const fs = require('fs');
727
+ const legacyHooksDir = getEmpiricalHooksDir();
728
+ const hooksDir = getCodexHooksScriptsDir();
729
+ ensureDir(hooksDir);
730
+
731
+ const sessionStartScriptPath = path.join(hooksDir, 'codex-session-start.js');
732
+ const stopScriptPath = path.join(hooksDir, 'codex-stop.js');
733
+ const userPromptScriptPath = path.join(hooksDir, 'codex-user-prompt-submit.js');
734
+ writeFile(sessionStartScriptPath, CODEX_HOOK_SESSION_START_SOURCE);
735
+ writeFile(stopScriptPath, CODEX_HOOK_STOP_SOURCE);
736
+ writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames, 'codex'));
737
+
738
+ // Best-effort cleanup of the pre-relocation script files/entries at the old shared path, so a
739
+ // machine that already had Codex hooks installed doesn't end up with both the old (broken
740
+ // under the sandbox) and new registration firing redundantly.
741
+ const legacySessionStartScriptPath = path.join(legacyHooksDir, 'codex-session-start.js');
742
+ const legacyStopScriptPath = path.join(legacyHooksDir, 'codex-stop.js');
743
+ const legacyUserPromptScriptPath = path.join(legacyHooksDir, 'codex-user-prompt-submit.js');
744
+ for (const legacyPath of [legacySessionStartScriptPath, legacyStopScriptPath, legacyUserPromptScriptPath]) {
745
+ try {
746
+ if (fs.existsSync(legacyPath)) fs.unlinkSync(legacyPath);
747
+ } catch (_error) {
748
+ // best-effort cleanup only
749
+ }
750
+ }
751
+
752
+ const hooksJsonPath = path.join(getCodexHomeDir(), 'hooks.json');
753
+ const hooksFile = readJsonIfExists(hooksJsonPath) || {};
754
+ if (!hooksFile.hooks || typeof hooksFile.hooks !== 'object') {
755
+ hooksFile.hooks = {};
756
+ }
757
+
758
+ hooksFile.hooks.SessionStart = removeEmpiricalHooksJsonEntries(
759
+ removeEmpiricalHooksJsonEntries(hooksFile.hooks.SessionStart, sessionStartScriptPath),
760
+ legacySessionStartScriptPath
761
+ );
762
+ const sessionStartCommand = buildHookCommand(sessionStartScriptPath);
763
+ const sessionStartCommandWindows = buildCodexWindowsHookCommand(sessionStartScriptPath);
764
+ for (const matcher of ['startup', 'resume']) {
765
+ hooksFile.hooks.SessionStart.push({
766
+ matcher,
767
+ hooks: [{ type: 'command', command: sessionStartCommand, commandWindows: sessionStartCommandWindows, timeout: 15 }]
768
+ });
769
+ }
770
+
771
+ hooksFile.hooks.Stop = removeEmpiricalHooksJsonEntries(
772
+ removeEmpiricalHooksJsonEntries(hooksFile.hooks.Stop, stopScriptPath),
773
+ legacyStopScriptPath
774
+ );
775
+ const stopCommand = buildHookCommand(stopScriptPath);
776
+ const stopCommandWindows = buildCodexWindowsHookCommand(stopScriptPath);
777
+ hooksFile.hooks.Stop.push({
778
+ hooks: [{ type: 'command', command: stopCommand, commandWindows: stopCommandWindows, timeout: 10 }]
779
+ });
780
+
781
+ // Confirmed against codex-rs/config/src/hook_config.rs's HookEventsToml serde renames: the
782
+ // config-file key is PascalCase "UserPromptSubmit", same casing convention as SessionStart/Stop.
783
+ hooksFile.hooks.UserPromptSubmit = removeEmpiricalHooksJsonEntries(
784
+ removeEmpiricalHooksJsonEntries(hooksFile.hooks.UserPromptSubmit, userPromptScriptPath),
785
+ legacyUserPromptScriptPath
786
+ );
787
+ const userPromptCommand = buildHookCommand(userPromptScriptPath);
788
+ const userPromptCommandWindows = buildCodexWindowsHookCommand(userPromptScriptPath);
789
+ hooksFile.hooks.UserPromptSubmit.push({
790
+ hooks: [{ type: 'command', command: userPromptCommand, commandWindows: userPromptCommandWindows, timeout: 10 }]
791
+ });
792
+
793
+ writeFile(hooksJsonPath, `${JSON.stringify(hooksFile, null, 2)}\n`);
794
+ log(' - Codex SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
795
+ }
796
+
797
+ // GitHub Copilot CLI's hook config is one JSON file per hook under ~/.copilot/hooks/ (any
798
+ // filename — confirmed via GitHub's own docs at docs.github.com/en/copilot/reference/hooks-
799
+ // reference), so unlike Claude/Codex this never needs to merge into a shared file: Empirical
800
+ // owns this file outright and can just overwrite it. Event key for turn-end is "agentStop"
801
+ // ("the main agent finishes a turn" per the same docs) — not "stop"/"Stop" like Claude/Codex.
802
+ // Schema: { version: 1, hooks: { sessionStart: [...], agentStop: [...] } }.
803
+ function installCopilotHooks(pluginNames) {
804
+ const hooksDir = getEmpiricalHooksDir();
805
+ ensureDir(hooksDir);
806
+
807
+ const sessionStartScriptPath = path.join(hooksDir, 'copilot-session-start.js');
808
+ const stopScriptPath = path.join(hooksDir, 'copilot-stop.js');
809
+ const userPromptScriptPath = path.join(hooksDir, 'copilot-user-prompt-submit.js');
810
+ writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
811
+ writeFile(stopScriptPath, EMPIRICAL_HOOK_STOP_SOURCE);
812
+ writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
813
+
814
+ const copilotHooksDir = path.join(os.homedir(), '.copilot', 'hooks');
815
+ ensureDir(copilotHooksDir);
816
+
817
+ // Prior versions of this installer wrote a single-purpose "empirical-session-start.json" —
818
+ // remove it now that both events live in one unified file, so a stale duplicate registration
819
+ // never lingers alongside the new one.
820
+ const legacyHookFilePath = path.join(copilotHooksDir, 'empirical-session-start.json');
821
+ try {
822
+ const fs = require('fs');
823
+ if (fs.existsSync(legacyHookFilePath)) fs.unlinkSync(legacyHookFilePath);
824
+ } catch (_error) {
825
+ // best-effort cleanup only
826
+ }
827
+
828
+ const hookFilePath = path.join(copilotHooksDir, 'empirical-hooks.json');
829
+ const hookFileContent = {
830
+ version: 1,
831
+ hooks: {
832
+ sessionStart: [
833
+ { type: 'command', command: buildHookCommand(sessionStartScriptPath), timeoutSec: 15 }
834
+ ],
835
+ // "userPromptSubmitted" per GitHub's hooks reference ("The user submits a prompt") - not
836
+ // "userPromptSubmit" (that camelCase spelling is Claude/Codex's convention, not Copilot's).
837
+ userPromptSubmitted: [
838
+ { type: 'command', command: buildHookCommand(userPromptScriptPath), timeoutSec: 10 }
839
+ ],
840
+ agentStop: [
841
+ { type: 'command', command: buildHookCommand(stopScriptPath), timeoutSec: 10 }
842
+ ]
843
+ }
844
+ };
845
+
846
+ writeFile(hookFilePath, `${JSON.stringify(hookFileContent, null, 2)}\n`);
847
+ log(' - Copilot SessionStart + userPromptSubmitted + agentStop hooks installed for deterministic memory lookups and reminders.');
848
+ }
849
+
850
+ module.exports = {
851
+ CLAUDE_HOOK_SESSION_START_SOURCE,
852
+ CODEX_HOOK_SESSION_START_SOURCE,
853
+ EMPIRICAL_HOOK_STOP_SOURCE,
854
+ CODEX_HOOK_STOP_SOURCE,
855
+ CLAUDE_HOOK_STOP_SOURCE,
856
+ buildPromptReminderMessage,
857
+ buildUserPromptSubmitHookSource,
858
+ getEmpiricalHooksDir,
859
+ buildHookCommand,
860
+ removeEmpiricalHookEntries,
861
+ installClaudeHooks,
862
+ getCodexHomeDir,
863
+ removeEmpiricalHooksJsonEntries,
864
+ getCodexHooksScriptsDir,
865
+ getWindowsShortPath,
866
+ buildCodexWindowsHookCommand,
867
+ installCodexHooks,
868
+ installCopilotHooks
869
+ };