@empirical-gauzza/cli 1.0.37 → 1.0.38
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.
- package/bin/empirical.js +1 -1
- package/lib/attachmentCommands.js +388 -0
- package/lib/constants.js +50 -0
- package/lib/empiricalTokensFormat.js +6 -4
- package/lib/help.js +12 -0
- package/lib/http.js +160 -0
- package/lib/installers/hooks.js +1312 -1312
- package/lib/installers/skill-variants/SKILL.both.md +20 -0
- package/lib/installers/skill-variants/SKILL.cli.md +231 -221
- package/lib/installers/skill-variants/SKILL.mcp.md +18 -0
- package/lib/memoryCommands.js +1267 -1222
- package/lib/memoryResolve.js +112 -0
- package/lib/setupCommands.js +191 -14
- package/lib/workspaceCommands.js +72 -5
- package/lib/workspaceContext.js +176 -176
- package/package.json +33 -33
package/lib/installers/hooks.js
CHANGED
|
@@ -1,1312 +1,1312 @@
|
|
|
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
|
-
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
89
|
-
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
90
|
-
timeout: 20000,
|
|
91
|
-
shell: false
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Fallback: could not resolve a direct entry point (non-Windows, or resolution failed) — run
|
|
96
|
-
// the "empirical" command as before. On POSIX this is shell:false and safe. On Windows this is
|
|
97
|
-
// shell:true and CAN still mangle args as described above; it only triggers if the direct-entry
|
|
98
|
-
// resolution above failed, so degraded (not absent) results are the acceptable worst case here.
|
|
99
|
-
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
100
|
-
return spawnSync('empirical', args, {
|
|
101
|
-
encoding: 'utf8',
|
|
102
|
-
timeout: 20000,
|
|
103
|
-
shell: process.platform === 'win32'
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function main() {
|
|
108
|
-
appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
109
|
-
|
|
110
|
-
if (!commandExists('empirical')) {
|
|
111
|
-
appendLog('empirical CLI not found on PATH, exiting');
|
|
112
|
-
process.exit(0);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Declared inside main() on purpose: function declarations hoist, and other hook templates in
|
|
116
|
-
// this file have their own main() without the memory-query plumbing.
|
|
117
|
-
|
|
118
|
-
// WHAT PROJECT IS THIS, in priority order. Guessing from the directory name is the last resort,
|
|
119
|
-
// not the contract: a repo folder is not always what the corpus tags memories with.
|
|
120
|
-
// 1. EMPIRICAL_PROJECT_TAG / EMPIRICAL_TOPICS environment variables
|
|
121
|
-
// 2. .empirical.json in the working directory or any ancestor, up to 6 levels:
|
|
122
|
-
// { "project": "empirical_v2", "topics": ["search ranking", "deploy"] }
|
|
123
|
-
// 3. the working directory name, lowercased
|
|
124
|
-
function readProjectConfig() {
|
|
125
|
-
var config = { project: '', topics: [] };
|
|
126
|
-
try {
|
|
127
|
-
var dir = process.cwd();
|
|
128
|
-
for (var depth = 0; depth < 6; depth += 1) {
|
|
129
|
-
var candidate = path.join(dir, '.empirical.json');
|
|
130
|
-
if (fs.existsSync(candidate)) {
|
|
131
|
-
var parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
132
|
-
config.project = String(parsed && parsed.project ? parsed.project : '').trim().toLowerCase();
|
|
133
|
-
if (parsed && Array.isArray(parsed.topics)) {
|
|
134
|
-
config.topics = parsed.topics.map(function (t) { return String(t || '').trim(); }).filter(Boolean);
|
|
135
|
-
}
|
|
136
|
-
appendLog('loaded project config from ' + candidate);
|
|
137
|
-
return config;
|
|
138
|
-
}
|
|
139
|
-
var parent = path.dirname(dir);
|
|
140
|
-
if (!parent || parent === dir) { break; }
|
|
141
|
-
dir = parent;
|
|
142
|
-
}
|
|
143
|
-
} catch (e) {
|
|
144
|
-
appendLog('project config unreadable, continuing without it: ' + e.message);
|
|
145
|
-
}
|
|
146
|
-
return config;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// requireAll selects --tags-all (AND) over --tags (OR). This matters: --tags is an OR match, so
|
|
150
|
-
// asking for 'empirical_v2,todo' returns every project's todos, which put a wellnessonbalance
|
|
151
|
-
// item into an empirical_v2 session during testing on 2026-09-16.
|
|
152
|
-
function fetchMemories(matchText, tags, topK, requireAll) {
|
|
153
|
-
var args = ['memory', 'query', '--match', matchText, '--top-k', String(topK)];
|
|
154
|
-
if (tags) { args.push(requireAll ? '--tags-all' : '--tags', tags); }
|
|
155
|
-
var queryResult = runMemoryQuery(args);
|
|
156
|
-
if (!queryResult || queryResult.status !== 0 || !queryResult.stdout) {
|
|
157
|
-
appendLog('memory query failed (tags=' + (tags || 'none') + ', status=' + (queryResult && queryResult.status) + ', stderr=' + (queryResult && queryResult.stderr ? String(queryResult.stderr).slice(0, 200) : '') + ')');
|
|
158
|
-
return [];
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
var parsed = JSON.parse(queryResult.stdout);
|
|
162
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
163
|
-
} catch (e) {
|
|
164
|
-
appendLog('failed to parse memory query stdout as JSON: ' + e.message);
|
|
165
|
-
return [];
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
var fileConfig = readProjectConfig();
|
|
170
|
-
var envTag = String(process.env.EMPIRICAL_PROJECT_TAG || '').trim().toLowerCase();
|
|
171
|
-
var envTopics = String(process.env.EMPIRICAL_TOPICS || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
|
172
|
-
|
|
173
|
-
var tag = envTag || fileConfig.project;
|
|
174
|
-
var tagSource = envTag ? 'env' : (fileConfig.project ? 'config' : 'cwd');
|
|
175
|
-
if (!tag) {
|
|
176
|
-
try {
|
|
177
|
-
tag = String(path.basename(process.cwd() || '') || '').trim().toLowerCase();
|
|
178
|
-
} catch (e) {
|
|
179
|
-
tag = '';
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
var topics = envTopics.length ? envTopics : fileConfig.topics;
|
|
184
|
-
var contextMatch = topics.length
|
|
185
|
-
? topics.join(', ')
|
|
186
|
-
: 'project context, conventions, recent decisions, user preferences';
|
|
187
|
-
|
|
188
|
-
appendLog('project tag=' + (tag || '(none)') + ' (source=' + tagSource + '), topics=' + (topics.length ? topics.join('|') : '(default)'));
|
|
189
|
-
|
|
190
|
-
// Scope every query to the project. Measured 2026-09-16: with no tag filter all 8 slots went to
|
|
191
|
-
// one unrelated project during a full session in a different repo, so the only automatic context
|
|
192
|
-
// rail in the product was spending every slot on the wrong thing. A tag filter also disables the
|
|
193
|
-
// server's agent-process-note exclusion, which is what makes a project's own scar tissue and
|
|
194
|
-
// work history reachable here at all.
|
|
195
|
-
//
|
|
196
|
-
// Open items get their OWN query instead of competing for rank. Tested 2026-09-16: a due-dated
|
|
197
|
-
// memory did not surface in the generic top-8 with or without a project filter, because dueness
|
|
198
|
-
// is not a semantic property. Anything that must be seen needs a reserved slot.
|
|
199
|
-
// AND, not OR: this must be THIS project's todos. No --status filter on purpose, because todos
|
|
200
|
-
// written without a data.status would silently vanish from the one place they get surfaced;
|
|
201
|
-
// the label tells the reader to confirm instead.
|
|
202
|
-
var todos = tag ? fetchMemories('open todo, next action, follow-up, due', tag + ',todo', 3, true) : [];
|
|
203
|
-
var context = tag ? fetchMemories(contextMatch, tag, 6) : [];
|
|
204
|
-
|
|
205
|
-
// A corpus whose tags do not match the resolved project must never end up worse off than before
|
|
206
|
-
// this change, so fall back to the original unscoped query.
|
|
207
|
-
if (context.length === 0) {
|
|
208
|
-
appendLog('project-scoped query returned nothing, falling back to unscoped');
|
|
209
|
-
context = fetchMemories(contextMatch, '', 8);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (todos.length === 0 && context.length === 0) {
|
|
213
|
-
appendLog('memory query returned zero results');
|
|
214
|
-
process.exit(0);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
var openCount = todos.length;
|
|
218
|
-
var results = todos.concat(context);
|
|
219
|
-
|
|
220
|
-
appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
|
|
221
|
-
|
|
222
|
-
// Summaries in a mature corpus run to thousands of characters. Injecting eight in full is a wall
|
|
223
|
-
// of text that gets skimmed past, which fails the same way as never injecting them at all:
|
|
224
|
-
// presence in context is not attention. Truncate hard; the agent can query for the rest.
|
|
225
|
-
var lines = results.slice(0, 9).map(function (r, i) {
|
|
226
|
-
var category = r && r.category ? r.category : 'memory';
|
|
227
|
-
var summary = String(r && r.summary ? r.summary : '');
|
|
228
|
-
summary = summary.split('\\n').join(' ').split('\\r').join(' ').split('\\t').join(' ').trim();
|
|
229
|
-
if (summary.length > 240) { summary = summary.slice(0, 237) + '...'; }
|
|
230
|
-
var prefix = i < openCount ? '- [OPEN ITEM, confirm status] ' : '- [' + category + '] ';
|
|
231
|
-
return prefix + summary;
|
|
232
|
-
});
|
|
233
|
-
lines.push('- Before risky or repeated work, ask first: empirical memory query --match "<what you are about to do>" --tags scar-tissue');
|
|
234
|
-
|
|
235
|
-
console.log('Empirical memory context for this session:\\n' + lines.join('\\n'));
|
|
236
|
-
process.exit(0);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Hooks must never surface as a hard failure to the host CLI (Codex/Claude/Copilot all render a
|
|
240
|
-
// nonzero exit as a red "hook failed" error) - an uncaught exception anywhere above (e.g. an
|
|
241
|
-
// environment that denies process spawn or fs access, such as Codex's experimental Windows
|
|
242
|
-
// sandbox) must degrade to a silent no-op, not a visible crash.
|
|
243
|
-
try {
|
|
244
|
-
main();
|
|
245
|
-
} catch (e) {
|
|
246
|
-
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
247
|
-
process.exit(0);
|
|
248
|
-
}
|
|
249
|
-
`;
|
|
250
|
-
|
|
251
|
-
// Codex-only variant of the SessionStart hook above. Confirmed live (2026-08-08, via a real
|
|
252
|
-
// `codex exec` run) that Codex requires valid JSON on stdout for a hook's output to actually
|
|
253
|
-
// register/display - plain text (what CLAUDE_HOOK_SESSION_START_SOURCE emits, which Claude Code
|
|
254
|
-
// handles fine) makes Codex report the hook as failed. Uses the same hookSpecificOutput.
|
|
255
|
-
// additionalContext shape Claude's own JSON-output hooks use (see CLAUDE_HOOK_STOP_SOURCE below) -
|
|
256
|
-
// confirmed accepted by Codex's SessionStart handler specifically. Otherwise byte-identical to
|
|
257
|
-
// CLAUDE_HOOK_SESSION_START_SOURCE; keep any future logic changes in sync between the two.
|
|
258
|
-
const CODEX_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
|
|
259
|
-
'use strict';
|
|
260
|
-
|
|
261
|
-
var spawnSync = require('child_process').spawnSync;
|
|
262
|
-
var fs = require('fs');
|
|
263
|
-
var os = require('os');
|
|
264
|
-
var path = require('path');
|
|
265
|
-
|
|
266
|
-
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
|
|
267
|
-
var MAX_LOG_BYTES = 200 * 1024;
|
|
268
|
-
|
|
269
|
-
function appendLog(line) {
|
|
270
|
-
try {
|
|
271
|
-
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
272
|
-
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
273
|
-
var stat = fs.statSync(LOG_PATH);
|
|
274
|
-
if (stat.size > MAX_LOG_BYTES) {
|
|
275
|
-
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
276
|
-
fs.writeFileSync(LOG_PATH, tail);
|
|
277
|
-
}
|
|
278
|
-
} catch (e) {
|
|
279
|
-
// Diagnostics must never break session start.
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function resolveEmpiricalEntryPoint() {
|
|
284
|
-
if (process.platform !== 'win32') {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
try {
|
|
288
|
-
var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
|
|
289
|
-
if (where.status !== 0 || !where.stdout) return null;
|
|
290
|
-
var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
|
|
291
|
-
if (!cmdPath || !fs.existsSync(cmdPath)) return null;
|
|
292
|
-
var cmdContent = fs.readFileSync(cmdPath, 'utf8');
|
|
293
|
-
var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
|
|
294
|
-
if (!match) return null;
|
|
295
|
-
var candidate = path.join(path.dirname(cmdPath), match[1]);
|
|
296
|
-
return fs.existsSync(candidate) ? candidate : null;
|
|
297
|
-
} catch (e) {
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function commandExists(cmd) {
|
|
303
|
-
var probe = process.platform === 'win32'
|
|
304
|
-
? spawnSync('where', [cmd], { stdio: 'ignore', shell: true })
|
|
305
|
-
: spawnSync('which', [cmd], { stdio: 'ignore' });
|
|
306
|
-
return probe.status === 0;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function runMemoryQuery(args) {
|
|
310
|
-
var entryPoint = resolveEmpiricalEntryPoint();
|
|
311
|
-
if (entryPoint) {
|
|
312
|
-
appendLog('resolved direct entry point: ' + entryPoint);
|
|
313
|
-
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
314
|
-
encoding: 'utf8',
|
|
315
|
-
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
316
|
-
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
317
|
-
timeout: 20000,
|
|
318
|
-
shell: false
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
323
|
-
return spawnSync('empirical', args, {
|
|
324
|
-
encoding: 'utf8',
|
|
325
|
-
timeout: 20000,
|
|
326
|
-
shell: process.platform === 'win32'
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Codex requires VALID JSON on stdout for every hook run. Emitting nothing is as broken as
|
|
331
|
-
// emitting plain text: both surface to the user as "SessionStart hook (failed)". Every early
|
|
332
|
-
// return below is a legitimate "no memory context to add" outcome (CLI missing, offline,
|
|
333
|
-
// expired token, zero results), so each one now emits a well-formed envelope with an empty
|
|
334
|
-
// context instead of exiting silently. Found 2026-09-09: with the API unreachable this hook
|
|
335
|
-
// printed nothing at all and exited 0.
|
|
336
|
-
function emitContext(context) {
|
|
337
|
-
console.log(JSON.stringify({
|
|
338
|
-
hookSpecificOutput: {
|
|
339
|
-
hookEventName: 'SessionStart',
|
|
340
|
-
additionalContext: context || ''
|
|
341
|
-
}
|
|
342
|
-
}));
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function main() {
|
|
346
|
-
appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
347
|
-
|
|
348
|
-
if (!commandExists('empirical')) {
|
|
349
|
-
appendLog('empirical CLI not found on PATH, exiting');
|
|
350
|
-
emitContext('');
|
|
351
|
-
process.exit(0);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Declared inside main() on purpose: function declarations hoist, and other hook templates in
|
|
355
|
-
// this file have their own main() without the memory-query plumbing.
|
|
356
|
-
|
|
357
|
-
// WHAT PROJECT IS THIS, in priority order. Guessing from the directory name is the last resort,
|
|
358
|
-
// not the contract: a repo folder is not always what the corpus tags memories with.
|
|
359
|
-
// 1. EMPIRICAL_PROJECT_TAG / EMPIRICAL_TOPICS environment variables
|
|
360
|
-
// 2. .empirical.json in the working directory or any ancestor, up to 6 levels:
|
|
361
|
-
// { "project": "empirical_v2", "topics": ["search ranking", "deploy"] }
|
|
362
|
-
// 3. the working directory name, lowercased
|
|
363
|
-
function readProjectConfig() {
|
|
364
|
-
var config = { project: '', topics: [] };
|
|
365
|
-
try {
|
|
366
|
-
var dir = process.cwd();
|
|
367
|
-
for (var depth = 0; depth < 6; depth += 1) {
|
|
368
|
-
var candidate = path.join(dir, '.empirical.json');
|
|
369
|
-
if (fs.existsSync(candidate)) {
|
|
370
|
-
var parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
371
|
-
config.project = String(parsed && parsed.project ? parsed.project : '').trim().toLowerCase();
|
|
372
|
-
if (parsed && Array.isArray(parsed.topics)) {
|
|
373
|
-
config.topics = parsed.topics.map(function (t) { return String(t || '').trim(); }).filter(Boolean);
|
|
374
|
-
}
|
|
375
|
-
appendLog('loaded project config from ' + candidate);
|
|
376
|
-
return config;
|
|
377
|
-
}
|
|
378
|
-
var parent = path.dirname(dir);
|
|
379
|
-
if (!parent || parent === dir) { break; }
|
|
380
|
-
dir = parent;
|
|
381
|
-
}
|
|
382
|
-
} catch (e) {
|
|
383
|
-
appendLog('project config unreadable, continuing without it: ' + e.message);
|
|
384
|
-
}
|
|
385
|
-
return config;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// requireAll selects --tags-all (AND) over --tags (OR). This matters: --tags is an OR match, so
|
|
389
|
-
// asking for 'empirical_v2,todo' returns every project's todos, which put a wellnessonbalance
|
|
390
|
-
// item into an empirical_v2 session during testing on 2026-09-16.
|
|
391
|
-
function fetchMemories(matchText, tags, topK, requireAll) {
|
|
392
|
-
var args = ['memory', 'query', '--match', matchText, '--top-k', String(topK)];
|
|
393
|
-
if (tags) { args.push(requireAll ? '--tags-all' : '--tags', tags); }
|
|
394
|
-
var queryResult = runMemoryQuery(args);
|
|
395
|
-
if (!queryResult || queryResult.status !== 0 || !queryResult.stdout) {
|
|
396
|
-
appendLog('memory query failed (tags=' + (tags || 'none') + ', status=' + (queryResult && queryResult.status) + ', stderr=' + (queryResult && queryResult.stderr ? String(queryResult.stderr).slice(0, 200) : '') + ')');
|
|
397
|
-
return [];
|
|
398
|
-
}
|
|
399
|
-
try {
|
|
400
|
-
var parsed = JSON.parse(queryResult.stdout);
|
|
401
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
402
|
-
} catch (e) {
|
|
403
|
-
appendLog('failed to parse memory query stdout as JSON: ' + e.message);
|
|
404
|
-
return [];
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
var fileConfig = readProjectConfig();
|
|
409
|
-
var envTag = String(process.env.EMPIRICAL_PROJECT_TAG || '').trim().toLowerCase();
|
|
410
|
-
var envTopics = String(process.env.EMPIRICAL_TOPICS || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
|
411
|
-
|
|
412
|
-
var tag = envTag || fileConfig.project;
|
|
413
|
-
var tagSource = envTag ? 'env' : (fileConfig.project ? 'config' : 'cwd');
|
|
414
|
-
if (!tag) {
|
|
415
|
-
try {
|
|
416
|
-
tag = String(path.basename(process.cwd() || '') || '').trim().toLowerCase();
|
|
417
|
-
} catch (e) {
|
|
418
|
-
tag = '';
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
var topics = envTopics.length ? envTopics : fileConfig.topics;
|
|
423
|
-
var contextMatch = topics.length
|
|
424
|
-
? topics.join(', ')
|
|
425
|
-
: 'project context, conventions, recent decisions, user preferences';
|
|
426
|
-
|
|
427
|
-
appendLog('project tag=' + (tag || '(none)') + ' (source=' + tagSource + '), topics=' + (topics.length ? topics.join('|') : '(default)'));
|
|
428
|
-
|
|
429
|
-
// Scope every query to the project. Measured 2026-09-16: with no tag filter all 8 slots went to
|
|
430
|
-
// one unrelated project during a full session in a different repo, so the only automatic context
|
|
431
|
-
// rail in the product was spending every slot on the wrong thing. A tag filter also disables the
|
|
432
|
-
// server's agent-process-note exclusion, which is what makes a project's own scar tissue and
|
|
433
|
-
// work history reachable here at all.
|
|
434
|
-
//
|
|
435
|
-
// Open items get their OWN query instead of competing for rank. Tested 2026-09-16: a due-dated
|
|
436
|
-
// memory did not surface in the generic top-8 with or without a project filter, because dueness
|
|
437
|
-
// is not a semantic property. Anything that must be seen needs a reserved slot.
|
|
438
|
-
// AND, not OR: this must be THIS project's todos. No --status filter on purpose, because todos
|
|
439
|
-
// written without a data.status would silently vanish from the one place they get surfaced;
|
|
440
|
-
// the label tells the reader to confirm instead.
|
|
441
|
-
var todos = tag ? fetchMemories('open todo, next action, follow-up, due', tag + ',todo', 3, true) : [];
|
|
442
|
-
var context = tag ? fetchMemories(contextMatch, tag, 6) : [];
|
|
443
|
-
|
|
444
|
-
// A corpus whose tags do not match the resolved project must never end up worse off than before
|
|
445
|
-
// this change, so fall back to the original unscoped query.
|
|
446
|
-
if (context.length === 0) {
|
|
447
|
-
appendLog('project-scoped query returned nothing, falling back to unscoped');
|
|
448
|
-
context = fetchMemories(contextMatch, '', 8);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (todos.length === 0 && context.length === 0) {
|
|
452
|
-
appendLog('memory query returned zero results');
|
|
453
|
-
emitContext('');
|
|
454
|
-
process.exit(0);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
var openCount = todos.length;
|
|
458
|
-
var results = todos.concat(context);
|
|
459
|
-
|
|
460
|
-
appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
|
|
461
|
-
|
|
462
|
-
// Summaries in a mature corpus run to thousands of characters. Injecting eight in full is a wall
|
|
463
|
-
// of text that gets skimmed past, which fails the same way as never injecting them at all:
|
|
464
|
-
// presence in context is not attention. Truncate hard; the agent can query for the rest.
|
|
465
|
-
var lines = results.slice(0, 9).map(function (r, i) {
|
|
466
|
-
var category = r && r.category ? r.category : 'memory';
|
|
467
|
-
var summary = String(r && r.summary ? r.summary : '');
|
|
468
|
-
summary = summary.split('\\n').join(' ').split('\\r').join(' ').split('\\t').join(' ').trim();
|
|
469
|
-
if (summary.length > 240) { summary = summary.slice(0, 237) + '...'; }
|
|
470
|
-
var prefix = i < openCount ? '- [OPEN ITEM, confirm status] ' : '- [' + category + '] ';
|
|
471
|
-
return prefix + summary;
|
|
472
|
-
});
|
|
473
|
-
lines.push('- Before risky or repeated work, ask first: empirical memory query --match "<what you are about to do>" --tags scar-tissue');
|
|
474
|
-
|
|
475
|
-
console.log(JSON.stringify({
|
|
476
|
-
hookSpecificOutput: {
|
|
477
|
-
hookEventName: 'SessionStart',
|
|
478
|
-
additionalContext: 'Empirical memory context for this session:\\n' + lines.join('\\n')
|
|
479
|
-
}
|
|
480
|
-
}));
|
|
481
|
-
process.exit(0);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
try {
|
|
485
|
-
main();
|
|
486
|
-
} catch (e) {
|
|
487
|
-
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
488
|
-
try { emitContext(''); } catch (_e2) { /* stdout already unusable */ }
|
|
489
|
-
process.exit(0);
|
|
490
|
-
}
|
|
491
|
-
`;
|
|
492
|
-
|
|
493
|
-
// Copilot-only variant of the SessionStart hook. Per GitHub's own hooks reference (fetched
|
|
494
|
-
// 2026-08-08): unlike Claude/Codex, Copilot's sessionStart event has NO output mechanism at all
|
|
495
|
-
// - there is no additionalContext-equivalent field, so a hook cannot inject memory context into
|
|
496
|
-
// the session this way, full stop (this is a genuine platform limitation, not a bug to work
|
|
497
|
-
// around). Separately, Copilot requires ALL hook stdout to be valid JSON parsed via a single
|
|
498
|
-
// JSON.parse call - plain text (fine for Claude, and previously assumed fine here too) is
|
|
499
|
-
// invalid and makes Copilot log a hook execution failure. So this variant still runs the memory
|
|
500
|
-
// query and logs the result for diagnostic/audit purposes, but emits {} (empty, valid JSON,
|
|
501
|
-
// explicitly documented as "use defaults") instead of attempting to inject anything.
|
|
502
|
-
const COPILOT_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
|
|
503
|
-
'use strict';
|
|
504
|
-
|
|
505
|
-
var spawnSync = require('child_process').spawnSync;
|
|
506
|
-
var fs = require('fs');
|
|
507
|
-
var os = require('os');
|
|
508
|
-
var path = require('path');
|
|
509
|
-
|
|
510
|
-
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
|
|
511
|
-
var MAX_LOG_BYTES = 200 * 1024;
|
|
512
|
-
|
|
513
|
-
function appendLog(line) {
|
|
514
|
-
try {
|
|
515
|
-
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
516
|
-
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
517
|
-
var stat = fs.statSync(LOG_PATH);
|
|
518
|
-
if (stat.size > MAX_LOG_BYTES) {
|
|
519
|
-
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
520
|
-
fs.writeFileSync(LOG_PATH, tail);
|
|
521
|
-
}
|
|
522
|
-
} catch (e) {
|
|
523
|
-
// Diagnostics must never break session start.
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function resolveEmpiricalEntryPoint() {
|
|
528
|
-
if (process.platform !== 'win32') {
|
|
529
|
-
return null;
|
|
530
|
-
}
|
|
531
|
-
try {
|
|
532
|
-
var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
|
|
533
|
-
if (where.status !== 0 || !where.stdout) return null;
|
|
534
|
-
var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
|
|
535
|
-
if (!cmdPath || !fs.existsSync(cmdPath)) return null;
|
|
536
|
-
var cmdContent = fs.readFileSync(cmdPath, 'utf8');
|
|
537
|
-
var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
|
|
538
|
-
if (!match) return null;
|
|
539
|
-
var candidate = path.join(path.dirname(cmdPath), match[1]);
|
|
540
|
-
return fs.existsSync(candidate) ? candidate : null;
|
|
541
|
-
} catch (e) {
|
|
542
|
-
return null;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function commandExists(cmd) {
|
|
547
|
-
var probe = process.platform === 'win32'
|
|
548
|
-
? spawnSync('where', [cmd], { stdio: 'ignore', shell: true })
|
|
549
|
-
: spawnSync('which', [cmd], { stdio: 'ignore' });
|
|
550
|
-
return probe.status === 0;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function runMemoryQuery(args) {
|
|
554
|
-
var entryPoint = resolveEmpiricalEntryPoint();
|
|
555
|
-
if (entryPoint) {
|
|
556
|
-
appendLog('resolved direct entry point: ' + entryPoint);
|
|
557
|
-
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
558
|
-
encoding: 'utf8',
|
|
559
|
-
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
560
|
-
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
561
|
-
timeout: 20000,
|
|
562
|
-
shell: false
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
567
|
-
return spawnSync('empirical', args, {
|
|
568
|
-
encoding: 'utf8',
|
|
569
|
-
timeout: 20000,
|
|
570
|
-
shell: process.platform === 'win32'
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// 2026-09-18: GitHub's own hooks reference (re-checked - the August 2026 check this file used to
|
|
575
|
-
// cite was stale) confirms sessionStart CAN inject additionalContext for command-type hooks, same
|
|
576
|
-
// flat { additionalContext: "..." } shape postToolUse etc. use. This event's input payload also
|
|
577
|
-
// carries an optional initialPrompt - using it as the query (like the removed UserPromptSubmit
|
|
578
|
-
// hook did for its own prompt) makes the one real query relevant to what's actually being asked,
|
|
579
|
-
// not just a generic default.
|
|
580
|
-
function readStdinPayload() {
|
|
581
|
-
try {
|
|
582
|
-
var raw = fs.readFileSync(0, 'utf8');
|
|
583
|
-
return raw ? JSON.parse(raw) : {};
|
|
584
|
-
} catch (e) {
|
|
585
|
-
return {};
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
function main() {
|
|
590
|
-
appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
591
|
-
var payload = readStdinPayload();
|
|
592
|
-
var initialPrompt = String((payload && (payload.initialPrompt || payload.initial_prompt)) || '').trim();
|
|
593
|
-
|
|
594
|
-
if (!commandExists('empirical')) {
|
|
595
|
-
appendLog('empirical CLI not found on PATH, exiting');
|
|
596
|
-
console.log('{}');
|
|
597
|
-
process.exit(0);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Declared inside main() on purpose: function declarations hoist, and other hook templates in
|
|
601
|
-
// this file have their own main() without the memory-query plumbing.
|
|
602
|
-
|
|
603
|
-
// WHAT PROJECT IS THIS, in priority order. Guessing from the directory name is the last resort,
|
|
604
|
-
// not the contract: a repo folder is not always what the corpus tags memories with.
|
|
605
|
-
// 1. EMPIRICAL_PROJECT_TAG / EMPIRICAL_TOPICS environment variables
|
|
606
|
-
// 2. .empirical.json in the working directory or any ancestor, up to 6 levels:
|
|
607
|
-
// { "project": "empirical_v2", "topics": ["search ranking", "deploy"] }
|
|
608
|
-
// 3. the working directory name, lowercased
|
|
609
|
-
function readProjectConfig() {
|
|
610
|
-
var config = { project: '', topics: [] };
|
|
611
|
-
try {
|
|
612
|
-
var dir = process.cwd();
|
|
613
|
-
for (var depth = 0; depth < 6; depth += 1) {
|
|
614
|
-
var candidate = path.join(dir, '.empirical.json');
|
|
615
|
-
if (fs.existsSync(candidate)) {
|
|
616
|
-
var parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
617
|
-
config.project = String(parsed && parsed.project ? parsed.project : '').trim().toLowerCase();
|
|
618
|
-
if (parsed && Array.isArray(parsed.topics)) {
|
|
619
|
-
config.topics = parsed.topics.map(function (t) { return String(t || '').trim(); }).filter(Boolean);
|
|
620
|
-
}
|
|
621
|
-
appendLog('loaded project config from ' + candidate);
|
|
622
|
-
return config;
|
|
623
|
-
}
|
|
624
|
-
var parent = path.dirname(dir);
|
|
625
|
-
if (!parent || parent === dir) { break; }
|
|
626
|
-
dir = parent;
|
|
627
|
-
}
|
|
628
|
-
} catch (e) {
|
|
629
|
-
appendLog('project config unreadable, continuing without it: ' + e.message);
|
|
630
|
-
}
|
|
631
|
-
return config;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// requireAll selects --tags-all (AND) over --tags (OR). This matters: --tags is an OR match, so
|
|
635
|
-
// asking for 'empirical_v2,todo' returns every project's todos, which put a wellnessonbalance
|
|
636
|
-
// item into an empirical_v2 session during testing on 2026-09-16.
|
|
637
|
-
function fetchMemories(matchText, tags, topK, requireAll) {
|
|
638
|
-
var args = ['memory', 'query', '--match', matchText, '--top-k', String(topK)];
|
|
639
|
-
if (tags) { args.push(requireAll ? '--tags-all' : '--tags', tags); }
|
|
640
|
-
var queryResult = runMemoryQuery(args);
|
|
641
|
-
if (!queryResult || queryResult.status !== 0 || !queryResult.stdout) {
|
|
642
|
-
appendLog('memory query failed (tags=' + (tags || 'none') + ', status=' + (queryResult && queryResult.status) + ', stderr=' + (queryResult && queryResult.stderr ? String(queryResult.stderr).slice(0, 200) : '') + ')');
|
|
643
|
-
return [];
|
|
644
|
-
}
|
|
645
|
-
try {
|
|
646
|
-
var parsed = JSON.parse(queryResult.stdout);
|
|
647
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
648
|
-
} catch (e) {
|
|
649
|
-
appendLog('failed to parse memory query stdout as JSON: ' + e.message);
|
|
650
|
-
return [];
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
var fileConfig = readProjectConfig();
|
|
655
|
-
var envTag = String(process.env.EMPIRICAL_PROJECT_TAG || '').trim().toLowerCase();
|
|
656
|
-
var envTopics = String(process.env.EMPIRICAL_TOPICS || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
|
657
|
-
|
|
658
|
-
var tag = envTag || fileConfig.project;
|
|
659
|
-
var tagSource = envTag ? 'env' : (fileConfig.project ? 'config' : 'cwd');
|
|
660
|
-
if (!tag) {
|
|
661
|
-
try {
|
|
662
|
-
tag = String(path.basename(process.cwd() || '') || '').trim().toLowerCase();
|
|
663
|
-
} catch (e) {
|
|
664
|
-
tag = '';
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
var topics = envTopics.length ? envTopics : fileConfig.topics;
|
|
669
|
-
var contextMatch = topics.length
|
|
670
|
-
? topics.join(', ')
|
|
671
|
-
: (initialPrompt || 'project context, conventions, recent decisions, user preferences');
|
|
672
|
-
|
|
673
|
-
appendLog('project tag=' + (tag || '(none)') + ' (source=' + tagSource + '), topics=' + (topics.length ? topics.join('|') : '(default)') + ', usingInitialPrompt=' + Boolean(initialPrompt && !topics.length));
|
|
674
|
-
|
|
675
|
-
// Scope every query to the project. Measured 2026-09-16: with no tag filter all 8 slots went to
|
|
676
|
-
// one unrelated project during a full session in a different repo, so the only automatic context
|
|
677
|
-
// rail in the product was spending every slot on the wrong thing. A tag filter also disables the
|
|
678
|
-
// server's agent-process-note exclusion, which is what makes a project's own scar tissue and
|
|
679
|
-
// work history reachable here at all.
|
|
680
|
-
//
|
|
681
|
-
// Open items get their OWN query instead of competing for rank. Tested 2026-09-16: a due-dated
|
|
682
|
-
// memory did not surface in the generic top-8 with or without a project filter, because dueness
|
|
683
|
-
// is not a semantic property. Anything that must be seen needs a reserved slot.
|
|
684
|
-
// AND, not OR: this must be THIS project's todos. No --status filter on purpose, because todos
|
|
685
|
-
// written without a data.status would silently vanish from the one place they get surfaced;
|
|
686
|
-
// the label tells the reader to confirm instead.
|
|
687
|
-
var todos = tag ? fetchMemories('open todo, next action, follow-up, due', tag + ',todo', 3, true) : [];
|
|
688
|
-
var context = tag ? fetchMemories(contextMatch, tag, 6) : [];
|
|
689
|
-
|
|
690
|
-
// A corpus whose tags do not match the resolved project must never end up worse off than before
|
|
691
|
-
// this change, so fall back to the original unscoped query.
|
|
692
|
-
if (context.length === 0) {
|
|
693
|
-
appendLog('project-scoped query returned nothing, falling back to unscoped');
|
|
694
|
-
context = fetchMemories(contextMatch, '', 8);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
if (todos.length === 0 && context.length === 0) {
|
|
698
|
-
appendLog('memory query returned zero results');
|
|
699
|
-
console.log('{}');
|
|
700
|
-
process.exit(0);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
var openCount = todos.length;
|
|
704
|
-
var results = todos.concat(context);
|
|
705
|
-
|
|
706
|
-
// Summaries in a mature corpus run to thousands of characters. Injecting eight in full is a wall
|
|
707
|
-
// of text that gets skimmed past, which fails the same way as never injecting them at all:
|
|
708
|
-
// presence in context is not attention. Truncate hard; the agent can query for the rest.
|
|
709
|
-
var lines = results.slice(0, 9).map(function (r, i) {
|
|
710
|
-
var category = r && r.category ? r.category : 'memory';
|
|
711
|
-
var summary = String(r && r.summary ? r.summary : '');
|
|
712
|
-
summary = summary.split('\\n').join(' ').split('\\r').join(' ').split('\\t').join(' ').trim();
|
|
713
|
-
if (summary.length > 240) { summary = summary.slice(0, 237) + '...'; }
|
|
714
|
-
var prefix = i < openCount ? '- [OPEN ITEM, confirm status] ' : '- [' + category + '] ';
|
|
715
|
-
return prefix + summary;
|
|
716
|
-
});
|
|
717
|
-
lines.push('- Before risky or repeated work, ask first: empirical memory query --match "<what you are about to do>" --tags scar-tissue');
|
|
718
|
-
|
|
719
|
-
// additionalContext is honored for command-type sessionStart hooks (re-confirmed 2026-09-18
|
|
720
|
-
// against GitHub's current hooks reference - the prior "no output channel" comment here was
|
|
721
|
-
// stale). Delivered invisibly: this never touches the visible prompt, unlike the UserPromptSubmit
|
|
722
|
-
// approach it replaces (removed - see installCopilotHooks's comment).
|
|
723
|
-
var block = 'Empirical memory context for this session:\\n' + lines.join('\\n');
|
|
724
|
-
appendLog('memory query returned ' + results.length + ' result(s), injecting via additionalContext');
|
|
725
|
-
console.log(JSON.stringify({ additionalContext: block }));
|
|
726
|
-
process.exit(0);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
try {
|
|
730
|
-
main();
|
|
731
|
-
} catch (e) {
|
|
732
|
-
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
733
|
-
console.log('{}');
|
|
734
|
-
process.exit(0);
|
|
735
|
-
}
|
|
736
|
-
`;
|
|
737
|
-
|
|
738
|
-
// Copilot has no UserPromptSubmit real-recall hook (removed 2026-09-18, superseded by
|
|
739
|
-
// additionalContext delivery through COPILOT_HOOK_SESSION_START_SOURCE above). That approach was
|
|
740
|
-
// abandoned because Copilot renders the ENTIRE modifiedPrompt back in the terminal as the user's
|
|
741
|
-
// own visible message - confirmed live, not documented, and not what "real hook" was supposed to
|
|
742
|
-
// mean. sessionStart's additionalContext (re-confirmed against current GitHub docs - the prior
|
|
743
|
-
// "no output channel" comment on that hook was stale) delivers the same real query invisibly
|
|
744
|
-
// instead, at the cost of firing once per real session rather than being keyed to the very first
|
|
745
|
-
// prompt's exact text (it uses the sessionStart payload's own initialPrompt field instead, which
|
|
746
|
-
// is present per GitHub's schema but not verified live as of this comment).
|
|
747
|
-
|
|
748
|
-
// Codex-only variant of the Stop reminder. Confirmed live (2026-08-08, via a real `codex
|
|
749
|
-
// exec` run) that Codex's Stop hook specifically requires a { systemMessage: "..." } JSON shape
|
|
750
|
-
// on stdout - plain text makes Codex report the hook as failed. Distinct from Claude's own JSON
|
|
751
|
-
// shape (hookSpecificOutput.additionalContext, see CLAUDE_HOOK_STOP_SOURCE below). Copilot has no
|
|
752
|
-
// Stop hook at all (removed 2026-09-18, see the comment above CLAUDE_HOOK_STOP_SOURCE).
|
|
753
|
-
const CODEX_HOOK_STOP_SOURCE = `#!/usr/bin/env node
|
|
754
|
-
'use strict';
|
|
755
|
-
|
|
756
|
-
var fs = require('fs');
|
|
757
|
-
var os = require('os');
|
|
758
|
-
var path = require('path');
|
|
759
|
-
|
|
760
|
-
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
|
|
761
|
-
var MAX_LOG_BYTES = 200 * 1024;
|
|
762
|
-
|
|
763
|
-
function appendLog(line) {
|
|
764
|
-
try {
|
|
765
|
-
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
766
|
-
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
767
|
-
var stat = fs.statSync(LOG_PATH);
|
|
768
|
-
if (stat.size > MAX_LOG_BYTES) {
|
|
769
|
-
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
770
|
-
fs.writeFileSync(LOG_PATH, tail);
|
|
771
|
-
}
|
|
772
|
-
} catch (e) {
|
|
773
|
-
// Diagnostics must never break the hook.
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
function readStdinPayload() {
|
|
778
|
-
try {
|
|
779
|
-
var raw = fs.readFileSync(0, 'utf8');
|
|
780
|
-
return raw ? JSON.parse(raw) : {};
|
|
781
|
-
} catch (e) {
|
|
782
|
-
return {};
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
function main() {
|
|
787
|
-
var payload = readStdinPayload();
|
|
788
|
-
|
|
789
|
-
if (payload && payload.stop_hook_active === true) {
|
|
790
|
-
appendLog('stop_hook_active=true, skipping reminder');
|
|
791
|
-
process.exit(0);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
795
|
-
|
|
796
|
-
console.log(JSON.stringify({
|
|
797
|
-
systemMessage: '[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now, one memory per finding (a fix, a root cause, a decision, a lesson), via the empirical-memory, empirical-work-history, or empirical-scar-tissue skill or \`empirical memory record\`. Local notes are not recorded. Do not wait to be asked.'
|
|
798
|
-
}));
|
|
799
|
-
process.exit(0);
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
try {
|
|
803
|
-
main();
|
|
804
|
-
} catch (e) {
|
|
805
|
-
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
806
|
-
process.exit(0);
|
|
807
|
-
}
|
|
808
|
-
`;
|
|
809
|
-
|
|
810
|
-
// Copilot's Stop reminder (agentStop) was removed entirely 2026-09-18 - see installCopilotHooks's
|
|
811
|
-
// comment in this file for why (agentStop only supports decision:"block"|"allow" with no passive
|
|
812
|
-
// field, so any reminder there forces a disruptive extra visible turn; no per-session cap on that
|
|
813
|
-
// avoids the tradeoff between nagging every turn and going silent right before something worth
|
|
814
|
-
// recording happens - confirmed live 2026-09-17/18 causing both failure modes in turn). Copilot
|
|
815
|
-
// relies entirely on copilot-instructions.md instead (see coreMemoryBlock.js's Copilot addendum).
|
|
816
|
-
|
|
817
|
-
// Claude-only variant of the Stop reminder above. hookSpecificOutput.additionalContext (stdout,
|
|
818
|
-
// exit 0) is Claude Code's non-blocking Stop-hook feedback channel: the text is fed into context
|
|
819
|
-
// and the conversation continues, same outcome the earlier exit-2 approach achieved, but the
|
|
820
|
-
// transcript UI renders it as a plain system reminder instead of a red "Stop hook error" line.
|
|
821
|
-
// Codex needs its own systemMessage shape (CODEX_HOOK_STOP_SOURCE) - plain console.log output
|
|
822
|
-
// (what this hook used before the additionalContext fix) never reaches the model. Copilot has no
|
|
823
|
-
// Stop hook at all (removed 2026-09-18, see the comment just above this one).
|
|
824
|
-
const CLAUDE_HOOK_STOP_SOURCE = `#!/usr/bin/env node
|
|
825
|
-
'use strict';
|
|
826
|
-
|
|
827
|
-
var fs = require('fs');
|
|
828
|
-
var os = require('os');
|
|
829
|
-
var path = require('path');
|
|
830
|
-
|
|
831
|
-
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
|
|
832
|
-
var MAX_LOG_BYTES = 200 * 1024;
|
|
833
|
-
|
|
834
|
-
function appendLog(line) {
|
|
835
|
-
try {
|
|
836
|
-
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
837
|
-
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
838
|
-
var stat = fs.statSync(LOG_PATH);
|
|
839
|
-
if (stat.size > MAX_LOG_BYTES) {
|
|
840
|
-
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
841
|
-
fs.writeFileSync(LOG_PATH, tail);
|
|
842
|
-
}
|
|
843
|
-
} catch (e) {
|
|
844
|
-
// Diagnostics must never break the hook.
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function readStdinPayload() {
|
|
849
|
-
try {
|
|
850
|
-
var raw = fs.readFileSync(0, 'utf8');
|
|
851
|
-
return raw ? JSON.parse(raw) : {};
|
|
852
|
-
} catch (e) {
|
|
853
|
-
return {};
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function main() {
|
|
858
|
-
var payload = readStdinPayload();
|
|
859
|
-
|
|
860
|
-
// stop_hook_active === true means Claude is re-invoking this hook because a PRIOR Stop hook
|
|
861
|
-
// (possibly this same one) already surfaced additionalContext and forced a continuation this
|
|
862
|
-
// turn. additionalContext causes the same forced-continuation behavior decision:block does -
|
|
863
|
-
// confirmed empirically: without this guard the hook re-fired every ~2-3s in an infinite loop,
|
|
864
|
-
// since each additionalContext output triggers another Stop event. Must exit 0 with no output
|
|
865
|
-
// here, unconditionally, or the loop continues.
|
|
866
|
-
if (payload && payload.stop_hook_active === true) {
|
|
867
|
-
appendLog('stop_hook_active=true, skipping reminder (already surfaced once this turn)');
|
|
868
|
-
process.exit(0);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + '), surfacing reminder via additionalContext');
|
|
872
|
-
|
|
873
|
-
process.stdout.write(JSON.stringify({
|
|
874
|
-
hookSpecificOutput: {
|
|
875
|
-
hookEventName: 'Stop',
|
|
876
|
-
additionalContext: '[Empirical] Record memory-worthy work now: one memory per finding (a fix, a root cause, a decision, a lesson) via the empirical-memory, empirical-work-history, or empirical-scar-tissue skill. Local notes are not recorded.'
|
|
877
|
-
}
|
|
878
|
-
}) + '\\n');
|
|
879
|
-
process.exit(0);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// See EMPIRICAL_HOOK_SESSION_START_SOURCE's identical guard: a crash here must degrade to a
|
|
883
|
-
// silent no-op, never a visible "hook failed" error in the host CLI.
|
|
884
|
-
try {
|
|
885
|
-
main();
|
|
886
|
-
} catch (e) {
|
|
887
|
-
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
888
|
-
process.exit(0);
|
|
889
|
-
}
|
|
890
|
-
`;
|
|
891
|
-
|
|
892
|
-
// UserPromptSubmit: fires before each turn is processed (Claude, Codex, and Copilot all support
|
|
893
|
-
// this event under the same name per each platform's docs/source). This is the recall-side
|
|
894
|
-
// counterpart to Stop's write-side reminder - deliberately a STATIC message, not a live
|
|
895
|
-
// `empirical memory query` call: a prior per-turn hook in this codebase was removed for making an
|
|
896
|
-
// unconditional network call on every single prompt (see the comment above installClaudeHooks),
|
|
897
|
-
// and re-adding that cost here on three platforms at once was an explicit no per product decision.
|
|
898
|
-
// The message itself is generated per-install from the actual plugin set being installed, not one
|
|
899
|
-
// fixed string for everyone - e.g. someone who declined empirical-scar-tissue at install time
|
|
900
|
-
// never sees a scar-tissue reminder, since that capability isn't actually available to them.
|
|
901
|
-
//
|
|
902
|
-
// Claude and Codex only - Copilot has no UserPromptSubmit reminder at all (removed 2026-09-18,
|
|
903
|
-
// see installCopilotHooks's comment in this file for why: its only output field is a full prompt
|
|
904
|
-
// replacement, not a side channel, so any injected reminder here reads as fake, model-authored
|
|
905
|
-
// text inside what looks like the user's own message).
|
|
906
|
-
function buildPromptReminderMessage(pluginNames, platform) {
|
|
907
|
-
const names = normalizePluginNames(pluginNames);
|
|
908
|
-
|
|
909
|
-
const lines = [
|
|
910
|
-
'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.'
|
|
911
|
-
];
|
|
912
|
-
if (names.includes('empirical-work-history')) {
|
|
913
|
-
lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
|
|
914
|
-
if (platform === 'codex') {
|
|
915
|
-
lines.push('If this turn results in meaningful completed work, BEFORE sending your final response you MUST use the empirical-work-history skill or record it via Empirical. The entry must carry tag work-history and mass 1; verify the create receipt. CLI fallback must use --json-file with data.session and data.git; do not use the shorthand record command for coding work. Do not wait for the Stop hook.');
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
if (names.includes('empirical-scar-tissue')) {
|
|
919
|
-
lines.push('Check for scar tissue before repeating a past mistake in this area.');
|
|
920
|
-
}
|
|
921
|
-
if (names.includes('empirical-todo')) {
|
|
922
|
-
lines.push('If there is an open todo for this project, check it before starting new work.');
|
|
923
|
-
}
|
|
924
|
-
return '[Empirical reminder] ' + lines.join(' ');
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
// Builds the full UserPromptSubmit hook script source with the tailored message baked in at
|
|
928
|
-
// generation time (via JSON.stringify, so the message is embedded as a single already-escaped
|
|
929
|
-
// string literal - substituted through `${}`, not written as raw template-literal source text,
|
|
930
|
-
// which is what caused the earlier comment-escaping bug in this same file. Never do the latter).
|
|
931
|
-
// `platform`:
|
|
932
|
-
// - 'codex': Codex's required hookSpecificOutput.additionalContext JSON shape (confirmed live
|
|
933
|
-
// 2026-08-08 via a real `codex exec` run - plain text made Codex report the hook as failed).
|
|
934
|
-
// - default ('claude'): plain console.log.
|
|
935
|
-
// Copilot has no variant here at all (removed 2026-09-18) - see installCopilotHooks's comment.
|
|
936
|
-
function buildUserPromptSubmitHookSource(pluginNames, platform) {
|
|
937
|
-
const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames, platform));
|
|
938
|
-
|
|
939
|
-
const outputStatement = platform === 'codex'
|
|
940
|
-
? `console.log(JSON.stringify({
|
|
941
|
-
hookSpecificOutput: {
|
|
942
|
-
hookEventName: 'UserPromptSubmit',
|
|
943
|
-
additionalContext: ${messageLiteral}
|
|
944
|
-
}
|
|
945
|
-
}));`
|
|
946
|
-
: `console.log(${messageLiteral});`;
|
|
947
|
-
|
|
948
|
-
return `#!/usr/bin/env node
|
|
949
|
-
'use strict';
|
|
950
|
-
|
|
951
|
-
var fs = require('fs');
|
|
952
|
-
var os = require('os');
|
|
953
|
-
var path = require('path');
|
|
954
|
-
|
|
955
|
-
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'user-prompt-submit.log');
|
|
956
|
-
var MAX_LOG_BYTES = 200 * 1024;
|
|
957
|
-
|
|
958
|
-
function appendLog(line) {
|
|
959
|
-
try {
|
|
960
|
-
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
961
|
-
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
962
|
-
var stat = fs.statSync(LOG_PATH);
|
|
963
|
-
if (stat.size > MAX_LOG_BYTES) {
|
|
964
|
-
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
965
|
-
fs.writeFileSync(LOG_PATH, tail);
|
|
966
|
-
}
|
|
967
|
-
} catch (e) {
|
|
968
|
-
// Diagnostics must never break the hook.
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// A crash here must degrade to a silent no-op, never a visible "hook failed" error in the host CLI.
|
|
973
|
-
try {
|
|
974
|
-
appendLog('user-prompt-submit hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
975
|
-
${outputStatement}
|
|
976
|
-
} catch (e) {
|
|
977
|
-
try { appendLog('uncaught error: ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
978
|
-
}
|
|
979
|
-
process.exit(0);
|
|
980
|
-
`;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
function getEmpiricalHooksDir() {
|
|
984
|
-
return path.join(os.homedir(), '.empirical', 'hooks');
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function buildHookCommand(scriptPath) {
|
|
988
|
-
return `"${process.execPath}" "${scriptPath}"`;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Confirmed live (2026-08-08, via a real `copilot -p` run with debug logging): Copilot runs a
|
|
992
|
-
// hook's "command"/"powershell" field through powershell.exe on Windows, and a bare
|
|
993
|
-
// `"path" "arg"` string is not valid PowerShell syntax without the call operator (&) - two
|
|
994
|
-
// adjacent quoted string literals with nothing between them is a ParserError
|
|
995
|
-
// ("Unexpected token '...' in expression or statement"), not a "file not found"-style failure.
|
|
996
|
-
// This is a completely different bug from Codex's (which needed 8.3 short paths to avoid
|
|
997
|
-
// quoting) - no short-path workaround is needed here, just the operator PowerShell requires
|
|
998
|
-
// before an executable path.
|
|
999
|
-
function buildPowershellHookCommand(scriptPath) {
|
|
1000
|
-
return `& "${process.execPath}" "${scriptPath}"`;
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
function removeEmpiricalHookEntries(entries, scriptPath) {
|
|
1004
|
-
if (!Array.isArray(entries)) return [];
|
|
1005
|
-
return entries
|
|
1006
|
-
.map((entry) => {
|
|
1007
|
-
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
1008
|
-
const filteredHooks = entry.hooks.filter(
|
|
1009
|
-
(h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
|
|
1010
|
-
);
|
|
1011
|
-
return { ...entry, hooks: filteredHooks };
|
|
1012
|
-
})
|
|
1013
|
-
.filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
function installClaudeHooks(pluginNames) {
|
|
1017
|
-
const hooksDir = getEmpiricalHooksDir();
|
|
1018
|
-
ensureDir(hooksDir);
|
|
1019
|
-
|
|
1020
|
-
// A prior version of this hook made an unconditional `empirical memory query` network call on
|
|
1021
|
-
// every single prompt and was removed for being wasteful (see git history). This is a
|
|
1022
|
-
// deliberate reintroduction at the SAME script path, but with fundamentally different content:
|
|
1023
|
-
// a static, install-time-generated reminder with no network call at all - the cost concern that
|
|
1024
|
-
// killed the old version does not apply to a plain stdout write.
|
|
1025
|
-
const userPromptScriptPath = path.join(hooksDir, 'claude-user-prompt-submit.js');
|
|
1026
|
-
const sessionStartScriptPath = path.join(hooksDir, 'claude-session-start.js');
|
|
1027
|
-
const stopScriptPath = path.join(hooksDir, 'claude-stop.js');
|
|
1028
|
-
writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
|
|
1029
|
-
writeFile(stopScriptPath, CLAUDE_HOOK_STOP_SOURCE);
|
|
1030
|
-
writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
|
|
1031
|
-
|
|
1032
|
-
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
1033
|
-
const settings = readJsonIfExists(settingsPath) || {};
|
|
1034
|
-
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
1035
|
-
settings.hooks = {};
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
settings.hooks.UserPromptSubmit = removeEmpiricalHookEntries(settings.hooks.UserPromptSubmit, userPromptScriptPath);
|
|
1039
|
-
settings.hooks.UserPromptSubmit.push({
|
|
1040
|
-
hooks: [{ type: 'command', command: buildHookCommand(userPromptScriptPath), timeout: 10 }]
|
|
1041
|
-
});
|
|
1042
|
-
|
|
1043
|
-
settings.hooks.SessionStart = removeEmpiricalHookEntries(settings.hooks.SessionStart, sessionStartScriptPath);
|
|
1044
|
-
for (const matcher of ['startup', 'resume', 'compact']) {
|
|
1045
|
-
settings.hooks.SessionStart.push({
|
|
1046
|
-
matcher,
|
|
1047
|
-
hooks: [{ type: 'command', command: buildHookCommand(sessionStartScriptPath), timeout: 25 }]
|
|
1048
|
-
});
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
settings.hooks.Stop = removeEmpiricalHookEntries(settings.hooks.Stop, stopScriptPath);
|
|
1052
|
-
settings.hooks.Stop.push({
|
|
1053
|
-
hooks: [{ type: 'command', command: buildHookCommand(stopScriptPath), timeout: 10 }]
|
|
1054
|
-
});
|
|
1055
|
-
|
|
1056
|
-
writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1057
|
-
log(' - Claude SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// Codex CLI ships a real hooks system (confirmed against openai/codex source — codex-rs/hooks,
|
|
1061
|
-
// codex-rs/config/src/hook_config.rs — not assumed from docs alone, since third-party writeups
|
|
1062
|
-
// disagreed on Windows support). It reads <CODEX_HOME>/hooks.json (JSON, not the config.toml
|
|
1063
|
-
// used for MCP/model settings — a separate file, so this never touches config.toml) shaped as
|
|
1064
|
-
// { hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command, commandWindows,
|
|
1065
|
-
// timeout }] }], ... } }. CODEX_HOME defaults to ~/.codex when unset, same convention as
|
|
1066
|
-
// ~/.claude for Claude Code.
|
|
1067
|
-
function getCodexHomeDir() {
|
|
1068
|
-
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
function removeEmpiricalHooksJsonEntries(entries, scriptPath) {
|
|
1072
|
-
if (!Array.isArray(entries)) return [];
|
|
1073
|
-
return entries
|
|
1074
|
-
.map((entry) => {
|
|
1075
|
-
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
1076
|
-
const filteredHooks = entry.hooks.filter(
|
|
1077
|
-
(h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
|
|
1078
|
-
);
|
|
1079
|
-
return { ...entry, hooks: filteredHooks };
|
|
1080
|
-
})
|
|
1081
|
-
.filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Codex's Windows sandbox (confirmed via `codex doctor` -> "filesystem sandbox: restricted" and
|
|
1085
|
-
// ~/.codex/.sandbox/sandbox.*.log's "processed N write roots (read roots delegated)" / "applying
|
|
1086
|
-
// read ACLs" at session start) scopes filesystem access per-session to project-specific roots
|
|
1087
|
-
// plus, necessarily, CODEX_HOME itself (Codex reads hooks.json, its state db, and its plugin
|
|
1088
|
-
// cache from there on every session regardless of project). The shared `~/.empirical/hooks`
|
|
1089
|
-
// directory used by Claude/Copilot is neither - so under this sandbox, node.exe most likely
|
|
1090
|
-
// cannot even read a hook script living there, which is why hooks fail identically (exit 1)
|
|
1091
|
-
// across SessionStart/UserPromptSubmit/Stop regardless of what each script actually does,
|
|
1092
|
-
// including the trivial UserPromptSubmit script that spawns no subprocess at all. Claude Code
|
|
1093
|
-
// hooks are unaffected (confirmed working from the shared dir) and Copilot has shown no such
|
|
1094
|
-
// failure, so only Codex's copies are relocated here - Claude/Copilot keep using
|
|
1095
|
-
// getEmpiricalHooksDir() unchanged.
|
|
1096
|
-
function getCodexHooksScriptsDir() {
|
|
1097
|
-
return path.join(getCodexHomeDir(), 'empirical-hooks');
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// Confirmed live (2026-08-08, via a real `codex exec` run): Codex's Windows hook launcher fails
|
|
1101
|
-
// to parse a quoted executable path (e.g. `"C:\Program Files\nodejs\node.exe" "<script>"`,
|
|
1102
|
-
// otherwise a perfectly standard Windows command line - Claude Code and every direct shell/
|
|
1103
|
-
// spawnSync invocation handle it fine) and reports the hook as a nonzero-exit failure before any
|
|
1104
|
-
// of the script's own JS ever runs. Windows 8.3 short path names never contain spaces, so
|
|
1105
|
-
// resolving both the node executable and the script path to their short form and joining them
|
|
1106
|
-
// unquoted sidesteps the parser entirely. Computed once at install time (not per hook
|
|
1107
|
-
// invocation), so a synchronous shell call here is an acceptable cost.
|
|
1108
|
-
function getWindowsShortPath(longPath) {
|
|
1109
|
-
if (process.platform !== 'win32') return null;
|
|
1110
|
-
try {
|
|
1111
|
-
const { execSync } = require('child_process');
|
|
1112
|
-
const out = execSync(`cmd /c for %I in ("${longPath}") do @echo %~sI`, { encoding: 'utf8', timeout: 5000 });
|
|
1113
|
-
const short = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).pop();
|
|
1114
|
-
const fs = require('fs');
|
|
1115
|
-
return short && fs.existsSync(short) ? short : null;
|
|
1116
|
-
} catch (e) {
|
|
1117
|
-
return null;
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
// Builds Codex's commandWindows value using short paths so neither the executable nor the script
|
|
1122
|
-
// path ever needs quoting. Falls back to the normal quoted buildHookCommand() form if short-path
|
|
1123
|
-
// resolution fails for any reason (e.g. 8.3 name generation disabled on that volume) - degraded
|
|
1124
|
-
// (same behavior as before this fix) rather than broken.
|
|
1125
|
-
function buildCodexWindowsHookCommand(scriptPath) {
|
|
1126
|
-
const shortNode = getWindowsShortPath(process.execPath);
|
|
1127
|
-
const shortScript = getWindowsShortPath(scriptPath);
|
|
1128
|
-
if (shortNode && shortScript) {
|
|
1129
|
-
return `${shortNode} ${shortScript}`;
|
|
1130
|
-
}
|
|
1131
|
-
return buildHookCommand(scriptPath);
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
function installCodexHooks(pluginNames) {
|
|
1135
|
-
const fs = require('fs');
|
|
1136
|
-
const legacyHooksDir = getEmpiricalHooksDir();
|
|
1137
|
-
const hooksDir = getCodexHooksScriptsDir();
|
|
1138
|
-
ensureDir(hooksDir);
|
|
1139
|
-
|
|
1140
|
-
const sessionStartScriptPath = path.join(hooksDir, 'codex-session-start.js');
|
|
1141
|
-
const stopScriptPath = path.join(hooksDir, 'codex-stop.js');
|
|
1142
|
-
const userPromptScriptPath = path.join(hooksDir, 'codex-user-prompt-submit.js');
|
|
1143
|
-
writeFile(sessionStartScriptPath, CODEX_HOOK_SESSION_START_SOURCE);
|
|
1144
|
-
writeFile(stopScriptPath, CODEX_HOOK_STOP_SOURCE);
|
|
1145
|
-
writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames, 'codex'));
|
|
1146
|
-
|
|
1147
|
-
// Best-effort cleanup of the pre-relocation script files/entries at the old shared path, so a
|
|
1148
|
-
// machine that already had Codex hooks installed doesn't end up with both the old (broken
|
|
1149
|
-
// under the sandbox) and new registration firing redundantly.
|
|
1150
|
-
const legacySessionStartScriptPath = path.join(legacyHooksDir, 'codex-session-start.js');
|
|
1151
|
-
const legacyStopScriptPath = path.join(legacyHooksDir, 'codex-stop.js');
|
|
1152
|
-
const legacyUserPromptScriptPath = path.join(legacyHooksDir, 'codex-user-prompt-submit.js');
|
|
1153
|
-
for (const legacyPath of [legacySessionStartScriptPath, legacyStopScriptPath, legacyUserPromptScriptPath]) {
|
|
1154
|
-
try {
|
|
1155
|
-
if (fs.existsSync(legacyPath)) fs.unlinkSync(legacyPath);
|
|
1156
|
-
} catch (_error) {
|
|
1157
|
-
// best-effort cleanup only
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
const hooksJsonPath = path.join(getCodexHomeDir(), 'hooks.json');
|
|
1162
|
-
const hooksFile = readJsonIfExists(hooksJsonPath) || {};
|
|
1163
|
-
if (!hooksFile.hooks || typeof hooksFile.hooks !== 'object') {
|
|
1164
|
-
hooksFile.hooks = {};
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
hooksFile.hooks.SessionStart = removeEmpiricalHooksJsonEntries(
|
|
1168
|
-
removeEmpiricalHooksJsonEntries(hooksFile.hooks.SessionStart, sessionStartScriptPath),
|
|
1169
|
-
legacySessionStartScriptPath
|
|
1170
|
-
);
|
|
1171
|
-
const sessionStartCommand = buildHookCommand(sessionStartScriptPath);
|
|
1172
|
-
const sessionStartCommandWindows = buildCodexWindowsHookCommand(sessionStartScriptPath);
|
|
1173
|
-
for (const matcher of ['startup', 'resume']) {
|
|
1174
|
-
hooksFile.hooks.SessionStart.push({
|
|
1175
|
-
matcher,
|
|
1176
|
-
hooks: [{ type: 'command', command: sessionStartCommand, commandWindows: sessionStartCommandWindows, timeout: 25 }]
|
|
1177
|
-
});
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
hooksFile.hooks.Stop = removeEmpiricalHooksJsonEntries(
|
|
1181
|
-
removeEmpiricalHooksJsonEntries(hooksFile.hooks.Stop, stopScriptPath),
|
|
1182
|
-
legacyStopScriptPath
|
|
1183
|
-
);
|
|
1184
|
-
const stopCommand = buildHookCommand(stopScriptPath);
|
|
1185
|
-
const stopCommandWindows = buildCodexWindowsHookCommand(stopScriptPath);
|
|
1186
|
-
hooksFile.hooks.Stop.push({
|
|
1187
|
-
hooks: [{ type: 'command', command: stopCommand, commandWindows: stopCommandWindows, timeout: 10 }]
|
|
1188
|
-
});
|
|
1189
|
-
|
|
1190
|
-
// Confirmed against codex-rs/config/src/hook_config.rs's HookEventsToml serde renames: the
|
|
1191
|
-
// config-file key is PascalCase "UserPromptSubmit", same casing convention as SessionStart/Stop.
|
|
1192
|
-
hooksFile.hooks.UserPromptSubmit = removeEmpiricalHooksJsonEntries(
|
|
1193
|
-
removeEmpiricalHooksJsonEntries(hooksFile.hooks.UserPromptSubmit, userPromptScriptPath),
|
|
1194
|
-
legacyUserPromptScriptPath
|
|
1195
|
-
);
|
|
1196
|
-
const userPromptCommand = buildHookCommand(userPromptScriptPath);
|
|
1197
|
-
const userPromptCommandWindows = buildCodexWindowsHookCommand(userPromptScriptPath);
|
|
1198
|
-
hooksFile.hooks.UserPromptSubmit.push({
|
|
1199
|
-
hooks: [{ type: 'command', command: userPromptCommand, commandWindows: userPromptCommandWindows, timeout: 10 }]
|
|
1200
|
-
});
|
|
1201
|
-
|
|
1202
|
-
writeFile(hooksJsonPath, `${JSON.stringify(hooksFile, null, 2)}\n`);
|
|
1203
|
-
log(' - Codex SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
// GitHub Copilot CLI's hook config is one JSON file per hook under ~/.copilot/hooks/ (any
|
|
1207
|
-
// filename — confirmed via GitHub's own docs at docs.github.com/en/copilot/reference/hooks-
|
|
1208
|
-
// reference), so unlike Claude/Codex this never needs to merge into a shared file: Empirical
|
|
1209
|
-
// owns this file outright and can just overwrite it.
|
|
1210
|
-
//
|
|
1211
|
-
// SessionStart only (2026-09-18): userPromptSubmitted and agentStop reminder hooks were removed
|
|
1212
|
-
// for Copilot entirely, not just capped. Both were fundamentally fake compared to Claude/Codex's
|
|
1213
|
-
// versions: userPromptSubmitted's only output field is modifiedPrompt (a full prompt replacement,
|
|
1214
|
-
// not a side-channel), so its "reminder" was really text injected into what looks like the user's
|
|
1215
|
-
// own message; agentStop only supports decision:"block"|"allow" with no passive/silent field, so
|
|
1216
|
-
// any reminder there forces a disruptive extra visible turn, and no per-session cap on that can
|
|
1217
|
-
// avoid the tradeoff between nagging every turn and going silent right before something worth
|
|
1218
|
-
// recording happens (confirmed live 2026-09-17/18: both caused real, reproducible bugs - a
|
|
1219
|
-
// prompt-blanking regression and an unstoppable "record your work" loop). Copilot instead relies
|
|
1220
|
-
// entirely on the identity-framed instructions block coreMemoryBlock.js writes into
|
|
1221
|
-
// copilot-instructions.md, same mechanism Claude/Codex have as a backstop under their working
|
|
1222
|
-
// hooks - see that file's Copilot-only addendum for the stronger, more directive wording this
|
|
1223
|
-
// puts on Copilot specifically to compensate for having no hook nudge at all.
|
|
1224
|
-
function installCopilotHooks(_pluginNames) {
|
|
1225
|
-
const hooksDir = getEmpiricalHooksDir();
|
|
1226
|
-
ensureDir(hooksDir);
|
|
1227
|
-
|
|
1228
|
-
const sessionStartScriptPath = path.join(hooksDir, 'copilot-session-start.js');
|
|
1229
|
-
writeFile(sessionStartScriptPath, COPILOT_HOOK_SESSION_START_SOURCE);
|
|
1230
|
-
|
|
1231
|
-
// Clean up scripts/state files from every removed Copilot hook (agentStop's Stop reminder, the
|
|
1232
|
-
// visible-modifiedPrompt UserPromptSubmit real-recall hook it was briefly replaced with) so an
|
|
1233
|
-
// upgrade never leaves orphaned, unregistered files behind.
|
|
1234
|
-
const fs = require('fs');
|
|
1235
|
-
const staleFiles = [
|
|
1236
|
-
path.join(hooksDir, 'copilot-stop.js'),
|
|
1237
|
-
path.join(hooksDir, 'stop-state.json'),
|
|
1238
|
-
path.join(hooksDir, 'copilot-user-prompt-submit.js'),
|
|
1239
|
-
path.join(hooksDir, 'prompt-state.json')
|
|
1240
|
-
];
|
|
1241
|
-
for (const stalePath of staleFiles) {
|
|
1242
|
-
try {
|
|
1243
|
-
if (fs.existsSync(stalePath)) fs.unlinkSync(stalePath);
|
|
1244
|
-
} catch (_error) {
|
|
1245
|
-
// best-effort cleanup only
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
const copilotHooksDir = path.join(os.homedir(), '.copilot', 'hooks');
|
|
1250
|
-
ensureDir(copilotHooksDir);
|
|
1251
|
-
|
|
1252
|
-
// Prior versions of this installer wrote a single-purpose "empirical-session-start.json" —
|
|
1253
|
-
// remove it now that both events live in one unified file, so a stale duplicate registration
|
|
1254
|
-
// never lingers alongside the new one.
|
|
1255
|
-
const legacyHookFilePath = path.join(copilotHooksDir, 'empirical-session-start.json');
|
|
1256
|
-
try {
|
|
1257
|
-
if (fs.existsSync(legacyHookFilePath)) fs.unlinkSync(legacyHookFilePath);
|
|
1258
|
-
} catch (_error) {
|
|
1259
|
-
// best-effort cleanup only
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
const hookFilePath = path.join(copilotHooksDir, 'empirical-hooks.json');
|
|
1263
|
-
// Both `command` (POSIX fallback) and `powershell` (Windows-explicit, with the & call operator
|
|
1264
|
-
// PowerShell requires - see buildPowershellHookCommand) are always set: per GitHub's hooks
|
|
1265
|
-
// reference, an explicit `powershell` entry takes precedence over `command` on Windows, so this
|
|
1266
|
-
// is correct on both platforms without needing to detect the install-time OS here.
|
|
1267
|
-
//
|
|
1268
|
-
// sessionStart only (2026-09-18): no agentStop (recording stays instructions-only, see
|
|
1269
|
-
// coreMemoryBlock.js's Copilot addendum) and no userPromptSubmitted (its only output field,
|
|
1270
|
-
// modifiedPrompt, is rendered back as the user's own visible message - confirmed live, not
|
|
1271
|
-
// acceptable for silent recall). sessionStart's additionalContext delivers the same real query
|
|
1272
|
-
// invisibly instead, once per real session boundary (not an artificial timer), using that
|
|
1273
|
-
// event's own initialPrompt field as the query when present.
|
|
1274
|
-
const hookFileContent = {
|
|
1275
|
-
version: 1,
|
|
1276
|
-
hooks: {
|
|
1277
|
-
sessionStart: [
|
|
1278
|
-
{
|
|
1279
|
-
type: 'command',
|
|
1280
|
-
command: buildHookCommand(sessionStartScriptPath),
|
|
1281
|
-
powershell: buildPowershellHookCommand(sessionStartScriptPath),
|
|
1282
|
-
timeoutSec: 15
|
|
1283
|
-
}
|
|
1284
|
-
]
|
|
1285
|
-
}
|
|
1286
|
-
};
|
|
1287
|
-
|
|
1288
|
-
writeFile(hookFilePath, `${JSON.stringify(hookFileContent, null, 2)}\n`);
|
|
1289
|
-
log(' - Copilot SessionStart hook installed (real recall via additionalContext). No UserPromptSubmit/agentStop reminders - see copilot-instructions.md instead.');
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
module.exports = {
|
|
1293
|
-
CLAUDE_HOOK_SESSION_START_SOURCE,
|
|
1294
|
-
CODEX_HOOK_SESSION_START_SOURCE,
|
|
1295
|
-
COPILOT_HOOK_SESSION_START_SOURCE,
|
|
1296
|
-
CODEX_HOOK_STOP_SOURCE,
|
|
1297
|
-
CLAUDE_HOOK_STOP_SOURCE,
|
|
1298
|
-
buildPromptReminderMessage,
|
|
1299
|
-
buildUserPromptSubmitHookSource,
|
|
1300
|
-
getEmpiricalHooksDir,
|
|
1301
|
-
buildHookCommand,
|
|
1302
|
-
buildPowershellHookCommand,
|
|
1303
|
-
removeEmpiricalHookEntries,
|
|
1304
|
-
installClaudeHooks,
|
|
1305
|
-
getCodexHomeDir,
|
|
1306
|
-
removeEmpiricalHooksJsonEntries,
|
|
1307
|
-
getCodexHooksScriptsDir,
|
|
1308
|
-
getWindowsShortPath,
|
|
1309
|
-
buildCodexWindowsHookCommand,
|
|
1310
|
-
installCodexHooks,
|
|
1311
|
-
installCopilotHooks
|
|
1312
|
-
};
|
|
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
|
+
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
89
|
+
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
90
|
+
timeout: 20000,
|
|
91
|
+
shell: false
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fallback: could not resolve a direct entry point (non-Windows, or resolution failed) — run
|
|
96
|
+
// the "empirical" command as before. On POSIX this is shell:false and safe. On Windows this is
|
|
97
|
+
// shell:true and CAN still mangle args as described above; it only triggers if the direct-entry
|
|
98
|
+
// resolution above failed, so degraded (not absent) results are the acceptable worst case here.
|
|
99
|
+
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
100
|
+
return spawnSync('empirical', args, {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
timeout: 20000,
|
|
103
|
+
shell: process.platform === 'win32'
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function main() {
|
|
108
|
+
appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
109
|
+
|
|
110
|
+
if (!commandExists('empirical')) {
|
|
111
|
+
appendLog('empirical CLI not found on PATH, exiting');
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Declared inside main() on purpose: function declarations hoist, and other hook templates in
|
|
116
|
+
// this file have their own main() without the memory-query plumbing.
|
|
117
|
+
|
|
118
|
+
// WHAT PROJECT IS THIS, in priority order. Guessing from the directory name is the last resort,
|
|
119
|
+
// not the contract: a repo folder is not always what the corpus tags memories with.
|
|
120
|
+
// 1. EMPIRICAL_PROJECT_TAG / EMPIRICAL_TOPICS environment variables
|
|
121
|
+
// 2. .empirical.json in the working directory or any ancestor, up to 6 levels:
|
|
122
|
+
// { "project": "empirical_v2", "topics": ["search ranking", "deploy"] }
|
|
123
|
+
// 3. the working directory name, lowercased
|
|
124
|
+
function readProjectConfig() {
|
|
125
|
+
var config = { project: '', topics: [] };
|
|
126
|
+
try {
|
|
127
|
+
var dir = process.cwd();
|
|
128
|
+
for (var depth = 0; depth < 6; depth += 1) {
|
|
129
|
+
var candidate = path.join(dir, '.empirical.json');
|
|
130
|
+
if (fs.existsSync(candidate)) {
|
|
131
|
+
var parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
132
|
+
config.project = String(parsed && parsed.project ? parsed.project : '').trim().toLowerCase();
|
|
133
|
+
if (parsed && Array.isArray(parsed.topics)) {
|
|
134
|
+
config.topics = parsed.topics.map(function (t) { return String(t || '').trim(); }).filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
appendLog('loaded project config from ' + candidate);
|
|
137
|
+
return config;
|
|
138
|
+
}
|
|
139
|
+
var parent = path.dirname(dir);
|
|
140
|
+
if (!parent || parent === dir) { break; }
|
|
141
|
+
dir = parent;
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
appendLog('project config unreadable, continuing without it: ' + e.message);
|
|
145
|
+
}
|
|
146
|
+
return config;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// requireAll selects --tags-all (AND) over --tags (OR). This matters: --tags is an OR match, so
|
|
150
|
+
// asking for 'empirical_v2,todo' returns every project's todos, which put a wellnessonbalance
|
|
151
|
+
// item into an empirical_v2 session during testing on 2026-09-16.
|
|
152
|
+
function fetchMemories(matchText, tags, topK, requireAll) {
|
|
153
|
+
var args = ['memory', 'query', '--match', matchText, '--top-k', String(topK)];
|
|
154
|
+
if (tags) { args.push(requireAll ? '--tags-all' : '--tags', tags); }
|
|
155
|
+
var queryResult = runMemoryQuery(args);
|
|
156
|
+
if (!queryResult || queryResult.status !== 0 || !queryResult.stdout) {
|
|
157
|
+
appendLog('memory query failed (tags=' + (tags || 'none') + ', status=' + (queryResult && queryResult.status) + ', stderr=' + (queryResult && queryResult.stderr ? String(queryResult.stderr).slice(0, 200) : '') + ')');
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
var parsed = JSON.parse(queryResult.stdout);
|
|
162
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
163
|
+
} catch (e) {
|
|
164
|
+
appendLog('failed to parse memory query stdout as JSON: ' + e.message);
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
var fileConfig = readProjectConfig();
|
|
170
|
+
var envTag = String(process.env.EMPIRICAL_PROJECT_TAG || '').trim().toLowerCase();
|
|
171
|
+
var envTopics = String(process.env.EMPIRICAL_TOPICS || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
|
172
|
+
|
|
173
|
+
var tag = envTag || fileConfig.project;
|
|
174
|
+
var tagSource = envTag ? 'env' : (fileConfig.project ? 'config' : 'cwd');
|
|
175
|
+
if (!tag) {
|
|
176
|
+
try {
|
|
177
|
+
tag = String(path.basename(process.cwd() || '') || '').trim().toLowerCase();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
tag = '';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
var topics = envTopics.length ? envTopics : fileConfig.topics;
|
|
184
|
+
var contextMatch = topics.length
|
|
185
|
+
? topics.join(', ')
|
|
186
|
+
: 'project context, conventions, recent decisions, user preferences';
|
|
187
|
+
|
|
188
|
+
appendLog('project tag=' + (tag || '(none)') + ' (source=' + tagSource + '), topics=' + (topics.length ? topics.join('|') : '(default)'));
|
|
189
|
+
|
|
190
|
+
// Scope every query to the project. Measured 2026-09-16: with no tag filter all 8 slots went to
|
|
191
|
+
// one unrelated project during a full session in a different repo, so the only automatic context
|
|
192
|
+
// rail in the product was spending every slot on the wrong thing. A tag filter also disables the
|
|
193
|
+
// server's agent-process-note exclusion, which is what makes a project's own scar tissue and
|
|
194
|
+
// work history reachable here at all.
|
|
195
|
+
//
|
|
196
|
+
// Open items get their OWN query instead of competing for rank. Tested 2026-09-16: a due-dated
|
|
197
|
+
// memory did not surface in the generic top-8 with or without a project filter, because dueness
|
|
198
|
+
// is not a semantic property. Anything that must be seen needs a reserved slot.
|
|
199
|
+
// AND, not OR: this must be THIS project's todos. No --status filter on purpose, because todos
|
|
200
|
+
// written without a data.status would silently vanish from the one place they get surfaced;
|
|
201
|
+
// the label tells the reader to confirm instead.
|
|
202
|
+
var todos = tag ? fetchMemories('open todo, next action, follow-up, due', tag + ',todo', 3, true) : [];
|
|
203
|
+
var context = tag ? fetchMemories(contextMatch, tag, 6) : [];
|
|
204
|
+
|
|
205
|
+
// A corpus whose tags do not match the resolved project must never end up worse off than before
|
|
206
|
+
// this change, so fall back to the original unscoped query.
|
|
207
|
+
if (context.length === 0) {
|
|
208
|
+
appendLog('project-scoped query returned nothing, falling back to unscoped');
|
|
209
|
+
context = fetchMemories(contextMatch, '', 8);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (todos.length === 0 && context.length === 0) {
|
|
213
|
+
appendLog('memory query returned zero results');
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
var openCount = todos.length;
|
|
218
|
+
var results = todos.concat(context);
|
|
219
|
+
|
|
220
|
+
appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
|
|
221
|
+
|
|
222
|
+
// Summaries in a mature corpus run to thousands of characters. Injecting eight in full is a wall
|
|
223
|
+
// of text that gets skimmed past, which fails the same way as never injecting them at all:
|
|
224
|
+
// presence in context is not attention. Truncate hard; the agent can query for the rest.
|
|
225
|
+
var lines = results.slice(0, 9).map(function (r, i) {
|
|
226
|
+
var category = r && r.category ? r.category : 'memory';
|
|
227
|
+
var summary = String(r && r.summary ? r.summary : '');
|
|
228
|
+
summary = summary.split('\\n').join(' ').split('\\r').join(' ').split('\\t').join(' ').trim();
|
|
229
|
+
if (summary.length > 240) { summary = summary.slice(0, 237) + '...'; }
|
|
230
|
+
var prefix = i < openCount ? '- [OPEN ITEM, confirm status] ' : '- [' + category + '] ';
|
|
231
|
+
return prefix + summary;
|
|
232
|
+
});
|
|
233
|
+
lines.push('- Before risky or repeated work, ask first: empirical memory query --match "<what you are about to do>" --tags scar-tissue');
|
|
234
|
+
|
|
235
|
+
console.log('Empirical memory context for this session:\\n' + lines.join('\\n'));
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Hooks must never surface as a hard failure to the host CLI (Codex/Claude/Copilot all render a
|
|
240
|
+
// nonzero exit as a red "hook failed" error) - an uncaught exception anywhere above (e.g. an
|
|
241
|
+
// environment that denies process spawn or fs access, such as Codex's experimental Windows
|
|
242
|
+
// sandbox) must degrade to a silent no-op, not a visible crash.
|
|
243
|
+
try {
|
|
244
|
+
main();
|
|
245
|
+
} catch (e) {
|
|
246
|
+
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
// Codex-only variant of the SessionStart hook above. Confirmed live (2026-08-08, via a real
|
|
252
|
+
// `codex exec` run) that Codex requires valid JSON on stdout for a hook's output to actually
|
|
253
|
+
// register/display - plain text (what CLAUDE_HOOK_SESSION_START_SOURCE emits, which Claude Code
|
|
254
|
+
// handles fine) makes Codex report the hook as failed. Uses the same hookSpecificOutput.
|
|
255
|
+
// additionalContext shape Claude's own JSON-output hooks use (see CLAUDE_HOOK_STOP_SOURCE below) -
|
|
256
|
+
// confirmed accepted by Codex's SessionStart handler specifically. Otherwise byte-identical to
|
|
257
|
+
// CLAUDE_HOOK_SESSION_START_SOURCE; keep any future logic changes in sync between the two.
|
|
258
|
+
const CODEX_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
|
|
259
|
+
'use strict';
|
|
260
|
+
|
|
261
|
+
var spawnSync = require('child_process').spawnSync;
|
|
262
|
+
var fs = require('fs');
|
|
263
|
+
var os = require('os');
|
|
264
|
+
var path = require('path');
|
|
265
|
+
|
|
266
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
|
|
267
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
268
|
+
|
|
269
|
+
function appendLog(line) {
|
|
270
|
+
try {
|
|
271
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
272
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
273
|
+
var stat = fs.statSync(LOG_PATH);
|
|
274
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
275
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
276
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
// Diagnostics must never break session start.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveEmpiricalEntryPoint() {
|
|
284
|
+
if (process.platform !== 'win32') {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
|
|
289
|
+
if (where.status !== 0 || !where.stdout) return null;
|
|
290
|
+
var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
|
|
291
|
+
if (!cmdPath || !fs.existsSync(cmdPath)) return null;
|
|
292
|
+
var cmdContent = fs.readFileSync(cmdPath, 'utf8');
|
|
293
|
+
var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
|
|
294
|
+
if (!match) return null;
|
|
295
|
+
var candidate = path.join(path.dirname(cmdPath), match[1]);
|
|
296
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function commandExists(cmd) {
|
|
303
|
+
var probe = process.platform === 'win32'
|
|
304
|
+
? spawnSync('where', [cmd], { stdio: 'ignore', shell: true })
|
|
305
|
+
: spawnSync('which', [cmd], { stdio: 'ignore' });
|
|
306
|
+
return probe.status === 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function runMemoryQuery(args) {
|
|
310
|
+
var entryPoint = resolveEmpiricalEntryPoint();
|
|
311
|
+
if (entryPoint) {
|
|
312
|
+
appendLog('resolved direct entry point: ' + entryPoint);
|
|
313
|
+
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
314
|
+
encoding: 'utf8',
|
|
315
|
+
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
316
|
+
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
317
|
+
timeout: 20000,
|
|
318
|
+
shell: false
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
323
|
+
return spawnSync('empirical', args, {
|
|
324
|
+
encoding: 'utf8',
|
|
325
|
+
timeout: 20000,
|
|
326
|
+
shell: process.platform === 'win32'
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Codex requires VALID JSON on stdout for every hook run. Emitting nothing is as broken as
|
|
331
|
+
// emitting plain text: both surface to the user as "SessionStart hook (failed)". Every early
|
|
332
|
+
// return below is a legitimate "no memory context to add" outcome (CLI missing, offline,
|
|
333
|
+
// expired token, zero results), so each one now emits a well-formed envelope with an empty
|
|
334
|
+
// context instead of exiting silently. Found 2026-09-09: with the API unreachable this hook
|
|
335
|
+
// printed nothing at all and exited 0.
|
|
336
|
+
function emitContext(context) {
|
|
337
|
+
console.log(JSON.stringify({
|
|
338
|
+
hookSpecificOutput: {
|
|
339
|
+
hookEventName: 'SessionStart',
|
|
340
|
+
additionalContext: context || ''
|
|
341
|
+
}
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function main() {
|
|
346
|
+
appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
347
|
+
|
|
348
|
+
if (!commandExists('empirical')) {
|
|
349
|
+
appendLog('empirical CLI not found on PATH, exiting');
|
|
350
|
+
emitContext('');
|
|
351
|
+
process.exit(0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Declared inside main() on purpose: function declarations hoist, and other hook templates in
|
|
355
|
+
// this file have their own main() without the memory-query plumbing.
|
|
356
|
+
|
|
357
|
+
// WHAT PROJECT IS THIS, in priority order. Guessing from the directory name is the last resort,
|
|
358
|
+
// not the contract: a repo folder is not always what the corpus tags memories with.
|
|
359
|
+
// 1. EMPIRICAL_PROJECT_TAG / EMPIRICAL_TOPICS environment variables
|
|
360
|
+
// 2. .empirical.json in the working directory or any ancestor, up to 6 levels:
|
|
361
|
+
// { "project": "empirical_v2", "topics": ["search ranking", "deploy"] }
|
|
362
|
+
// 3. the working directory name, lowercased
|
|
363
|
+
function readProjectConfig() {
|
|
364
|
+
var config = { project: '', topics: [] };
|
|
365
|
+
try {
|
|
366
|
+
var dir = process.cwd();
|
|
367
|
+
for (var depth = 0; depth < 6; depth += 1) {
|
|
368
|
+
var candidate = path.join(dir, '.empirical.json');
|
|
369
|
+
if (fs.existsSync(candidate)) {
|
|
370
|
+
var parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
371
|
+
config.project = String(parsed && parsed.project ? parsed.project : '').trim().toLowerCase();
|
|
372
|
+
if (parsed && Array.isArray(parsed.topics)) {
|
|
373
|
+
config.topics = parsed.topics.map(function (t) { return String(t || '').trim(); }).filter(Boolean);
|
|
374
|
+
}
|
|
375
|
+
appendLog('loaded project config from ' + candidate);
|
|
376
|
+
return config;
|
|
377
|
+
}
|
|
378
|
+
var parent = path.dirname(dir);
|
|
379
|
+
if (!parent || parent === dir) { break; }
|
|
380
|
+
dir = parent;
|
|
381
|
+
}
|
|
382
|
+
} catch (e) {
|
|
383
|
+
appendLog('project config unreadable, continuing without it: ' + e.message);
|
|
384
|
+
}
|
|
385
|
+
return config;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// requireAll selects --tags-all (AND) over --tags (OR). This matters: --tags is an OR match, so
|
|
389
|
+
// asking for 'empirical_v2,todo' returns every project's todos, which put a wellnessonbalance
|
|
390
|
+
// item into an empirical_v2 session during testing on 2026-09-16.
|
|
391
|
+
function fetchMemories(matchText, tags, topK, requireAll) {
|
|
392
|
+
var args = ['memory', 'query', '--match', matchText, '--top-k', String(topK)];
|
|
393
|
+
if (tags) { args.push(requireAll ? '--tags-all' : '--tags', tags); }
|
|
394
|
+
var queryResult = runMemoryQuery(args);
|
|
395
|
+
if (!queryResult || queryResult.status !== 0 || !queryResult.stdout) {
|
|
396
|
+
appendLog('memory query failed (tags=' + (tags || 'none') + ', status=' + (queryResult && queryResult.status) + ', stderr=' + (queryResult && queryResult.stderr ? String(queryResult.stderr).slice(0, 200) : '') + ')');
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
var parsed = JSON.parse(queryResult.stdout);
|
|
401
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
402
|
+
} catch (e) {
|
|
403
|
+
appendLog('failed to parse memory query stdout as JSON: ' + e.message);
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
var fileConfig = readProjectConfig();
|
|
409
|
+
var envTag = String(process.env.EMPIRICAL_PROJECT_TAG || '').trim().toLowerCase();
|
|
410
|
+
var envTopics = String(process.env.EMPIRICAL_TOPICS || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
|
411
|
+
|
|
412
|
+
var tag = envTag || fileConfig.project;
|
|
413
|
+
var tagSource = envTag ? 'env' : (fileConfig.project ? 'config' : 'cwd');
|
|
414
|
+
if (!tag) {
|
|
415
|
+
try {
|
|
416
|
+
tag = String(path.basename(process.cwd() || '') || '').trim().toLowerCase();
|
|
417
|
+
} catch (e) {
|
|
418
|
+
tag = '';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
var topics = envTopics.length ? envTopics : fileConfig.topics;
|
|
423
|
+
var contextMatch = topics.length
|
|
424
|
+
? topics.join(', ')
|
|
425
|
+
: 'project context, conventions, recent decisions, user preferences';
|
|
426
|
+
|
|
427
|
+
appendLog('project tag=' + (tag || '(none)') + ' (source=' + tagSource + '), topics=' + (topics.length ? topics.join('|') : '(default)'));
|
|
428
|
+
|
|
429
|
+
// Scope every query to the project. Measured 2026-09-16: with no tag filter all 8 slots went to
|
|
430
|
+
// one unrelated project during a full session in a different repo, so the only automatic context
|
|
431
|
+
// rail in the product was spending every slot on the wrong thing. A tag filter also disables the
|
|
432
|
+
// server's agent-process-note exclusion, which is what makes a project's own scar tissue and
|
|
433
|
+
// work history reachable here at all.
|
|
434
|
+
//
|
|
435
|
+
// Open items get their OWN query instead of competing for rank. Tested 2026-09-16: a due-dated
|
|
436
|
+
// memory did not surface in the generic top-8 with or without a project filter, because dueness
|
|
437
|
+
// is not a semantic property. Anything that must be seen needs a reserved slot.
|
|
438
|
+
// AND, not OR: this must be THIS project's todos. No --status filter on purpose, because todos
|
|
439
|
+
// written without a data.status would silently vanish from the one place they get surfaced;
|
|
440
|
+
// the label tells the reader to confirm instead.
|
|
441
|
+
var todos = tag ? fetchMemories('open todo, next action, follow-up, due', tag + ',todo', 3, true) : [];
|
|
442
|
+
var context = tag ? fetchMemories(contextMatch, tag, 6) : [];
|
|
443
|
+
|
|
444
|
+
// A corpus whose tags do not match the resolved project must never end up worse off than before
|
|
445
|
+
// this change, so fall back to the original unscoped query.
|
|
446
|
+
if (context.length === 0) {
|
|
447
|
+
appendLog('project-scoped query returned nothing, falling back to unscoped');
|
|
448
|
+
context = fetchMemories(contextMatch, '', 8);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (todos.length === 0 && context.length === 0) {
|
|
452
|
+
appendLog('memory query returned zero results');
|
|
453
|
+
emitContext('');
|
|
454
|
+
process.exit(0);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
var openCount = todos.length;
|
|
458
|
+
var results = todos.concat(context);
|
|
459
|
+
|
|
460
|
+
appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
|
|
461
|
+
|
|
462
|
+
// Summaries in a mature corpus run to thousands of characters. Injecting eight in full is a wall
|
|
463
|
+
// of text that gets skimmed past, which fails the same way as never injecting them at all:
|
|
464
|
+
// presence in context is not attention. Truncate hard; the agent can query for the rest.
|
|
465
|
+
var lines = results.slice(0, 9).map(function (r, i) {
|
|
466
|
+
var category = r && r.category ? r.category : 'memory';
|
|
467
|
+
var summary = String(r && r.summary ? r.summary : '');
|
|
468
|
+
summary = summary.split('\\n').join(' ').split('\\r').join(' ').split('\\t').join(' ').trim();
|
|
469
|
+
if (summary.length > 240) { summary = summary.slice(0, 237) + '...'; }
|
|
470
|
+
var prefix = i < openCount ? '- [OPEN ITEM, confirm status] ' : '- [' + category + '] ';
|
|
471
|
+
return prefix + summary;
|
|
472
|
+
});
|
|
473
|
+
lines.push('- Before risky or repeated work, ask first: empirical memory query --match "<what you are about to do>" --tags scar-tissue');
|
|
474
|
+
|
|
475
|
+
console.log(JSON.stringify({
|
|
476
|
+
hookSpecificOutput: {
|
|
477
|
+
hookEventName: 'SessionStart',
|
|
478
|
+
additionalContext: 'Empirical memory context for this session:\\n' + lines.join('\\n')
|
|
479
|
+
}
|
|
480
|
+
}));
|
|
481
|
+
process.exit(0);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
main();
|
|
486
|
+
} catch (e) {
|
|
487
|
+
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
488
|
+
try { emitContext(''); } catch (_e2) { /* stdout already unusable */ }
|
|
489
|
+
process.exit(0);
|
|
490
|
+
}
|
|
491
|
+
`;
|
|
492
|
+
|
|
493
|
+
// Copilot-only variant of the SessionStart hook. Per GitHub's own hooks reference (fetched
|
|
494
|
+
// 2026-08-08): unlike Claude/Codex, Copilot's sessionStart event has NO output mechanism at all
|
|
495
|
+
// - there is no additionalContext-equivalent field, so a hook cannot inject memory context into
|
|
496
|
+
// the session this way, full stop (this is a genuine platform limitation, not a bug to work
|
|
497
|
+
// around). Separately, Copilot requires ALL hook stdout to be valid JSON parsed via a single
|
|
498
|
+
// JSON.parse call - plain text (fine for Claude, and previously assumed fine here too) is
|
|
499
|
+
// invalid and makes Copilot log a hook execution failure. So this variant still runs the memory
|
|
500
|
+
// query and logs the result for diagnostic/audit purposes, but emits {} (empty, valid JSON,
|
|
501
|
+
// explicitly documented as "use defaults") instead of attempting to inject anything.
|
|
502
|
+
const COPILOT_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
|
|
503
|
+
'use strict';
|
|
504
|
+
|
|
505
|
+
var spawnSync = require('child_process').spawnSync;
|
|
506
|
+
var fs = require('fs');
|
|
507
|
+
var os = require('os');
|
|
508
|
+
var path = require('path');
|
|
509
|
+
|
|
510
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'session-start.log');
|
|
511
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
512
|
+
|
|
513
|
+
function appendLog(line) {
|
|
514
|
+
try {
|
|
515
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
516
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
517
|
+
var stat = fs.statSync(LOG_PATH);
|
|
518
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
519
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
520
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
521
|
+
}
|
|
522
|
+
} catch (e) {
|
|
523
|
+
// Diagnostics must never break session start.
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function resolveEmpiricalEntryPoint() {
|
|
528
|
+
if (process.platform !== 'win32') {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
var where = spawnSync('where', ['empirical.cmd'], { encoding: 'utf8', timeout: 5000, shell: true });
|
|
533
|
+
if (where.status !== 0 || !where.stdout) return null;
|
|
534
|
+
var cmdPath = where.stdout.split(/\\r?\\n/).map(function (s) { return s.trim(); }).filter(Boolean)[0];
|
|
535
|
+
if (!cmdPath || !fs.existsSync(cmdPath)) return null;
|
|
536
|
+
var cmdContent = fs.readFileSync(cmdPath, 'utf8');
|
|
537
|
+
var match = cmdContent.match(/"%dp0%\\\\(node_modules\\\\.+?\\.js)"/);
|
|
538
|
+
if (!match) return null;
|
|
539
|
+
var candidate = path.join(path.dirname(cmdPath), match[1]);
|
|
540
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
541
|
+
} catch (e) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function commandExists(cmd) {
|
|
547
|
+
var probe = process.platform === 'win32'
|
|
548
|
+
? spawnSync('where', [cmd], { stdio: 'ignore', shell: true })
|
|
549
|
+
: spawnSync('which', [cmd], { stdio: 'ignore' });
|
|
550
|
+
return probe.status === 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function runMemoryQuery(args) {
|
|
554
|
+
var entryPoint = resolveEmpiricalEntryPoint();
|
|
555
|
+
if (entryPoint) {
|
|
556
|
+
appendLog('resolved direct entry point: ' + entryPoint);
|
|
557
|
+
return spawnSync(process.execPath, [entryPoint].concat(args), {
|
|
558
|
+
encoding: 'utf8',
|
|
559
|
+
// Must stay under the host manifest cap for this event (25s). Recall is chronically 2-7s
|
|
560
|
+
// and the old 10s here killed the query silently while the host still had 15s to spare.
|
|
561
|
+
timeout: 20000,
|
|
562
|
+
shell: false
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
|
|
567
|
+
return spawnSync('empirical', args, {
|
|
568
|
+
encoding: 'utf8',
|
|
569
|
+
timeout: 20000,
|
|
570
|
+
shell: process.platform === 'win32'
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 2026-09-18: GitHub's own hooks reference (re-checked - the August 2026 check this file used to
|
|
575
|
+
// cite was stale) confirms sessionStart CAN inject additionalContext for command-type hooks, same
|
|
576
|
+
// flat { additionalContext: "..." } shape postToolUse etc. use. This event's input payload also
|
|
577
|
+
// carries an optional initialPrompt - using it as the query (like the removed UserPromptSubmit
|
|
578
|
+
// hook did for its own prompt) makes the one real query relevant to what's actually being asked,
|
|
579
|
+
// not just a generic default.
|
|
580
|
+
function readStdinPayload() {
|
|
581
|
+
try {
|
|
582
|
+
var raw = fs.readFileSync(0, 'utf8');
|
|
583
|
+
return raw ? JSON.parse(raw) : {};
|
|
584
|
+
} catch (e) {
|
|
585
|
+
return {};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function main() {
|
|
590
|
+
appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
591
|
+
var payload = readStdinPayload();
|
|
592
|
+
var initialPrompt = String((payload && (payload.initialPrompt || payload.initial_prompt)) || '').trim();
|
|
593
|
+
|
|
594
|
+
if (!commandExists('empirical')) {
|
|
595
|
+
appendLog('empirical CLI not found on PATH, exiting');
|
|
596
|
+
console.log('{}');
|
|
597
|
+
process.exit(0);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Declared inside main() on purpose: function declarations hoist, and other hook templates in
|
|
601
|
+
// this file have their own main() without the memory-query plumbing.
|
|
602
|
+
|
|
603
|
+
// WHAT PROJECT IS THIS, in priority order. Guessing from the directory name is the last resort,
|
|
604
|
+
// not the contract: a repo folder is not always what the corpus tags memories with.
|
|
605
|
+
// 1. EMPIRICAL_PROJECT_TAG / EMPIRICAL_TOPICS environment variables
|
|
606
|
+
// 2. .empirical.json in the working directory or any ancestor, up to 6 levels:
|
|
607
|
+
// { "project": "empirical_v2", "topics": ["search ranking", "deploy"] }
|
|
608
|
+
// 3. the working directory name, lowercased
|
|
609
|
+
function readProjectConfig() {
|
|
610
|
+
var config = { project: '', topics: [] };
|
|
611
|
+
try {
|
|
612
|
+
var dir = process.cwd();
|
|
613
|
+
for (var depth = 0; depth < 6; depth += 1) {
|
|
614
|
+
var candidate = path.join(dir, '.empirical.json');
|
|
615
|
+
if (fs.existsSync(candidate)) {
|
|
616
|
+
var parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
617
|
+
config.project = String(parsed && parsed.project ? parsed.project : '').trim().toLowerCase();
|
|
618
|
+
if (parsed && Array.isArray(parsed.topics)) {
|
|
619
|
+
config.topics = parsed.topics.map(function (t) { return String(t || '').trim(); }).filter(Boolean);
|
|
620
|
+
}
|
|
621
|
+
appendLog('loaded project config from ' + candidate);
|
|
622
|
+
return config;
|
|
623
|
+
}
|
|
624
|
+
var parent = path.dirname(dir);
|
|
625
|
+
if (!parent || parent === dir) { break; }
|
|
626
|
+
dir = parent;
|
|
627
|
+
}
|
|
628
|
+
} catch (e) {
|
|
629
|
+
appendLog('project config unreadable, continuing without it: ' + e.message);
|
|
630
|
+
}
|
|
631
|
+
return config;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// requireAll selects --tags-all (AND) over --tags (OR). This matters: --tags is an OR match, so
|
|
635
|
+
// asking for 'empirical_v2,todo' returns every project's todos, which put a wellnessonbalance
|
|
636
|
+
// item into an empirical_v2 session during testing on 2026-09-16.
|
|
637
|
+
function fetchMemories(matchText, tags, topK, requireAll) {
|
|
638
|
+
var args = ['memory', 'query', '--match', matchText, '--top-k', String(topK)];
|
|
639
|
+
if (tags) { args.push(requireAll ? '--tags-all' : '--tags', tags); }
|
|
640
|
+
var queryResult = runMemoryQuery(args);
|
|
641
|
+
if (!queryResult || queryResult.status !== 0 || !queryResult.stdout) {
|
|
642
|
+
appendLog('memory query failed (tags=' + (tags || 'none') + ', status=' + (queryResult && queryResult.status) + ', stderr=' + (queryResult && queryResult.stderr ? String(queryResult.stderr).slice(0, 200) : '') + ')');
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
var parsed = JSON.parse(queryResult.stdout);
|
|
647
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
648
|
+
} catch (e) {
|
|
649
|
+
appendLog('failed to parse memory query stdout as JSON: ' + e.message);
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
var fileConfig = readProjectConfig();
|
|
655
|
+
var envTag = String(process.env.EMPIRICAL_PROJECT_TAG || '').trim().toLowerCase();
|
|
656
|
+
var envTopics = String(process.env.EMPIRICAL_TOPICS || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
|
657
|
+
|
|
658
|
+
var tag = envTag || fileConfig.project;
|
|
659
|
+
var tagSource = envTag ? 'env' : (fileConfig.project ? 'config' : 'cwd');
|
|
660
|
+
if (!tag) {
|
|
661
|
+
try {
|
|
662
|
+
tag = String(path.basename(process.cwd() || '') || '').trim().toLowerCase();
|
|
663
|
+
} catch (e) {
|
|
664
|
+
tag = '';
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
var topics = envTopics.length ? envTopics : fileConfig.topics;
|
|
669
|
+
var contextMatch = topics.length
|
|
670
|
+
? topics.join(', ')
|
|
671
|
+
: (initialPrompt || 'project context, conventions, recent decisions, user preferences');
|
|
672
|
+
|
|
673
|
+
appendLog('project tag=' + (tag || '(none)') + ' (source=' + tagSource + '), topics=' + (topics.length ? topics.join('|') : '(default)') + ', usingInitialPrompt=' + Boolean(initialPrompt && !topics.length));
|
|
674
|
+
|
|
675
|
+
// Scope every query to the project. Measured 2026-09-16: with no tag filter all 8 slots went to
|
|
676
|
+
// one unrelated project during a full session in a different repo, so the only automatic context
|
|
677
|
+
// rail in the product was spending every slot on the wrong thing. A tag filter also disables the
|
|
678
|
+
// server's agent-process-note exclusion, which is what makes a project's own scar tissue and
|
|
679
|
+
// work history reachable here at all.
|
|
680
|
+
//
|
|
681
|
+
// Open items get their OWN query instead of competing for rank. Tested 2026-09-16: a due-dated
|
|
682
|
+
// memory did not surface in the generic top-8 with or without a project filter, because dueness
|
|
683
|
+
// is not a semantic property. Anything that must be seen needs a reserved slot.
|
|
684
|
+
// AND, not OR: this must be THIS project's todos. No --status filter on purpose, because todos
|
|
685
|
+
// written without a data.status would silently vanish from the one place they get surfaced;
|
|
686
|
+
// the label tells the reader to confirm instead.
|
|
687
|
+
var todos = tag ? fetchMemories('open todo, next action, follow-up, due', tag + ',todo', 3, true) : [];
|
|
688
|
+
var context = tag ? fetchMemories(contextMatch, tag, 6) : [];
|
|
689
|
+
|
|
690
|
+
// A corpus whose tags do not match the resolved project must never end up worse off than before
|
|
691
|
+
// this change, so fall back to the original unscoped query.
|
|
692
|
+
if (context.length === 0) {
|
|
693
|
+
appendLog('project-scoped query returned nothing, falling back to unscoped');
|
|
694
|
+
context = fetchMemories(contextMatch, '', 8);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (todos.length === 0 && context.length === 0) {
|
|
698
|
+
appendLog('memory query returned zero results');
|
|
699
|
+
console.log('{}');
|
|
700
|
+
process.exit(0);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
var openCount = todos.length;
|
|
704
|
+
var results = todos.concat(context);
|
|
705
|
+
|
|
706
|
+
// Summaries in a mature corpus run to thousands of characters. Injecting eight in full is a wall
|
|
707
|
+
// of text that gets skimmed past, which fails the same way as never injecting them at all:
|
|
708
|
+
// presence in context is not attention. Truncate hard; the agent can query for the rest.
|
|
709
|
+
var lines = results.slice(0, 9).map(function (r, i) {
|
|
710
|
+
var category = r && r.category ? r.category : 'memory';
|
|
711
|
+
var summary = String(r && r.summary ? r.summary : '');
|
|
712
|
+
summary = summary.split('\\n').join(' ').split('\\r').join(' ').split('\\t').join(' ').trim();
|
|
713
|
+
if (summary.length > 240) { summary = summary.slice(0, 237) + '...'; }
|
|
714
|
+
var prefix = i < openCount ? '- [OPEN ITEM, confirm status] ' : '- [' + category + '] ';
|
|
715
|
+
return prefix + summary;
|
|
716
|
+
});
|
|
717
|
+
lines.push('- Before risky or repeated work, ask first: empirical memory query --match "<what you are about to do>" --tags scar-tissue');
|
|
718
|
+
|
|
719
|
+
// additionalContext is honored for command-type sessionStart hooks (re-confirmed 2026-09-18
|
|
720
|
+
// against GitHub's current hooks reference - the prior "no output channel" comment here was
|
|
721
|
+
// stale). Delivered invisibly: this never touches the visible prompt, unlike the UserPromptSubmit
|
|
722
|
+
// approach it replaces (removed - see installCopilotHooks's comment).
|
|
723
|
+
var block = 'Empirical memory context for this session:\\n' + lines.join('\\n');
|
|
724
|
+
appendLog('memory query returned ' + results.length + ' result(s), injecting via additionalContext');
|
|
725
|
+
console.log(JSON.stringify({ additionalContext: block }));
|
|
726
|
+
process.exit(0);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
main();
|
|
731
|
+
} catch (e) {
|
|
732
|
+
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
733
|
+
console.log('{}');
|
|
734
|
+
process.exit(0);
|
|
735
|
+
}
|
|
736
|
+
`;
|
|
737
|
+
|
|
738
|
+
// Copilot has no UserPromptSubmit real-recall hook (removed 2026-09-18, superseded by
|
|
739
|
+
// additionalContext delivery through COPILOT_HOOK_SESSION_START_SOURCE above). That approach was
|
|
740
|
+
// abandoned because Copilot renders the ENTIRE modifiedPrompt back in the terminal as the user's
|
|
741
|
+
// own visible message - confirmed live, not documented, and not what "real hook" was supposed to
|
|
742
|
+
// mean. sessionStart's additionalContext (re-confirmed against current GitHub docs - the prior
|
|
743
|
+
// "no output channel" comment on that hook was stale) delivers the same real query invisibly
|
|
744
|
+
// instead, at the cost of firing once per real session rather than being keyed to the very first
|
|
745
|
+
// prompt's exact text (it uses the sessionStart payload's own initialPrompt field instead, which
|
|
746
|
+
// is present per GitHub's schema but not verified live as of this comment).
|
|
747
|
+
|
|
748
|
+
// Codex-only variant of the Stop reminder. Confirmed live (2026-08-08, via a real `codex
|
|
749
|
+
// exec` run) that Codex's Stop hook specifically requires a { systemMessage: "..." } JSON shape
|
|
750
|
+
// on stdout - plain text makes Codex report the hook as failed. Distinct from Claude's own JSON
|
|
751
|
+
// shape (hookSpecificOutput.additionalContext, see CLAUDE_HOOK_STOP_SOURCE below). Copilot has no
|
|
752
|
+
// Stop hook at all (removed 2026-09-18, see the comment above CLAUDE_HOOK_STOP_SOURCE).
|
|
753
|
+
const CODEX_HOOK_STOP_SOURCE = `#!/usr/bin/env node
|
|
754
|
+
'use strict';
|
|
755
|
+
|
|
756
|
+
var fs = require('fs');
|
|
757
|
+
var os = require('os');
|
|
758
|
+
var path = require('path');
|
|
759
|
+
|
|
760
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
|
|
761
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
762
|
+
|
|
763
|
+
function appendLog(line) {
|
|
764
|
+
try {
|
|
765
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
766
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
767
|
+
var stat = fs.statSync(LOG_PATH);
|
|
768
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
769
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
770
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
771
|
+
}
|
|
772
|
+
} catch (e) {
|
|
773
|
+
// Diagnostics must never break the hook.
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function readStdinPayload() {
|
|
778
|
+
try {
|
|
779
|
+
var raw = fs.readFileSync(0, 'utf8');
|
|
780
|
+
return raw ? JSON.parse(raw) : {};
|
|
781
|
+
} catch (e) {
|
|
782
|
+
return {};
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function main() {
|
|
787
|
+
var payload = readStdinPayload();
|
|
788
|
+
|
|
789
|
+
if (payload && payload.stop_hook_active === true) {
|
|
790
|
+
appendLog('stop_hook_active=true, skipping reminder');
|
|
791
|
+
process.exit(0);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
795
|
+
|
|
796
|
+
console.log(JSON.stringify({
|
|
797
|
+
systemMessage: '[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now, one memory per finding (a fix, a root cause, a decision, a lesson), via the empirical-memory, empirical-work-history, or empirical-scar-tissue skill or \`empirical memory record\`. Local notes are not recorded. Do not wait to be asked.'
|
|
798
|
+
}));
|
|
799
|
+
process.exit(0);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
main();
|
|
804
|
+
} catch (e) {
|
|
805
|
+
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
806
|
+
process.exit(0);
|
|
807
|
+
}
|
|
808
|
+
`;
|
|
809
|
+
|
|
810
|
+
// Copilot's Stop reminder (agentStop) was removed entirely 2026-09-18 - see installCopilotHooks's
|
|
811
|
+
// comment in this file for why (agentStop only supports decision:"block"|"allow" with no passive
|
|
812
|
+
// field, so any reminder there forces a disruptive extra visible turn; no per-session cap on that
|
|
813
|
+
// avoids the tradeoff between nagging every turn and going silent right before something worth
|
|
814
|
+
// recording happens - confirmed live 2026-09-17/18 causing both failure modes in turn). Copilot
|
|
815
|
+
// relies entirely on copilot-instructions.md instead (see coreMemoryBlock.js's Copilot addendum).
|
|
816
|
+
|
|
817
|
+
// Claude-only variant of the Stop reminder above. hookSpecificOutput.additionalContext (stdout,
|
|
818
|
+
// exit 0) is Claude Code's non-blocking Stop-hook feedback channel: the text is fed into context
|
|
819
|
+
// and the conversation continues, same outcome the earlier exit-2 approach achieved, but the
|
|
820
|
+
// transcript UI renders it as a plain system reminder instead of a red "Stop hook error" line.
|
|
821
|
+
// Codex needs its own systemMessage shape (CODEX_HOOK_STOP_SOURCE) - plain console.log output
|
|
822
|
+
// (what this hook used before the additionalContext fix) never reaches the model. Copilot has no
|
|
823
|
+
// Stop hook at all (removed 2026-09-18, see the comment just above this one).
|
|
824
|
+
const CLAUDE_HOOK_STOP_SOURCE = `#!/usr/bin/env node
|
|
825
|
+
'use strict';
|
|
826
|
+
|
|
827
|
+
var fs = require('fs');
|
|
828
|
+
var os = require('os');
|
|
829
|
+
var path = require('path');
|
|
830
|
+
|
|
831
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
|
|
832
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
833
|
+
|
|
834
|
+
function appendLog(line) {
|
|
835
|
+
try {
|
|
836
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
837
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
838
|
+
var stat = fs.statSync(LOG_PATH);
|
|
839
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
840
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
841
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
842
|
+
}
|
|
843
|
+
} catch (e) {
|
|
844
|
+
// Diagnostics must never break the hook.
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function readStdinPayload() {
|
|
849
|
+
try {
|
|
850
|
+
var raw = fs.readFileSync(0, 'utf8');
|
|
851
|
+
return raw ? JSON.parse(raw) : {};
|
|
852
|
+
} catch (e) {
|
|
853
|
+
return {};
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function main() {
|
|
858
|
+
var payload = readStdinPayload();
|
|
859
|
+
|
|
860
|
+
// stop_hook_active === true means Claude is re-invoking this hook because a PRIOR Stop hook
|
|
861
|
+
// (possibly this same one) already surfaced additionalContext and forced a continuation this
|
|
862
|
+
// turn. additionalContext causes the same forced-continuation behavior decision:block does -
|
|
863
|
+
// confirmed empirically: without this guard the hook re-fired every ~2-3s in an infinite loop,
|
|
864
|
+
// since each additionalContext output triggers another Stop event. Must exit 0 with no output
|
|
865
|
+
// here, unconditionally, or the loop continues.
|
|
866
|
+
if (payload && payload.stop_hook_active === true) {
|
|
867
|
+
appendLog('stop_hook_active=true, skipping reminder (already surfaced once this turn)');
|
|
868
|
+
process.exit(0);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + '), surfacing reminder via additionalContext');
|
|
872
|
+
|
|
873
|
+
process.stdout.write(JSON.stringify({
|
|
874
|
+
hookSpecificOutput: {
|
|
875
|
+
hookEventName: 'Stop',
|
|
876
|
+
additionalContext: '[Empirical] Record memory-worthy work now: one memory per finding (a fix, a root cause, a decision, a lesson) via the empirical-memory, empirical-work-history, or empirical-scar-tissue skill. Local notes are not recorded.'
|
|
877
|
+
}
|
|
878
|
+
}) + '\\n');
|
|
879
|
+
process.exit(0);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// See EMPIRICAL_HOOK_SESSION_START_SOURCE's identical guard: a crash here must degrade to a
|
|
883
|
+
// silent no-op, never a visible "hook failed" error in the host CLI.
|
|
884
|
+
try {
|
|
885
|
+
main();
|
|
886
|
+
} catch (e) {
|
|
887
|
+
try { appendLog('uncaught error in main(): ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
888
|
+
process.exit(0);
|
|
889
|
+
}
|
|
890
|
+
`;
|
|
891
|
+
|
|
892
|
+
// UserPromptSubmit: fires before each turn is processed (Claude, Codex, and Copilot all support
|
|
893
|
+
// this event under the same name per each platform's docs/source). This is the recall-side
|
|
894
|
+
// counterpart to Stop's write-side reminder - deliberately a STATIC message, not a live
|
|
895
|
+
// `empirical memory query` call: a prior per-turn hook in this codebase was removed for making an
|
|
896
|
+
// unconditional network call on every single prompt (see the comment above installClaudeHooks),
|
|
897
|
+
// and re-adding that cost here on three platforms at once was an explicit no per product decision.
|
|
898
|
+
// The message itself is generated per-install from the actual plugin set being installed, not one
|
|
899
|
+
// fixed string for everyone - e.g. someone who declined empirical-scar-tissue at install time
|
|
900
|
+
// never sees a scar-tissue reminder, since that capability isn't actually available to them.
|
|
901
|
+
//
|
|
902
|
+
// Claude and Codex only - Copilot has no UserPromptSubmit reminder at all (removed 2026-09-18,
|
|
903
|
+
// see installCopilotHooks's comment in this file for why: its only output field is a full prompt
|
|
904
|
+
// replacement, not a side channel, so any injected reminder here reads as fake, model-authored
|
|
905
|
+
// text inside what looks like the user's own message).
|
|
906
|
+
function buildPromptReminderMessage(pluginNames, platform) {
|
|
907
|
+
const names = normalizePluginNames(pluginNames);
|
|
908
|
+
|
|
909
|
+
const lines = [
|
|
910
|
+
'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.'
|
|
911
|
+
];
|
|
912
|
+
if (names.includes('empirical-work-history')) {
|
|
913
|
+
lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
|
|
914
|
+
if (platform === 'codex') {
|
|
915
|
+
lines.push('If this turn results in meaningful completed work, BEFORE sending your final response you MUST use the empirical-work-history skill or record it via Empirical. The entry must carry tag work-history and mass 1; verify the create receipt. CLI fallback must use --json-file with data.session and data.git; do not use the shorthand record command for coding work. Do not wait for the Stop hook.');
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (names.includes('empirical-scar-tissue')) {
|
|
919
|
+
lines.push('Check for scar tissue before repeating a past mistake in this area.');
|
|
920
|
+
}
|
|
921
|
+
if (names.includes('empirical-todo')) {
|
|
922
|
+
lines.push('If there is an open todo for this project, check it before starting new work.');
|
|
923
|
+
}
|
|
924
|
+
return '[Empirical reminder] ' + lines.join(' ');
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Builds the full UserPromptSubmit hook script source with the tailored message baked in at
|
|
928
|
+
// generation time (via JSON.stringify, so the message is embedded as a single already-escaped
|
|
929
|
+
// string literal - substituted through `${}`, not written as raw template-literal source text,
|
|
930
|
+
// which is what caused the earlier comment-escaping bug in this same file. Never do the latter).
|
|
931
|
+
// `platform`:
|
|
932
|
+
// - 'codex': Codex's required hookSpecificOutput.additionalContext JSON shape (confirmed live
|
|
933
|
+
// 2026-08-08 via a real `codex exec` run - plain text made Codex report the hook as failed).
|
|
934
|
+
// - default ('claude'): plain console.log.
|
|
935
|
+
// Copilot has no variant here at all (removed 2026-09-18) - see installCopilotHooks's comment.
|
|
936
|
+
function buildUserPromptSubmitHookSource(pluginNames, platform) {
|
|
937
|
+
const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames, platform));
|
|
938
|
+
|
|
939
|
+
const outputStatement = platform === 'codex'
|
|
940
|
+
? `console.log(JSON.stringify({
|
|
941
|
+
hookSpecificOutput: {
|
|
942
|
+
hookEventName: 'UserPromptSubmit',
|
|
943
|
+
additionalContext: ${messageLiteral}
|
|
944
|
+
}
|
|
945
|
+
}));`
|
|
946
|
+
: `console.log(${messageLiteral});`;
|
|
947
|
+
|
|
948
|
+
return `#!/usr/bin/env node
|
|
949
|
+
'use strict';
|
|
950
|
+
|
|
951
|
+
var fs = require('fs');
|
|
952
|
+
var os = require('os');
|
|
953
|
+
var path = require('path');
|
|
954
|
+
|
|
955
|
+
var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'user-prompt-submit.log');
|
|
956
|
+
var MAX_LOG_BYTES = 200 * 1024;
|
|
957
|
+
|
|
958
|
+
function appendLog(line) {
|
|
959
|
+
try {
|
|
960
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
961
|
+
fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
|
|
962
|
+
var stat = fs.statSync(LOG_PATH);
|
|
963
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
964
|
+
var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
|
|
965
|
+
fs.writeFileSync(LOG_PATH, tail);
|
|
966
|
+
}
|
|
967
|
+
} catch (e) {
|
|
968
|
+
// Diagnostics must never break the hook.
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// A crash here must degrade to a silent no-op, never a visible "hook failed" error in the host CLI.
|
|
973
|
+
try {
|
|
974
|
+
appendLog('user-prompt-submit hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
|
|
975
|
+
${outputStatement}
|
|
976
|
+
} catch (e) {
|
|
977
|
+
try { appendLog('uncaught error: ' + (e && e.message)); } catch (_e) { /* best effort */ }
|
|
978
|
+
}
|
|
979
|
+
process.exit(0);
|
|
980
|
+
`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function getEmpiricalHooksDir() {
|
|
984
|
+
return path.join(os.homedir(), '.empirical', 'hooks');
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function buildHookCommand(scriptPath) {
|
|
988
|
+
return `"${process.execPath}" "${scriptPath}"`;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Confirmed live (2026-08-08, via a real `copilot -p` run with debug logging): Copilot runs a
|
|
992
|
+
// hook's "command"/"powershell" field through powershell.exe on Windows, and a bare
|
|
993
|
+
// `"path" "arg"` string is not valid PowerShell syntax without the call operator (&) - two
|
|
994
|
+
// adjacent quoted string literals with nothing between them is a ParserError
|
|
995
|
+
// ("Unexpected token '...' in expression or statement"), not a "file not found"-style failure.
|
|
996
|
+
// This is a completely different bug from Codex's (which needed 8.3 short paths to avoid
|
|
997
|
+
// quoting) - no short-path workaround is needed here, just the operator PowerShell requires
|
|
998
|
+
// before an executable path.
|
|
999
|
+
function buildPowershellHookCommand(scriptPath) {
|
|
1000
|
+
return `& "${process.execPath}" "${scriptPath}"`;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function removeEmpiricalHookEntries(entries, scriptPath) {
|
|
1004
|
+
if (!Array.isArray(entries)) return [];
|
|
1005
|
+
return entries
|
|
1006
|
+
.map((entry) => {
|
|
1007
|
+
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
1008
|
+
const filteredHooks = entry.hooks.filter(
|
|
1009
|
+
(h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
|
|
1010
|
+
);
|
|
1011
|
+
return { ...entry, hooks: filteredHooks };
|
|
1012
|
+
})
|
|
1013
|
+
.filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function installClaudeHooks(pluginNames) {
|
|
1017
|
+
const hooksDir = getEmpiricalHooksDir();
|
|
1018
|
+
ensureDir(hooksDir);
|
|
1019
|
+
|
|
1020
|
+
// A prior version of this hook made an unconditional `empirical memory query` network call on
|
|
1021
|
+
// every single prompt and was removed for being wasteful (see git history). This is a
|
|
1022
|
+
// deliberate reintroduction at the SAME script path, but with fundamentally different content:
|
|
1023
|
+
// a static, install-time-generated reminder with no network call at all - the cost concern that
|
|
1024
|
+
// killed the old version does not apply to a plain stdout write.
|
|
1025
|
+
const userPromptScriptPath = path.join(hooksDir, 'claude-user-prompt-submit.js');
|
|
1026
|
+
const sessionStartScriptPath = path.join(hooksDir, 'claude-session-start.js');
|
|
1027
|
+
const stopScriptPath = path.join(hooksDir, 'claude-stop.js');
|
|
1028
|
+
writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
|
|
1029
|
+
writeFile(stopScriptPath, CLAUDE_HOOK_STOP_SOURCE);
|
|
1030
|
+
writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
|
|
1031
|
+
|
|
1032
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
1033
|
+
const settings = readJsonIfExists(settingsPath) || {};
|
|
1034
|
+
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
|
1035
|
+
settings.hooks = {};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
settings.hooks.UserPromptSubmit = removeEmpiricalHookEntries(settings.hooks.UserPromptSubmit, userPromptScriptPath);
|
|
1039
|
+
settings.hooks.UserPromptSubmit.push({
|
|
1040
|
+
hooks: [{ type: 'command', command: buildHookCommand(userPromptScriptPath), timeout: 10 }]
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
settings.hooks.SessionStart = removeEmpiricalHookEntries(settings.hooks.SessionStart, sessionStartScriptPath);
|
|
1044
|
+
for (const matcher of ['startup', 'resume', 'compact']) {
|
|
1045
|
+
settings.hooks.SessionStart.push({
|
|
1046
|
+
matcher,
|
|
1047
|
+
hooks: [{ type: 'command', command: buildHookCommand(sessionStartScriptPath), timeout: 25 }]
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
settings.hooks.Stop = removeEmpiricalHookEntries(settings.hooks.Stop, stopScriptPath);
|
|
1052
|
+
settings.hooks.Stop.push({
|
|
1053
|
+
hooks: [{ type: 'command', command: buildHookCommand(stopScriptPath), timeout: 10 }]
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1057
|
+
log(' - Claude SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Codex CLI ships a real hooks system (confirmed against openai/codex source — codex-rs/hooks,
|
|
1061
|
+
// codex-rs/config/src/hook_config.rs — not assumed from docs alone, since third-party writeups
|
|
1062
|
+
// disagreed on Windows support). It reads <CODEX_HOME>/hooks.json (JSON, not the config.toml
|
|
1063
|
+
// used for MCP/model settings — a separate file, so this never touches config.toml) shaped as
|
|
1064
|
+
// { hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command, commandWindows,
|
|
1065
|
+
// timeout }] }], ... } }. CODEX_HOME defaults to ~/.codex when unset, same convention as
|
|
1066
|
+
// ~/.claude for Claude Code.
|
|
1067
|
+
function getCodexHomeDir() {
|
|
1068
|
+
return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function removeEmpiricalHooksJsonEntries(entries, scriptPath) {
|
|
1072
|
+
if (!Array.isArray(entries)) return [];
|
|
1073
|
+
return entries
|
|
1074
|
+
.map((entry) => {
|
|
1075
|
+
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
1076
|
+
const filteredHooks = entry.hooks.filter(
|
|
1077
|
+
(h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
|
|
1078
|
+
);
|
|
1079
|
+
return { ...entry, hooks: filteredHooks };
|
|
1080
|
+
})
|
|
1081
|
+
.filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Codex's Windows sandbox (confirmed via `codex doctor` -> "filesystem sandbox: restricted" and
|
|
1085
|
+
// ~/.codex/.sandbox/sandbox.*.log's "processed N write roots (read roots delegated)" / "applying
|
|
1086
|
+
// read ACLs" at session start) scopes filesystem access per-session to project-specific roots
|
|
1087
|
+
// plus, necessarily, CODEX_HOME itself (Codex reads hooks.json, its state db, and its plugin
|
|
1088
|
+
// cache from there on every session regardless of project). The shared `~/.empirical/hooks`
|
|
1089
|
+
// directory used by Claude/Copilot is neither - so under this sandbox, node.exe most likely
|
|
1090
|
+
// cannot even read a hook script living there, which is why hooks fail identically (exit 1)
|
|
1091
|
+
// across SessionStart/UserPromptSubmit/Stop regardless of what each script actually does,
|
|
1092
|
+
// including the trivial UserPromptSubmit script that spawns no subprocess at all. Claude Code
|
|
1093
|
+
// hooks are unaffected (confirmed working from the shared dir) and Copilot has shown no such
|
|
1094
|
+
// failure, so only Codex's copies are relocated here - Claude/Copilot keep using
|
|
1095
|
+
// getEmpiricalHooksDir() unchanged.
|
|
1096
|
+
function getCodexHooksScriptsDir() {
|
|
1097
|
+
return path.join(getCodexHomeDir(), 'empirical-hooks');
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Confirmed live (2026-08-08, via a real `codex exec` run): Codex's Windows hook launcher fails
|
|
1101
|
+
// to parse a quoted executable path (e.g. `"C:\Program Files\nodejs\node.exe" "<script>"`,
|
|
1102
|
+
// otherwise a perfectly standard Windows command line - Claude Code and every direct shell/
|
|
1103
|
+
// spawnSync invocation handle it fine) and reports the hook as a nonzero-exit failure before any
|
|
1104
|
+
// of the script's own JS ever runs. Windows 8.3 short path names never contain spaces, so
|
|
1105
|
+
// resolving both the node executable and the script path to their short form and joining them
|
|
1106
|
+
// unquoted sidesteps the parser entirely. Computed once at install time (not per hook
|
|
1107
|
+
// invocation), so a synchronous shell call here is an acceptable cost.
|
|
1108
|
+
function getWindowsShortPath(longPath) {
|
|
1109
|
+
if (process.platform !== 'win32') return null;
|
|
1110
|
+
try {
|
|
1111
|
+
const { execSync } = require('child_process');
|
|
1112
|
+
const out = execSync(`cmd /c for %I in ("${longPath}") do @echo %~sI`, { encoding: 'utf8', timeout: 5000 });
|
|
1113
|
+
const short = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).pop();
|
|
1114
|
+
const fs = require('fs');
|
|
1115
|
+
return short && fs.existsSync(short) ? short : null;
|
|
1116
|
+
} catch (e) {
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Builds Codex's commandWindows value using short paths so neither the executable nor the script
|
|
1122
|
+
// path ever needs quoting. Falls back to the normal quoted buildHookCommand() form if short-path
|
|
1123
|
+
// resolution fails for any reason (e.g. 8.3 name generation disabled on that volume) - degraded
|
|
1124
|
+
// (same behavior as before this fix) rather than broken.
|
|
1125
|
+
function buildCodexWindowsHookCommand(scriptPath) {
|
|
1126
|
+
const shortNode = getWindowsShortPath(process.execPath);
|
|
1127
|
+
const shortScript = getWindowsShortPath(scriptPath);
|
|
1128
|
+
if (shortNode && shortScript) {
|
|
1129
|
+
return `${shortNode} ${shortScript}`;
|
|
1130
|
+
}
|
|
1131
|
+
return buildHookCommand(scriptPath);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function installCodexHooks(pluginNames) {
|
|
1135
|
+
const fs = require('fs');
|
|
1136
|
+
const legacyHooksDir = getEmpiricalHooksDir();
|
|
1137
|
+
const hooksDir = getCodexHooksScriptsDir();
|
|
1138
|
+
ensureDir(hooksDir);
|
|
1139
|
+
|
|
1140
|
+
const sessionStartScriptPath = path.join(hooksDir, 'codex-session-start.js');
|
|
1141
|
+
const stopScriptPath = path.join(hooksDir, 'codex-stop.js');
|
|
1142
|
+
const userPromptScriptPath = path.join(hooksDir, 'codex-user-prompt-submit.js');
|
|
1143
|
+
writeFile(sessionStartScriptPath, CODEX_HOOK_SESSION_START_SOURCE);
|
|
1144
|
+
writeFile(stopScriptPath, CODEX_HOOK_STOP_SOURCE);
|
|
1145
|
+
writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames, 'codex'));
|
|
1146
|
+
|
|
1147
|
+
// Best-effort cleanup of the pre-relocation script files/entries at the old shared path, so a
|
|
1148
|
+
// machine that already had Codex hooks installed doesn't end up with both the old (broken
|
|
1149
|
+
// under the sandbox) and new registration firing redundantly.
|
|
1150
|
+
const legacySessionStartScriptPath = path.join(legacyHooksDir, 'codex-session-start.js');
|
|
1151
|
+
const legacyStopScriptPath = path.join(legacyHooksDir, 'codex-stop.js');
|
|
1152
|
+
const legacyUserPromptScriptPath = path.join(legacyHooksDir, 'codex-user-prompt-submit.js');
|
|
1153
|
+
for (const legacyPath of [legacySessionStartScriptPath, legacyStopScriptPath, legacyUserPromptScriptPath]) {
|
|
1154
|
+
try {
|
|
1155
|
+
if (fs.existsSync(legacyPath)) fs.unlinkSync(legacyPath);
|
|
1156
|
+
} catch (_error) {
|
|
1157
|
+
// best-effort cleanup only
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const hooksJsonPath = path.join(getCodexHomeDir(), 'hooks.json');
|
|
1162
|
+
const hooksFile = readJsonIfExists(hooksJsonPath) || {};
|
|
1163
|
+
if (!hooksFile.hooks || typeof hooksFile.hooks !== 'object') {
|
|
1164
|
+
hooksFile.hooks = {};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
hooksFile.hooks.SessionStart = removeEmpiricalHooksJsonEntries(
|
|
1168
|
+
removeEmpiricalHooksJsonEntries(hooksFile.hooks.SessionStart, sessionStartScriptPath),
|
|
1169
|
+
legacySessionStartScriptPath
|
|
1170
|
+
);
|
|
1171
|
+
const sessionStartCommand = buildHookCommand(sessionStartScriptPath);
|
|
1172
|
+
const sessionStartCommandWindows = buildCodexWindowsHookCommand(sessionStartScriptPath);
|
|
1173
|
+
for (const matcher of ['startup', 'resume']) {
|
|
1174
|
+
hooksFile.hooks.SessionStart.push({
|
|
1175
|
+
matcher,
|
|
1176
|
+
hooks: [{ type: 'command', command: sessionStartCommand, commandWindows: sessionStartCommandWindows, timeout: 25 }]
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
hooksFile.hooks.Stop = removeEmpiricalHooksJsonEntries(
|
|
1181
|
+
removeEmpiricalHooksJsonEntries(hooksFile.hooks.Stop, stopScriptPath),
|
|
1182
|
+
legacyStopScriptPath
|
|
1183
|
+
);
|
|
1184
|
+
const stopCommand = buildHookCommand(stopScriptPath);
|
|
1185
|
+
const stopCommandWindows = buildCodexWindowsHookCommand(stopScriptPath);
|
|
1186
|
+
hooksFile.hooks.Stop.push({
|
|
1187
|
+
hooks: [{ type: 'command', command: stopCommand, commandWindows: stopCommandWindows, timeout: 10 }]
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
// Confirmed against codex-rs/config/src/hook_config.rs's HookEventsToml serde renames: the
|
|
1191
|
+
// config-file key is PascalCase "UserPromptSubmit", same casing convention as SessionStart/Stop.
|
|
1192
|
+
hooksFile.hooks.UserPromptSubmit = removeEmpiricalHooksJsonEntries(
|
|
1193
|
+
removeEmpiricalHooksJsonEntries(hooksFile.hooks.UserPromptSubmit, userPromptScriptPath),
|
|
1194
|
+
legacyUserPromptScriptPath
|
|
1195
|
+
);
|
|
1196
|
+
const userPromptCommand = buildHookCommand(userPromptScriptPath);
|
|
1197
|
+
const userPromptCommandWindows = buildCodexWindowsHookCommand(userPromptScriptPath);
|
|
1198
|
+
hooksFile.hooks.UserPromptSubmit.push({
|
|
1199
|
+
hooks: [{ type: 'command', command: userPromptCommand, commandWindows: userPromptCommandWindows, timeout: 10 }]
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
writeFile(hooksJsonPath, `${JSON.stringify(hooksFile, null, 2)}\n`);
|
|
1203
|
+
log(' - Codex SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// GitHub Copilot CLI's hook config is one JSON file per hook under ~/.copilot/hooks/ (any
|
|
1207
|
+
// filename — confirmed via GitHub's own docs at docs.github.com/en/copilot/reference/hooks-
|
|
1208
|
+
// reference), so unlike Claude/Codex this never needs to merge into a shared file: Empirical
|
|
1209
|
+
// owns this file outright and can just overwrite it.
|
|
1210
|
+
//
|
|
1211
|
+
// SessionStart only (2026-09-18): userPromptSubmitted and agentStop reminder hooks were removed
|
|
1212
|
+
// for Copilot entirely, not just capped. Both were fundamentally fake compared to Claude/Codex's
|
|
1213
|
+
// versions: userPromptSubmitted's only output field is modifiedPrompt (a full prompt replacement,
|
|
1214
|
+
// not a side-channel), so its "reminder" was really text injected into what looks like the user's
|
|
1215
|
+
// own message; agentStop only supports decision:"block"|"allow" with no passive/silent field, so
|
|
1216
|
+
// any reminder there forces a disruptive extra visible turn, and no per-session cap on that can
|
|
1217
|
+
// avoid the tradeoff between nagging every turn and going silent right before something worth
|
|
1218
|
+
// recording happens (confirmed live 2026-09-17/18: both caused real, reproducible bugs - a
|
|
1219
|
+
// prompt-blanking regression and an unstoppable "record your work" loop). Copilot instead relies
|
|
1220
|
+
// entirely on the identity-framed instructions block coreMemoryBlock.js writes into
|
|
1221
|
+
// copilot-instructions.md, same mechanism Claude/Codex have as a backstop under their working
|
|
1222
|
+
// hooks - see that file's Copilot-only addendum for the stronger, more directive wording this
|
|
1223
|
+
// puts on Copilot specifically to compensate for having no hook nudge at all.
|
|
1224
|
+
function installCopilotHooks(_pluginNames) {
|
|
1225
|
+
const hooksDir = getEmpiricalHooksDir();
|
|
1226
|
+
ensureDir(hooksDir);
|
|
1227
|
+
|
|
1228
|
+
const sessionStartScriptPath = path.join(hooksDir, 'copilot-session-start.js');
|
|
1229
|
+
writeFile(sessionStartScriptPath, COPILOT_HOOK_SESSION_START_SOURCE);
|
|
1230
|
+
|
|
1231
|
+
// Clean up scripts/state files from every removed Copilot hook (agentStop's Stop reminder, the
|
|
1232
|
+
// visible-modifiedPrompt UserPromptSubmit real-recall hook it was briefly replaced with) so an
|
|
1233
|
+
// upgrade never leaves orphaned, unregistered files behind.
|
|
1234
|
+
const fs = require('fs');
|
|
1235
|
+
const staleFiles = [
|
|
1236
|
+
path.join(hooksDir, 'copilot-stop.js'),
|
|
1237
|
+
path.join(hooksDir, 'stop-state.json'),
|
|
1238
|
+
path.join(hooksDir, 'copilot-user-prompt-submit.js'),
|
|
1239
|
+
path.join(hooksDir, 'prompt-state.json')
|
|
1240
|
+
];
|
|
1241
|
+
for (const stalePath of staleFiles) {
|
|
1242
|
+
try {
|
|
1243
|
+
if (fs.existsSync(stalePath)) fs.unlinkSync(stalePath);
|
|
1244
|
+
} catch (_error) {
|
|
1245
|
+
// best-effort cleanup only
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const copilotHooksDir = path.join(os.homedir(), '.copilot', 'hooks');
|
|
1250
|
+
ensureDir(copilotHooksDir);
|
|
1251
|
+
|
|
1252
|
+
// Prior versions of this installer wrote a single-purpose "empirical-session-start.json" —
|
|
1253
|
+
// remove it now that both events live in one unified file, so a stale duplicate registration
|
|
1254
|
+
// never lingers alongside the new one.
|
|
1255
|
+
const legacyHookFilePath = path.join(copilotHooksDir, 'empirical-session-start.json');
|
|
1256
|
+
try {
|
|
1257
|
+
if (fs.existsSync(legacyHookFilePath)) fs.unlinkSync(legacyHookFilePath);
|
|
1258
|
+
} catch (_error) {
|
|
1259
|
+
// best-effort cleanup only
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const hookFilePath = path.join(copilotHooksDir, 'empirical-hooks.json');
|
|
1263
|
+
// Both `command` (POSIX fallback) and `powershell` (Windows-explicit, with the & call operator
|
|
1264
|
+
// PowerShell requires - see buildPowershellHookCommand) are always set: per GitHub's hooks
|
|
1265
|
+
// reference, an explicit `powershell` entry takes precedence over `command` on Windows, so this
|
|
1266
|
+
// is correct on both platforms without needing to detect the install-time OS here.
|
|
1267
|
+
//
|
|
1268
|
+
// sessionStart only (2026-09-18): no agentStop (recording stays instructions-only, see
|
|
1269
|
+
// coreMemoryBlock.js's Copilot addendum) and no userPromptSubmitted (its only output field,
|
|
1270
|
+
// modifiedPrompt, is rendered back as the user's own visible message - confirmed live, not
|
|
1271
|
+
// acceptable for silent recall). sessionStart's additionalContext delivers the same real query
|
|
1272
|
+
// invisibly instead, once per real session boundary (not an artificial timer), using that
|
|
1273
|
+
// event's own initialPrompt field as the query when present.
|
|
1274
|
+
const hookFileContent = {
|
|
1275
|
+
version: 1,
|
|
1276
|
+
hooks: {
|
|
1277
|
+
sessionStart: [
|
|
1278
|
+
{
|
|
1279
|
+
type: 'command',
|
|
1280
|
+
command: buildHookCommand(sessionStartScriptPath),
|
|
1281
|
+
powershell: buildPowershellHookCommand(sessionStartScriptPath),
|
|
1282
|
+
timeoutSec: 15
|
|
1283
|
+
}
|
|
1284
|
+
]
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
writeFile(hookFilePath, `${JSON.stringify(hookFileContent, null, 2)}\n`);
|
|
1289
|
+
log(' - Copilot SessionStart hook installed (real recall via additionalContext). No UserPromptSubmit/agentStop reminders - see copilot-instructions.md instead.');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
module.exports = {
|
|
1293
|
+
CLAUDE_HOOK_SESSION_START_SOURCE,
|
|
1294
|
+
CODEX_HOOK_SESSION_START_SOURCE,
|
|
1295
|
+
COPILOT_HOOK_SESSION_START_SOURCE,
|
|
1296
|
+
CODEX_HOOK_STOP_SOURCE,
|
|
1297
|
+
CLAUDE_HOOK_STOP_SOURCE,
|
|
1298
|
+
buildPromptReminderMessage,
|
|
1299
|
+
buildUserPromptSubmitHookSource,
|
|
1300
|
+
getEmpiricalHooksDir,
|
|
1301
|
+
buildHookCommand,
|
|
1302
|
+
buildPowershellHookCommand,
|
|
1303
|
+
removeEmpiricalHookEntries,
|
|
1304
|
+
installClaudeHooks,
|
|
1305
|
+
getCodexHomeDir,
|
|
1306
|
+
removeEmpiricalHooksJsonEntries,
|
|
1307
|
+
getCodexHooksScriptsDir,
|
|
1308
|
+
getWindowsShortPath,
|
|
1309
|
+
buildCodexWindowsHookCommand,
|
|
1310
|
+
installCodexHooks,
|
|
1311
|
+
installCopilotHooks
|
|
1312
|
+
};
|