@empirical-gauzza/cli 1.0.9 → 1.0.11

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 CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  'use strict';
4
4
 
package/lib/constants.js CHANGED
@@ -121,9 +121,10 @@ Runs the interactive installer to connect Empirical memory to your AI coding CLI
121
121
  instruction files are left unchanged
122
122
  5. Offers optional browser OAuth login for CLI use, optional headless/API-key bootstrap for
123
123
  automation, containers, and external runtimes, and optional work-history/scar-tissue guidance
124
- 6. For Claude, installs a \`SessionStart\` hook in \`~/.claude/settings.json\` that calls the
125
- \`empirical\` CLI directly once per session — deterministic baseline context that doesn't
126
- depend on the model deciding to call a tool. Codex/Copilot have no equivalent hook mechanism.
124
+ 6. Installs a \`SessionStart\` hook for each selected client that calls the \`empirical\` CLI
125
+ directly once per session — deterministic baseline context that doesn't depend on the model
126
+ deciding to call a tool. Claude: \`~/.claude/settings.json\`. Codex: \`<CODEX_HOME>/hooks.json\`
127
+ (defaults to \`~/.codex\`). Copilot: \`~/.copilot/hooks/empirical-session-start.json\`.
127
128
  7. Installs the native \`empirical-memory\` plugin for each selected client. The plugin owns
128
129
  the Empirical MCP connection and all agent-facing Skills; the CLI does not generate duplicate
129
130
  \`SKILL.md\` or \`EMPIRICAL_SKILL.md\` files.
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
- const { log, warn, uiHeader, uiQuestion, uiSuccess, uiWarn, uiDim, uiLink, ask, askYesNo, parseSelection, formatHeadlessErrorHint } = require('./ui');
4
- const { getEnv } = require('./env');
3
+ const { log, warn, uiHeader, uiQuestion, uiSuccess, uiWarn, uiDim, uiLink, ask, askYesNo, parseSelection, formatHeadlessErrorHint } = require('./ui');
4
+ const { getEnv } = require('./env');
5
5
  const { summarizeProcessOutput } = require('./fsUtils');
6
6
  const {
7
7
  EMPIRICAL_MCP_NAME, EMPIRICAL_MCP_URL,
8
- getCliVersion, deriveBaseUrlFromMcpUrl
8
+ getCliVersion, deriveBaseUrlFromMcpUrl
9
9
  } = require('./constants');
10
10
  const {
11
11
  readStoredOAuthClient, writeStoredOAuthClient, getLocalOAuthClientPath,
@@ -16,22 +16,22 @@ const {
16
16
  runMcpOAuthFlow, updateClaudeMcpServerAuth, resolveAccessToken
17
17
  } = require('./auth');
18
18
  const { performSync, printSyncSummary } = require('./skillSync');
19
- const {
20
- INSTALL_PROMPT_OPTIONS_DEFAULTS, collectInstallerPromptOptions,
21
- } = require('./installers/promptBlocks');
19
+ const {
20
+ INSTALL_PROMPT_OPTIONS_DEFAULTS, collectInstallerPromptOptions,
21
+ } = require('./installers/promptBlocks');
22
22
  const { detectInstallTargets } = require('./installers/detect');
23
- const { installClaudeHooks } = require('./installers/hooks');
24
- const { runCodex } = require('./installers/codex');
25
- const { installNativePlugin, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
26
- const { policyFromInstallerOptions, savePolicy: persistPolicy } = require('./policyCommands');
27
-
28
- function pluginNamesForInstallerOptions(options = {}) {
29
- const names = ['empirical-memory'];
30
- if (options.recordWorkHistory !== false) names.push('empirical-work-history');
31
- if (options.recordScarTissue !== false) names.push('empirical-scar-tissue');
32
- if (options.recordTodos === true) names.push('empirical-todo');
33
- return names.filter((name) => name === 'empirical-memory' || OPTIONAL_PLUGIN_NAMES.includes(name));
34
- }
23
+ const { installClaudeHooks, installCodexHooks, installCopilotHooks } = require('./installers/hooks');
24
+ const { runCodex } = require('./installers/codex');
25
+ const { installNativePlugin, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
26
+ const { policyFromInstallerOptions, savePolicy: persistPolicy } = require('./policyCommands');
27
+
28
+ function pluginNamesForInstallerOptions(options = {}) {
29
+ const names = ['empirical-memory'];
30
+ if (options.recordWorkHistory !== false) names.push('empirical-work-history');
31
+ if (options.recordScarTissue !== false) names.push('empirical-scar-tissue');
32
+ if (options.recordTodos === true) names.push('empirical-todo');
33
+ return names.filter((name) => name === 'empirical-memory' || OPTIONAL_PLUGIN_NAMES.includes(name));
34
+ }
35
35
 
36
36
  async function runInstallFlow() {
37
37
  log(uiHeader(`Empirical installer v${getCliVersion()}`));
@@ -74,17 +74,17 @@ async function runInstallFlow() {
74
74
  log('');
75
75
  }
76
76
 
77
- let promptOptions;
78
- let savePolicyEnabled;
77
+ let promptOptions;
78
+ let savePolicyEnabled;
79
79
 
80
80
  if (integrationMode === 2) {
81
- // CLI-direct mode: skip MCP-specific installer questions
82
- promptOptions = { ...INSTALL_PROMPT_OPTIONS_DEFAULTS };
83
- savePolicyEnabled = true;
84
- } else {
85
- const promptSetup = await collectInstallerPromptOptions();
86
- promptOptions = promptSetup.promptOptions;
87
- savePolicyEnabled = promptSetup.savePolicy !== false;
81
+ // CLI-direct mode: skip MCP-specific installer questions
82
+ promptOptions = { ...INSTALL_PROMPT_OPTIONS_DEFAULTS };
83
+ savePolicyEnabled = true;
84
+ } else {
85
+ const promptSetup = await collectInstallerPromptOptions();
86
+ promptOptions = promptSetup.promptOptions;
87
+ savePolicyEnabled = promptSetup.savePolicy !== false;
88
88
  }
89
89
 
90
90
  log(uiHeader('Installer auth setup'));
@@ -95,8 +95,8 @@ async function runInstallFlow() {
95
95
  }
96
96
  const useHeadlessDefault = getEnv('EMPIRICAL_HEADLESS_DEFAULT', 'true').toLowerCase() !== 'false';
97
97
  const settingsUrl = `${installerBaseUrl.replace(/\/$/, '')}/app/settings`;
98
- let headlessToken = null;
99
- let policyAccessToken = null;
98
+ let headlessToken = null;
99
+ let policyAccessToken = null;
100
100
  try {
101
101
  const existingClient = readStoredOAuthClient();
102
102
  if (getEnv('EMPIRICAL_CLIENT_ID')) {
@@ -138,12 +138,12 @@ async function runInstallFlow() {
138
138
  }
139
139
  log('');
140
140
 
141
- let shouldBootstrapHeadless = useHeadlessDefault;
142
- if (process.stdin.isTTY) {
143
- log('Optional credential setup:');
144
- log(' Browser OAuth is the normal choice for interactive CLI use.');
145
- log(' Headless bootstrap creates reusable credentials for CLI use, automation, containers, and external runtimes.');
146
- log(' Headless bootstrap uses Settings client credentials (client ID + client secret).');
141
+ let shouldBootstrapHeadless = useHeadlessDefault;
142
+ if (process.stdin.isTTY) {
143
+ log('Optional credential setup:');
144
+ log(' Browser OAuth is the normal choice for interactive CLI use.');
145
+ log(' Headless bootstrap creates reusable credentials for CLI use, automation, containers, and external runtimes.');
146
+ log(' Headless bootstrap uses Settings client credentials (client ID + client secret).');
147
147
  log(' Client credentials are loaded from env/saved oauth-client.json, with prompt fallback.');
148
148
  log(` Settings: ${uiLink(settingsUrl)}`);
149
149
  shouldBootstrapHeadless = await askYesNo('Bootstrap headless token now?', useHeadlessDefault);
@@ -192,31 +192,31 @@ async function runInstallFlow() {
192
192
  }
193
193
  }
194
194
  }
195
- } else {
196
- log('Headless auth: skipped by user choice. Interactive OAuth will be used when required.');
197
- log('');
198
- }
199
-
200
- if ((integrationMode === 2 || integrationMode === 3) && !resolveAccessToken()) {
201
- try {
202
- log('CLI auth: no reusable credential found. Opening browser OAuth login...');
203
- await runMcpOAuthFlow(installerBaseUrl);
204
- log(uiSuccess('CLI OAuth session saved. Empirical CLI commands are ready to use.'));
205
- log('');
206
- } catch (error) {
207
- warn(uiWarn(`CLI OAuth login failed: ${error.message}`));
208
- warn(' CLI commands will need a credential later: run "empirical auth login".');
209
- log('');
210
- }
211
- }
212
-
213
- if (integrationMode !== 2) {
214
- const preflight = await oauthPreflight(EMPIRICAL_MCP_URL);
215
- if (!preflight.ok) {
216
- warn(`OAuth preflight warning: ${preflight.message}`);
217
- warn('Codex/Claude setup may fail until the hosted OAuth login page is updated.');
218
- log('');
219
- }
195
+ } else {
196
+ log('Headless auth: skipped by user choice. Interactive OAuth will be used when required.');
197
+ log('');
198
+ }
199
+
200
+ if ((integrationMode === 2 || integrationMode === 3) && !resolveAccessToken()) {
201
+ try {
202
+ log('CLI auth: no reusable credential found. Opening browser OAuth login...');
203
+ await runMcpOAuthFlow(installerBaseUrl);
204
+ log(uiSuccess('CLI OAuth session saved. Empirical CLI commands are ready to use.'));
205
+ log('');
206
+ } catch (error) {
207
+ warn(uiWarn(`CLI OAuth login failed: ${error.message}`));
208
+ warn(' CLI commands will need a credential later: run "empirical auth login".');
209
+ log('');
210
+ }
211
+ }
212
+
213
+ if (integrationMode !== 2) {
214
+ const preflight = await oauthPreflight(EMPIRICAL_MCP_URL);
215
+ if (!preflight.ok) {
216
+ warn(`OAuth preflight warning: ${preflight.message}`);
217
+ warn('Codex/Claude setup may fail until the hosted OAuth login page is updated.');
218
+ log('');
219
+ }
220
220
  }
221
221
 
222
222
  let failures = 0;
@@ -228,26 +228,40 @@ async function runInstallFlow() {
228
228
  const target = targets[index];
229
229
  log(uiHeader(`Step 2/2: Installing for ${target.label}...`));
230
230
  try {
231
- if (integrationMode === 2) {
232
- // CLI-direct: skip MCP registration; the native plugin owns guidance.
233
- const pluginNames = pluginNamesForInstallerOptions(promptOptions);
234
- installNativePlugin(target.id, pluginNames);
235
- log(` - ${target.label} native plugins installed: ${pluginNames.join(', ')}.`);
236
- if (target.id === 'claude') {
237
- try {
238
- installClaudeHooks();
231
+ if (integrationMode === 2) {
232
+ // CLI-direct: skip MCP registration; the native plugin owns guidance.
233
+ const pluginNames = pluginNamesForInstallerOptions(promptOptions);
234
+ installNativePlugin(target.id, pluginNames);
235
+ log(` - ${target.label} native plugins installed: ${pluginNames.join(', ')}.`);
236
+ if (target.id === 'claude') {
237
+ try {
238
+ installClaudeHooks(pluginNames);
239
239
  } catch (error) {
240
240
  warn(` - Could not install Claude hooks for deterministic memory lookups: ${error.message}`);
241
241
  }
242
- }
243
- log(uiSuccess(`Done: ${target.label} (CLI-direct mode)`));
242
+ }
243
+ if (target.id === 'codex') {
244
+ try {
245
+ installCodexHooks(pluginNames);
246
+ } catch (error) {
247
+ warn(` - Could not install Codex hooks for deterministic memory lookups: ${error.message}`);
248
+ }
249
+ }
250
+ if (target.id === 'copilot') {
251
+ try {
252
+ installCopilotHooks(pluginNames);
253
+ } catch (error) {
254
+ warn(` - Could not install Copilot hooks for deterministic memory lookups: ${error.message}`);
255
+ }
256
+ }
257
+ log(uiSuccess(`Done: ${target.label} (CLI-direct mode)`));
244
258
  } else {
245
259
  // Mode 1 or 3: run MCP install normally
246
- const pluginNames = pluginNamesForInstallerOptions(promptOptions);
247
- const installResult = target.install(headlessToken, promptOptions, {
248
- writeGlobalInstructions: false,
249
- pluginNames
250
- });
260
+ const pluginNames = pluginNamesForInstallerOptions(promptOptions);
261
+ const installResult = target.install(headlessToken, promptOptions, {
262
+ writeGlobalInstructions: false,
263
+ pluginNames
264
+ });
251
265
  log(uiSuccess(`Done: ${target.label}`));
252
266
  if (target.id === 'codex') {
253
267
  codexInstalled = true;
@@ -256,7 +270,7 @@ async function runInstallFlow() {
256
270
  if (target.id === 'claude') {
257
271
  claudeInstalled = true;
258
272
  }
259
- }
273
+ }
260
274
  } catch (error) {
261
275
  failures += 1;
262
276
  warn(uiWarn(`Failed: ${target.label} - ${error.message}`));
@@ -282,11 +296,11 @@ async function runInstallFlow() {
282
296
  } else {
283
297
  const detail = summarizeProcessOutput(loginResult);
284
298
  warn(uiWarn(`Codex MCP authentication returned a non-zero status${detail ? `: ${detail}` : '.'}`));
285
- warn('Re-run `empirical install` to retry automatic authentication.');
299
+ warn('Re-run `empirical install` to retry automatic authentication.');
286
300
  }
287
301
  } catch (error) {
288
302
  warn(uiWarn(`Codex MCP authentication failed: ${error.message}`));
289
- warn('Re-run `empirical install` to retry automatic authentication.');
303
+ warn('Re-run `empirical install` to retry automatic authentication.');
290
304
  }
291
305
  log('');
292
306
  }
@@ -294,7 +308,7 @@ async function runInstallFlow() {
294
308
  // Offer to authenticate Empirical MCP after Claude installation.
295
309
  // Prefer the permanent API key from the headless bootstrap (never expires)
296
310
  // over a short-lived JWT from the browser OAuth flow.
297
- if (claudeInstalled && process.stdin.isTTY) {
311
+ if (claudeInstalled && process.stdin.isTTY) {
298
312
  log(uiHeader('Empirical MCP authentication'));
299
313
  const permanentToken = headlessToken?.apiKey || '';
300
314
  if (permanentToken) {
@@ -310,45 +324,45 @@ async function runInstallFlow() {
310
324
  try {
311
325
  log('');
312
326
  log(uiQuestion('Authenticating Empirical MCP...'));
313
- const mcpToken = await runMcpOAuthFlow(installerBaseUrl);
314
- policyAccessToken = mcpToken.accessToken;
315
- updateClaudeMcpServerAuth(EMPIRICAL_MCP_NAME, mcpToken.accessToken);
327
+ const mcpToken = await runMcpOAuthFlow(installerBaseUrl);
328
+ policyAccessToken = mcpToken.accessToken;
329
+ updateClaudeMcpServerAuth(EMPIRICAL_MCP_NAME, mcpToken.accessToken);
316
330
  log(uiSuccess('Empirical MCP authenticated successfully in Claude!'));
317
- log(' Note: this OAuth token expires in ~30 days. Re-run the installer with');
318
- log(' your Settings client credentials to get a permanent credential instead.');
331
+ log(' Note: this OAuth token expires in ~30 days. Re-run the installer with');
332
+ log(' your Settings client credentials to get a permanent credential instead.');
319
333
  } catch (error) {
320
334
  warn(uiWarn(`MCP authentication failed: ${error.message}`));
321
335
  warn('You can authenticate later from Claude Code by using the /mcp command.');
322
336
  }
323
337
  }
324
- log('');
325
- }
326
-
327
- // Persist the selected behavior after the installer has had a chance to complete OAuth/MCP
328
- // authentication. In MCP-only mode, authentication happens after native installation.
329
- try {
330
- const accessToken = resolveAccessToken() || policyAccessToken;
331
- if (accessToken && savePolicyEnabled) {
332
- await persistPolicy(policyFromInstallerOptions(promptOptions), accessToken);
333
- log(uiSuccess('Empirical behavior policy saved for native skills.'));
334
- } else if (accessToken) {
335
- log('Empirical behavior policy not saved by user choice; native skills will use safe defaults.');
336
- } else {
337
- warn('Empirical behavior policy was not saved: no authenticated CLI credential is available yet.');
338
- }
339
- log('');
340
- } catch (error) {
341
- warn(uiWarn(`Empirical behavior policy could not be saved: ${error.message}`));
342
- log('');
343
- }
338
+ log('');
339
+ }
340
+
341
+ // Persist the selected behavior after the installer has had a chance to complete OAuth/MCP
342
+ // authentication. In MCP-only mode, authentication happens after native installation.
343
+ try {
344
+ const accessToken = resolveAccessToken() || policyAccessToken;
345
+ if (accessToken && savePolicyEnabled) {
346
+ await persistPolicy(policyFromInstallerOptions(promptOptions), accessToken);
347
+ log(uiSuccess('Empirical behavior policy saved for native skills.'));
348
+ } else if (accessToken) {
349
+ log('Empirical behavior policy not saved by user choice; native skills will use safe defaults.');
350
+ } else {
351
+ warn('Empirical behavior policy was not saved: no authenticated CLI credential is available yet.');
352
+ }
353
+ log('');
354
+ } catch (error) {
355
+ warn(uiWarn(`Empirical behavior policy could not be saved: ${error.message}`));
356
+ log('');
357
+ }
344
358
 
345
359
  // US-011: independent of whether any Kit exists or `kit use` has ever been run, also sync
346
360
  // the active device's resolved (builtin ∪ Default Kit ∪ active named Kit) Skill set into
347
361
  // every detected agent's skill directory now, so a brand-new device gets the Default Kit's
348
362
  // authored Skills (if any exist yet — likely none for a fresh account, which is fine; the
349
363
  // sync just writes zero non-builtin Skills) immediately. This does NOT touch the
350
- // Native plugin installation above owns the empirical-memory Skill. The generalized Kit
351
- // writer below must continue to exclude the builtin placeholder with the same name.
364
+ // Native plugin installation above owns the empirical-memory Skill. The generalized Kit
365
+ // writer below must continue to exclude the builtin placeholder with the same name.
352
366
  try {
353
367
  const syncAccessToken = resolveAccessToken();
354
368
  if (syncAccessToken) {
@@ -370,13 +384,13 @@ async function runInstallFlow() {
370
384
  log('');
371
385
  }
372
386
 
373
- const completionMessage = integrationMode === 2
374
- ? 'Install complete. Next time you open the selected CLI(s), Empirical CLI commands should be available.'
375
- : integrationMode === 3
376
- ? 'Install complete. Next time you open the selected CLI(s), Empirical MCP and CLI commands should be available.'
377
- : 'Install complete. Next time you open the selected CLI(s), Empirical MCP should be available.';
378
- log(uiSuccess(completionMessage));
379
- }
387
+ const completionMessage = integrationMode === 2
388
+ ? 'Install complete. Next time you open the selected CLI(s), Empirical CLI commands should be available.'
389
+ : integrationMode === 3
390
+ ? 'Install complete. Next time you open the selected CLI(s), Empirical MCP and CLI commands should be available.'
391
+ : 'Install complete. Next time you open the selected CLI(s), Empirical MCP should be available.';
392
+ log(uiSuccess(completionMessage));
393
+ }
380
394
 
381
395
  module.exports = {
382
396
  runInstallFlow
@@ -22,15 +22,19 @@ function isClaudeServerConfigured(name) {
22
22
  function installClaude(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
23
23
  log(' - Claude global guidance file unchanged; native plugin guidance is authoritative.');
24
24
 
25
+ // installNativePlugin throws if the plugin install/update genuinely fails (see
26
+ // assertPluginInstallSucceeded in nativePlugin.js) - hooks are only installed AFTER this
27
+ // succeeds, never before, so a failed/skipped plugin install never leaves an orphaned hook
28
+ // pointing at a plugin that isn't actually present.
29
+ installNativePlugin('claude', installOptions.pluginNames);
30
+ log(` - Claude native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
31
+
25
32
  try {
26
- installClaudeHooks();
33
+ installClaudeHooks(installOptions.pluginNames);
27
34
  } catch (error) {
28
35
  warn(` - Could not install Claude hooks for deterministic memory lookups: ${error.message}`);
29
36
  }
30
37
 
31
- installNativePlugin('claude', installOptions.pluginNames);
32
- log(` - Claude native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
33
-
34
38
  if (!commandExists('claude')) {
35
39
  warn(' - Claude CLI not found on PATH. Skipped Claude MCP registration.');
36
40
  return;
@@ -7,6 +7,7 @@ const {
7
7
  EMPIRICAL_MCP_NAME, EMPIRICAL_MCP_LEGACY_NAMES, EMPIRICAL_MCP_URL
8
8
  } = require('../constants');
9
9
  const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
10
+ const { installCodexHooks } = require('./hooks');
10
11
  const { installNativePlugin } = require('./nativePlugin');
11
12
 
12
13
  function runCodex(args, options = {}) {
@@ -21,9 +22,18 @@ function isCodexServerConfigured(name) {
21
22
  function installCodex(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
22
23
  log(' - Codex global AGENTS instructions unchanged; native plugin guidance is authoritative.');
23
24
 
25
+ // installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
26
+ // nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
27
+ // plugin install never leaves an orphaned hook pointing at a plugin that isn't present.
24
28
  installNativePlugin('codex', installOptions.pluginNames);
25
29
  log(` - Codex native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
26
30
 
31
+ try {
32
+ installCodexHooks(installOptions.pluginNames);
33
+ } catch (error) {
34
+ warn(` - Could not install Codex hooks for deterministic memory lookups: ${error.message}`);
35
+ }
36
+
27
37
  if (!commandExists('codex')) {
28
38
  warn(' - Codex CLI not found on PATH. Skipped Codex MCP registration.');
29
39
  return;
@@ -10,6 +10,7 @@ const {
10
10
  EMPIRICAL_MCP_NAME, EMPIRICAL_MCP_LEGACY_NAMES, EMPIRICAL_MCP_URL, COPILOT_OAUTH_CLIENT_ID
11
11
  } = require('../constants');
12
12
  const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
13
+ const { installCopilotHooks } = require('./hooks');
13
14
  const { installNativePlugin } = require('./nativePlugin');
14
15
 
15
16
  function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
@@ -62,9 +63,18 @@ function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DE
62
63
 
63
64
  log(' - Copilot global instructions unchanged; native plugin guidance is authoritative.');
64
65
 
66
+ // installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
67
+ // nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
68
+ // plugin install never leaves an orphaned hook pointing at a plugin that isn't present.
65
69
  installNativePlugin('copilot', installOptions.pluginNames);
66
70
  log(` - Copilot native plugins installed: ${(installOptions.pluginNames || ['empirical-memory']).join(', ')}.`);
67
71
 
72
+ try {
73
+ installCopilotHooks(installOptions.pluginNames);
74
+ } catch (error) {
75
+ warn(` - Could not install Copilot hooks for deterministic memory lookups: ${error.message}`);
76
+ }
77
+
68
78
  log(' - Copilot MCP config updated.');
69
79
  if (storedClient?.clientId) {
70
80
  log(` - Copilot OAuth client loaded from ${getLocalOAuthClientPath()}.`);
@@ -4,11 +4,73 @@ const os = require('os');
4
4
  const path = require('path');
5
5
  const { log } = require('../ui');
6
6
  const { ensureDir, writeFile, readJsonIfExists } = require('../fsUtils');
7
+ const { normalizePluginNames } = require('./nativePlugin');
7
8
 
8
9
  const CLAUDE_HOOK_SESSION_START_SOURCE = `#!/usr/bin/env node
9
10
  'use strict';
10
11
 
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
+ }
12
74
 
13
75
  function commandExists(cmd) {
14
76
  var probe = process.platform === 'win32'
@@ -17,22 +79,45 @@ function commandExists(cmd) {
17
79
  return probe.status === 0;
18
80
  }
19
81
 
82
+ function runMemoryQuery(args) {
83
+ var entryPoint = resolveEmpiricalEntryPoint();
84
+ if (entryPoint) {
85
+ appendLog('resolved direct entry point: ' + entryPoint);
86
+ return spawnSync(process.execPath, [entryPoint].concat(args), {
87
+ encoding: 'utf8',
88
+ timeout: 10000,
89
+ shell: false
90
+ });
91
+ }
92
+
93
+ // Fallback: could not resolve a direct entry point (non-Windows, or resolution failed) — run
94
+ // the "empirical" command as before. On POSIX this is shell:false and safe. On Windows this is
95
+ // shell:true and CAN still mangle args as described above; it only triggers if the direct-entry
96
+ // resolution above failed, so degraded (not absent) results are the acceptable worst case here.
97
+ appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
98
+ return spawnSync('empirical', args, {
99
+ encoding: 'utf8',
100
+ timeout: 10000,
101
+ shell: process.platform === 'win32'
102
+ });
103
+ }
104
+
20
105
  function main() {
106
+ appendLog('hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
107
+
21
108
  if (!commandExists('empirical')) {
109
+ appendLog('empirical CLI not found on PATH, exiting');
22
110
  process.exit(0);
23
111
  }
24
112
 
25
- var result = spawnSync('empirical', [
113
+ var result = runMemoryQuery([
26
114
  'memory', 'query',
27
115
  '--match', 'project context, conventions, recent decisions, user preferences',
28
116
  '--top-k', '8'
29
- ], {
30
- encoding: 'utf8',
31
- timeout: 10000,
32
- shell: process.platform === 'win32'
33
- });
117
+ ]);
34
118
 
35
119
  if (!result || result.status !== 0 || !result.stdout) {
120
+ appendLog('memory query failed or returned no stdout (status=' + (result && result.status) + ', stderr=' + (result && result.stderr ? String(result.stderr).slice(0, 300) : '') + ')');
36
121
  process.exit(0);
37
122
  }
38
123
 
@@ -40,13 +125,17 @@ function main() {
40
125
  try {
41
126
  results = JSON.parse(result.stdout);
42
127
  } catch (e) {
128
+ appendLog('failed to parse memory query stdout as JSON: ' + e.message);
43
129
  process.exit(0);
44
130
  }
45
131
 
46
132
  if (!Array.isArray(results) || results.length === 0) {
133
+ appendLog('memory query returned zero results');
47
134
  process.exit(0);
48
135
  }
49
136
 
137
+ appendLog('memory query returned ' + results.length + ' result(s), injecting into session context');
138
+
50
139
  var lines = results.slice(0, 8).map(function (r) {
51
140
  var category = r && r.category ? r.category : 'memory';
52
141
  var summary = r && r.summary ? r.summary : '';
@@ -60,6 +149,204 @@ function main() {
60
149
  main();
61
150
  `;
62
151
 
152
+ // EMPIRICAL_HOOK_STOP_SOURCE: fires once per assistant turn (Claude: Stop; Codex: Stop, turn-
153
+ // scoped per codex-rs/hooks/src/engine/dispatcher.rs; Copilot: agentStop, "the main agent
154
+ // finishes a turn" per GitHub's own hooks reference). This is the write-side counterpart to the
155
+ // SessionStart hook above (which only covers recall, once per session boundary) - deliberately
156
+ // simple: it does NOT parse the transcript to detect whether memory-worthy activity happened
157
+ // this turn (transcript formats differ across all three platforms and getting that heuristic
158
+ // wrong silently - the exact class of bug this session spent hours finding - is worse than an
159
+ // unconditional reminder). It never calls the empirical CLI and makes no network call, so the
160
+ // "wasteful per-turn hook" concern that killed the old per-turn UserPromptSubmit hook (see
161
+ // installClaudeHooks below) does not apply here - this is a single static stdout write.
162
+ // ALWAYS exits 0: per Claude Code's documented Stop hook contract, exit 2 forces the agent to
163
+ // keep responding (a blocking decision) - a bug in a hook that's supposed to be an informational
164
+ // nudge must never be able to trap a session in a forced-continuation loop, so this is
165
+ // unconditional regardless of any error encountered.
166
+ const EMPIRICAL_HOOK_STOP_SOURCE = `#!/usr/bin/env node
167
+ 'use strict';
168
+
169
+ var fs = require('fs');
170
+ var os = require('os');
171
+ var path = require('path');
172
+
173
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
174
+ var MAX_LOG_BYTES = 200 * 1024;
175
+
176
+ function appendLog(line) {
177
+ try {
178
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
179
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
180
+ var stat = fs.statSync(LOG_PATH);
181
+ if (stat.size > MAX_LOG_BYTES) {
182
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
183
+ fs.writeFileSync(LOG_PATH, tail);
184
+ }
185
+ } catch (e) {
186
+ // Diagnostics must never break the hook.
187
+ }
188
+ }
189
+
190
+ function readStdinPayload() {
191
+ try {
192
+ var raw = fs.readFileSync(0, 'utf8');
193
+ return raw ? JSON.parse(raw) : {};
194
+ } catch (e) {
195
+ return {};
196
+ }
197
+ }
198
+
199
+ function main() {
200
+ var payload = readStdinPayload();
201
+
202
+ // Defensive loop guard: if a Stop hook's own forced-continuation output somehow triggers
203
+ // another Stop event before the agent produces new output, stop_hook_active (Claude's documented
204
+ // field name for this) prevents a reminder-print-loop. This hook never sets decision:block
205
+ // itself, but checking costs nothing and protects against any future change that does.
206
+ if (payload && payload.stop_hook_active === true) {
207
+ appendLog('stop_hook_active=true, skipping reminder');
208
+ process.exit(0);
209
+ }
210
+
211
+ appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
212
+
213
+ console.log('[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now via the empirical-memory skill or \`empirical memory record\` - do not wait to be asked.');
214
+ process.exit(0);
215
+ }
216
+
217
+ main();
218
+ `;
219
+
220
+ // Claude-only variant of the Stop reminder above. Claude Code's Stop hook contract is the one
221
+ // platform of the three we've actually confirmed supports safe forced-continuation: exit code 2
222
+ // feeds stderr back to Claude as the reason and blocks the stop, and the stop_hook_active field on
223
+ // the next invocation's stdin payload tells us a forced-continuation already happened for this
224
+ // turn (Claude sets it specifically so a hook can avoid re-blocking and trapping the session in a
225
+ // loop). EMPIRICAL_HOOK_STOP_SOURCE above already reads stop_hook_active defensively but never
226
+ // blocks, so on Claude the reminder was silently logged and never reached the model - this variant
227
+ // is the same script with the loop guard actually load-bearing: block once per turn, never twice.
228
+ // Codex/Copilot keep using the silent EMPIRICAL_HOOK_STOP_SOURCE until their own Stop/agentStop
229
+ // blocking semantics are confirmed against source the way Claude's was here.
230
+ const CLAUDE_HOOK_STOP_SOURCE = `#!/usr/bin/env node
231
+ 'use strict';
232
+
233
+ var fs = require('fs');
234
+ var os = require('os');
235
+ var path = require('path');
236
+
237
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'stop.log');
238
+ var MAX_LOG_BYTES = 200 * 1024;
239
+
240
+ function appendLog(line) {
241
+ try {
242
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
243
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
244
+ var stat = fs.statSync(LOG_PATH);
245
+ if (stat.size > MAX_LOG_BYTES) {
246
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
247
+ fs.writeFileSync(LOG_PATH, tail);
248
+ }
249
+ } catch (e) {
250
+ // Diagnostics must never break the hook.
251
+ }
252
+ }
253
+
254
+ function readStdinPayload() {
255
+ try {
256
+ var raw = fs.readFileSync(0, 'utf8');
257
+ return raw ? JSON.parse(raw) : {};
258
+ } catch (e) {
259
+ return {};
260
+ }
261
+ }
262
+
263
+ function main() {
264
+ var payload = readStdinPayload();
265
+
266
+ // stop_hook_active === true means Claude is re-invoking this hook because a PRIOR Stop hook
267
+ // (possibly this same one) already forced a continuation this turn. Blocking again here would
268
+ // trap the session in a forced-continuation loop, so this is the one case that must always exit
269
+ // 0 with no reminder, unconditionally.
270
+ if (payload && payload.stop_hook_active === true) {
271
+ appendLog('stop_hook_active=true, skipping reminder (already blocked once this turn)');
272
+ process.exit(0);
273
+ }
274
+
275
+ appendLog('stop hook fired (pid=' + process.pid + ', platform=' + process.platform + '), blocking once to surface reminder');
276
+
277
+ // Exit 2 is Claude Code's documented Stop-hook blocking signal: stderr is fed back to Claude as
278
+ // context and the turn is forced to continue instead of ending, which is what actually gets this
279
+ // reminder read (a plain stdout console.log + exit 0 is only logged, never seen by the model -
280
+ // this was this hook's exact original bug).
281
+ process.stderr.write('[Empirical reminder] Before finishing: if this turn included a completed decision, a corrected mistake, or meaningful work worth remembering, record it now via the empirical-memory skill or \`empirical memory record\` - do not wait to be asked.\\n');
282
+ process.exit(2);
283
+ }
284
+
285
+ main();
286
+ `;
287
+
288
+ // UserPromptSubmit: fires before each turn is processed (Claude, Codex, and Copilot all support
289
+ // this event under the same name per each platform's docs/source). This is the recall-side
290
+ // counterpart to Stop's write-side reminder - deliberately a STATIC message, not a live
291
+ // `empirical memory query` call: a prior per-turn hook in this codebase was removed for making an
292
+ // unconditional network call on every single prompt (see the comment above installClaudeHooks),
293
+ // and re-adding that cost here on three platforms at once was an explicit no per product decision.
294
+ // The message itself is generated per-install from the actual plugin set being installed, not one
295
+ // fixed string for everyone - e.g. someone who declined empirical-scar-tissue at install time
296
+ // never sees a scar-tissue reminder, since that capability isn't actually available to them.
297
+ function buildPromptReminderMessage(pluginNames) {
298
+ const names = normalizePluginNames(pluginNames);
299
+ const lines = [
300
+ 'Before responding: if this task needs prior context (research, debugging, a spec or status update, a handoff), query Empirical memory first via the empirical-memory skill or empirical memory query - do not assume you already have enough context.'
301
+ ];
302
+ if (names.includes('empirical-work-history')) {
303
+ lines.push('If you are picking up previously completed work, check for a recent work-history entry before assuming there is none.');
304
+ }
305
+ if (names.includes('empirical-scar-tissue')) {
306
+ lines.push('Check for scar tissue before repeating a past mistake in this area.');
307
+ }
308
+ if (names.includes('empirical-todo')) {
309
+ lines.push('If there is an open todo for this project, check it before starting new work.');
310
+ }
311
+ return '[Empirical reminder] ' + lines.join(' ');
312
+ }
313
+
314
+ // Builds the full UserPromptSubmit hook script source with the tailored message baked in at
315
+ // generation time (via JSON.stringify, so the message is embedded as a single already-escaped
316
+ // string literal - substituted through `${}`, not written as raw template-literal source text,
317
+ // which is what caused the earlier comment-escaping bug in this same file. Never do the latter).
318
+ function buildUserPromptSubmitHookSource(pluginNames) {
319
+ const messageLiteral = JSON.stringify(buildPromptReminderMessage(pluginNames));
320
+ return `#!/usr/bin/env node
321
+ 'use strict';
322
+
323
+ var fs = require('fs');
324
+ var os = require('os');
325
+ var path = require('path');
326
+
327
+ var LOG_PATH = path.join(os.homedir(), '.empirical', 'hooks', 'user-prompt-submit.log');
328
+ var MAX_LOG_BYTES = 200 * 1024;
329
+
330
+ function appendLog(line) {
331
+ try {
332
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
333
+ fs.appendFileSync(LOG_PATH, '[' + new Date().toISOString() + '] ' + line + '\\n');
334
+ var stat = fs.statSync(LOG_PATH);
335
+ if (stat.size > MAX_LOG_BYTES) {
336
+ var tail = fs.readFileSync(LOG_PATH, 'utf8').slice(-MAX_LOG_BYTES / 2);
337
+ fs.writeFileSync(LOG_PATH, tail);
338
+ }
339
+ } catch (e) {
340
+ // Diagnostics must never break the hook.
341
+ }
342
+ }
343
+
344
+ appendLog('user-prompt-submit hook fired (pid=' + process.pid + ', platform=' + process.platform + ')');
345
+ console.log(${messageLiteral});
346
+ process.exit(0);
347
+ `;
348
+ }
349
+
63
350
  function getEmpiricalHooksDir() {
64
351
  return path.join(os.homedir(), '.empirical', 'hooks');
65
352
  }
@@ -81,17 +368,21 @@ function removeEmpiricalHookEntries(entries, scriptPath) {
81
368
  .filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
82
369
  }
83
370
 
84
- function installClaudeHooks() {
371
+ function installClaudeHooks(pluginNames) {
85
372
  const hooksDir = getEmpiricalHooksDir();
86
373
  ensureDir(hooksDir);
87
374
 
88
- // Older installs registered a per-turn UserPromptSubmit hook here too. That traded an
89
- // unconditional network call on every single prompt for determinism, which is wasteful —
90
- // the native empirical-memory plugin now covers ad-hoc reads/writes via
91
- // Bash without that per-turn tax. SessionStart remains: one cheap call per session boundary.
375
+ // A prior version of this hook made an unconditional `empirical memory query` network call on
376
+ // every single prompt and was removed for being wasteful (see git history). This is a
377
+ // deliberate reintroduction at the SAME script path, but with fundamentally different content:
378
+ // a static, install-time-generated reminder with no network call at all - the cost concern that
379
+ // killed the old version does not apply to a plain stdout write.
92
380
  const userPromptScriptPath = path.join(hooksDir, 'claude-user-prompt-submit.js');
93
381
  const sessionStartScriptPath = path.join(hooksDir, 'claude-session-start.js');
382
+ const stopScriptPath = path.join(hooksDir, 'claude-stop.js');
94
383
  writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
384
+ writeFile(stopScriptPath, CLAUDE_HOOK_STOP_SOURCE);
385
+ writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
95
386
 
96
387
  const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
97
388
  const settings = readJsonIfExists(settingsPath) || {};
@@ -99,13 +390,10 @@ function installClaudeHooks() {
99
390
  settings.hooks = {};
100
391
  }
101
392
 
102
- // Clean up the old per-turn hook entry if a prior install left one behind.
103
- if (Array.isArray(settings.hooks.UserPromptSubmit)) {
104
- settings.hooks.UserPromptSubmit = removeEmpiricalHookEntries(settings.hooks.UserPromptSubmit, userPromptScriptPath);
105
- if (settings.hooks.UserPromptSubmit.length === 0) {
106
- delete settings.hooks.UserPromptSubmit;
107
- }
108
- }
393
+ settings.hooks.UserPromptSubmit = removeEmpiricalHookEntries(settings.hooks.UserPromptSubmit, userPromptScriptPath);
394
+ settings.hooks.UserPromptSubmit.push({
395
+ hooks: [{ type: 'command', command: buildHookCommand(userPromptScriptPath), timeout: 10 }]
396
+ });
109
397
 
110
398
  settings.hooks.SessionStart = removeEmpiricalHookEntries(settings.hooks.SessionStart, sessionStartScriptPath);
111
399
  for (const matcher of ['startup', 'resume', 'compact']) {
@@ -115,14 +403,148 @@ function installClaudeHooks() {
115
403
  });
116
404
  }
117
405
 
406
+ settings.hooks.Stop = removeEmpiricalHookEntries(settings.hooks.Stop, stopScriptPath);
407
+ settings.hooks.Stop.push({
408
+ hooks: [{ type: 'command', command: buildHookCommand(stopScriptPath), timeout: 10 }]
409
+ });
410
+
118
411
  writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
119
- log(' - Claude SessionStart hook installed for deterministic memory lookups.');
412
+ log(' - Claude SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
413
+ }
414
+
415
+ // Codex CLI ships a real hooks system (confirmed against openai/codex source — codex-rs/hooks,
416
+ // codex-rs/config/src/hook_config.rs — not assumed from docs alone, since third-party writeups
417
+ // disagreed on Windows support). It reads <CODEX_HOME>/hooks.json (JSON, not the config.toml
418
+ // used for MCP/model settings — a separate file, so this never touches config.toml) shaped as
419
+ // { hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command, commandWindows,
420
+ // timeout }] }], ... } }. CODEX_HOME defaults to ~/.codex when unset, same convention as
421
+ // ~/.claude for Claude Code.
422
+ function getCodexHomeDir() {
423
+ return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
424
+ }
425
+
426
+ function removeEmpiricalHooksJsonEntries(entries, scriptPath) {
427
+ if (!Array.isArray(entries)) return [];
428
+ return entries
429
+ .map((entry) => {
430
+ if (!entry || !Array.isArray(entry.hooks)) return entry;
431
+ const filteredHooks = entry.hooks.filter(
432
+ (h) => !(h && typeof h.command === 'string' && h.command.includes(scriptPath))
433
+ );
434
+ return { ...entry, hooks: filteredHooks };
435
+ })
436
+ .filter((entry) => entry && (!Array.isArray(entry.hooks) || entry.hooks.length > 0));
437
+ }
438
+
439
+ function installCodexHooks(pluginNames) {
440
+ const hooksDir = getEmpiricalHooksDir();
441
+ ensureDir(hooksDir);
442
+
443
+ const sessionStartScriptPath = path.join(hooksDir, 'codex-session-start.js');
444
+ const stopScriptPath = path.join(hooksDir, 'codex-stop.js');
445
+ const userPromptScriptPath = path.join(hooksDir, 'codex-user-prompt-submit.js');
446
+ writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
447
+ writeFile(stopScriptPath, EMPIRICAL_HOOK_STOP_SOURCE);
448
+ writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
449
+
450
+ const hooksJsonPath = path.join(getCodexHomeDir(), 'hooks.json');
451
+ const hooksFile = readJsonIfExists(hooksJsonPath) || {};
452
+ if (!hooksFile.hooks || typeof hooksFile.hooks !== 'object') {
453
+ hooksFile.hooks = {};
454
+ }
455
+
456
+ hooksFile.hooks.SessionStart = removeEmpiricalHooksJsonEntries(hooksFile.hooks.SessionStart, sessionStartScriptPath);
457
+ const sessionStartCommand = buildHookCommand(sessionStartScriptPath);
458
+ for (const matcher of ['startup', 'resume']) {
459
+ hooksFile.hooks.SessionStart.push({
460
+ matcher,
461
+ hooks: [{ type: 'command', command: sessionStartCommand, commandWindows: sessionStartCommand, timeout: 15 }]
462
+ });
463
+ }
464
+
465
+ hooksFile.hooks.Stop = removeEmpiricalHooksJsonEntries(hooksFile.hooks.Stop, stopScriptPath);
466
+ const stopCommand = buildHookCommand(stopScriptPath);
467
+ hooksFile.hooks.Stop.push({
468
+ hooks: [{ type: 'command', command: stopCommand, commandWindows: stopCommand, timeout: 10 }]
469
+ });
470
+
471
+ // Confirmed against codex-rs/config/src/hook_config.rs's HookEventsToml serde renames: the
472
+ // config-file key is PascalCase "UserPromptSubmit", same casing convention as SessionStart/Stop.
473
+ hooksFile.hooks.UserPromptSubmit = removeEmpiricalHooksJsonEntries(hooksFile.hooks.UserPromptSubmit, userPromptScriptPath);
474
+ const userPromptCommand = buildHookCommand(userPromptScriptPath);
475
+ hooksFile.hooks.UserPromptSubmit.push({
476
+ hooks: [{ type: 'command', command: userPromptCommand, commandWindows: userPromptCommand, timeout: 10 }]
477
+ });
478
+
479
+ writeFile(hooksJsonPath, `${JSON.stringify(hooksFile, null, 2)}\n`);
480
+ log(' - Codex SessionStart + UserPromptSubmit + Stop hooks installed for deterministic memory lookups and reminders.');
481
+ }
482
+
483
+ // GitHub Copilot CLI's hook config is one JSON file per hook under ~/.copilot/hooks/ (any
484
+ // filename — confirmed via GitHub's own docs at docs.github.com/en/copilot/reference/hooks-
485
+ // reference), so unlike Claude/Codex this never needs to merge into a shared file: Empirical
486
+ // owns this file outright and can just overwrite it. Event key for turn-end is "agentStop"
487
+ // ("the main agent finishes a turn" per the same docs) — not "stop"/"Stop" like Claude/Codex.
488
+ // Schema: { version: 1, hooks: { sessionStart: [...], agentStop: [...] } }.
489
+ function installCopilotHooks(pluginNames) {
490
+ const hooksDir = getEmpiricalHooksDir();
491
+ ensureDir(hooksDir);
492
+
493
+ const sessionStartScriptPath = path.join(hooksDir, 'copilot-session-start.js');
494
+ const stopScriptPath = path.join(hooksDir, 'copilot-stop.js');
495
+ const userPromptScriptPath = path.join(hooksDir, 'copilot-user-prompt-submit.js');
496
+ writeFile(sessionStartScriptPath, CLAUDE_HOOK_SESSION_START_SOURCE);
497
+ writeFile(stopScriptPath, EMPIRICAL_HOOK_STOP_SOURCE);
498
+ writeFile(userPromptScriptPath, buildUserPromptSubmitHookSource(pluginNames));
499
+
500
+ const copilotHooksDir = path.join(os.homedir(), '.copilot', 'hooks');
501
+ ensureDir(copilotHooksDir);
502
+
503
+ // Prior versions of this installer wrote a single-purpose "empirical-session-start.json" —
504
+ // remove it now that both events live in one unified file, so a stale duplicate registration
505
+ // never lingers alongside the new one.
506
+ const legacyHookFilePath = path.join(copilotHooksDir, 'empirical-session-start.json');
507
+ try {
508
+ const fs = require('fs');
509
+ if (fs.existsSync(legacyHookFilePath)) fs.unlinkSync(legacyHookFilePath);
510
+ } catch (_error) {
511
+ // best-effort cleanup only
512
+ }
513
+
514
+ const hookFilePath = path.join(copilotHooksDir, 'empirical-hooks.json');
515
+ const hookFileContent = {
516
+ version: 1,
517
+ hooks: {
518
+ sessionStart: [
519
+ { type: 'command', command: buildHookCommand(sessionStartScriptPath), timeoutSec: 15 }
520
+ ],
521
+ // "userPromptSubmitted" per GitHub's hooks reference ("The user submits a prompt") - not
522
+ // "userPromptSubmit" (that camelCase spelling is Claude/Codex's convention, not Copilot's).
523
+ userPromptSubmitted: [
524
+ { type: 'command', command: buildHookCommand(userPromptScriptPath), timeoutSec: 10 }
525
+ ],
526
+ agentStop: [
527
+ { type: 'command', command: buildHookCommand(stopScriptPath), timeoutSec: 10 }
528
+ ]
529
+ }
530
+ };
531
+
532
+ writeFile(hookFilePath, `${JSON.stringify(hookFileContent, null, 2)}\n`);
533
+ log(' - Copilot SessionStart + userPromptSubmitted + agentStop hooks installed for deterministic memory lookups and reminders.');
120
534
  }
121
535
 
122
536
  module.exports = {
123
537
  CLAUDE_HOOK_SESSION_START_SOURCE,
538
+ EMPIRICAL_HOOK_STOP_SOURCE,
539
+ CLAUDE_HOOK_STOP_SOURCE,
540
+ buildPromptReminderMessage,
541
+ buildUserPromptSubmitHookSource,
124
542
  getEmpiricalHooksDir,
125
543
  buildHookCommand,
126
544
  removeEmpiricalHookEntries,
127
- installClaudeHooks
545
+ installClaudeHooks,
546
+ getCodexHomeDir,
547
+ removeEmpiricalHooksJsonEntries,
548
+ installCodexHooks,
549
+ installCopilotHooks
128
550
  };
@@ -159,16 +159,28 @@ function buildEmpiricalSkillFrontmatter(options = {}) {
159
159
  name: 'empirical-memory',
160
160
  // Keep frontmatter deliberately simple: Copilot's YAML parser rejects an
161
161
  // unquoted colon in a plain scalar, and does not support when_to_use.
162
- description: 'Use Empirical CLI and MCP to recall and save durable project context across sessions and projects.'
162
+ // Covers both triggers this skill owns: durable project memory recall/save,
163
+ // and "how do I use Empirical" questions (CLI commands, install, auth,
164
+ // workspaces, kits, skills) - a single skill so there's no ambiguity about
165
+ // which one to check for an Empirical-shaped question.
166
+ description: 'Use Empirical to recall/save durable project context across sessions, and to look up how to use Empirical itself - CLI commands, install/auth/workspace/kit/skill setup, docs.'
163
167
  };
164
168
  }
165
169
 
166
170
  function buildEmpiricalSkillBody(options = {}) {
167
171
  const policyLines = buildSharedEmpiricalPolicyLines(options);
168
172
  const workflows = workflowLabelsSummary(options);
169
- return `## Empirical Memory - Use It Like grep/view
173
+ return `## Empirical - Use It Like grep/view
170
174
 
171
- Use this skill for ${workflows}, whenever prior context may matter, and whenever the user asks to remember, recall, update, or forget information.
175
+ This skill covers two distinct triggers. Check both, not just the first one that seems to fit:
176
+
177
+ 1. **Durable memory recall/save** - for ${workflows}, whenever prior context may matter, and
178
+ whenever the user asks to remember, recall, update, or forget information.
179
+ 2. **"How do I use Empirical" questions** - CLI command syntax, install/auth/workspace/kit/skill
180
+ setup, or any question about what Empirical can do or how it's configured. Don't answer these
181
+ from general knowledge or by grepping the empirical_v2 source - run \`empirical doc\` (lists
182
+ topics) or \`empirical doc <topic>\` via the available shell for the current, agent-readable
183
+ answer before guessing.
172
184
 
173
185
  Prefer calling the \`empirical\` CLI directly via the available shell over the MCP tools when both are
174
186
  available: \`empirical memory query|get|list|record|note|update|delete\`. The CLI doesn't require an
@@ -6,8 +6,8 @@ const { getEnv, parseFlag, parseOption } = require('./env');
6
6
  const { ensureHeadlessToken, resolveAccessToken, buildApiAuthHeaders } = require('./auth');
7
7
  const { readStoredOAuthClient } = require('./oauthClient');
8
8
  const { deriveBaseUrlFromMcpUrl } = require('./constants');
9
- const { withCliAnalyticsContext } = require('./memoryCommands');
10
- const { readSelectedWorkspace, writeSelectedWorkspace, clearSelectedWorkspace } = require('./workspaceContext');
9
+ const { withCliAnalyticsContext } = require('./memoryCommands');
10
+ const { readSelectedWorkspace, writeSelectedWorkspace, clearSelectedWorkspace } = require('./workspaceContext');
11
11
 
12
12
  function isWorkspaceCommand(args) {
13
13
  if (args[0] !== 'workspace') return false;
@@ -43,7 +43,7 @@ function targetFromOption(option, teamBrain) {
43
43
  return {
44
44
  ...(option || {}),
45
45
  key: option?.key || teamBrain?.workspaceId,
46
- selectionKey: option?.key || teamBrain?.workspaceId || option?.workspaceId,
46
+ selectionKey: option?.key || teamBrain?.workspaceId || option?.workspaceId,
47
47
  label: option?.label || teamBrain?.name,
48
48
  isDefault: Boolean(option?.isDefault),
49
49
  workspaceId: teamBrain?.workspaceId || option?.workspaceId || null,
@@ -118,12 +118,20 @@ async function getWorkspaceCommandAuth() {
118
118
  if (!accessToken) {
119
119
  throw new Error('No access token available. Run `empirical oauth bootstrap headless --prompt-credentials` first.');
120
120
  }
121
- const clientId = tokenPayload?.clientId || getEnv('EMPIRICAL_CLIENT_ID') || readStoredOAuthClient()?.clientId;
121
+ // clientId is genuinely optional here now -- `list`/`current` fall back to a per-user endpoint
122
+ // (GET /api/v1/users/me/workspaces) when it's absent, since most CLI usage is a plain API key
123
+ // with no OAuth client_credentials flow at all (see `doctor`'s token_type check). Actions that
124
+ // mutate a client's workspace selection (select/use/create/delete/invite) still require one --
125
+ // there's no per-user equivalent of "which workspace is this client currently using."
126
+ const clientId = tokenPayload?.clientId || getEnv('EMPIRICAL_CLIENT_ID') || readStoredOAuthClient()?.clientId || null;
127
+ const baseUrl = getEnv('EMPIRICAL_BASE_URL', deriveBaseUrlFromMcpUrl() || 'https://empirical.gauzza.com');
128
+ return { baseUrl, clientId, accessToken };
129
+ }
130
+
131
+ function requireClientId(clientId) {
122
132
  if (!clientId) {
123
133
  throw new Error('No OAuth client ID found. Run `empirical oauth client set --client-id <id>` first.');
124
134
  }
125
- const baseUrl = getEnv('EMPIRICAL_BASE_URL', deriveBaseUrlFromMcpUrl() || 'https://empirical.gauzza.com');
126
- return { baseUrl, clientId, accessToken };
127
135
  }
128
136
 
129
137
  async function runWorkspaceCommand(args) {
@@ -132,9 +140,13 @@ async function runWorkspaceCommand(args) {
132
140
  async () => {
133
141
  const action = args[1];
134
142
  const { baseUrl, clientId, accessToken } = await getWorkspaceCommandAuth();
135
- const encodedClientId = encodeURIComponent(clientId);
136
- const listUrl = new URL(`/oauth/clients/${encodedClientId}/workspaces`, baseUrl).toString();
137
- const currentUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
143
+ const encodedClientId = clientId ? encodeURIComponent(clientId) : null;
144
+ const listUrl = clientId
145
+ ? new URL(`/oauth/clients/${encodedClientId}/workspaces`, baseUrl).toString()
146
+ : new URL('/api/v1/users/me/workspaces', baseUrl).toString();
147
+ const currentUrl = clientId
148
+ ? new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString()
149
+ : null; // no per-client selection endpoint to hit -- 'current' derives it from listUrl's `selected` flags instead
138
150
  const teamBrainsUrl = new URL('/api/v1/team-brains', baseUrl).toString();
139
151
  const authHeaders = buildApiAuthHeaders(accessToken);
140
152
 
@@ -151,13 +163,21 @@ async function runWorkspaceCommand(args) {
151
163
  return;
152
164
  }
153
165
 
154
- if (action === 'current') {
155
- const locallySelected = readSelectedWorkspace();
156
- if (locallySelected) {
157
- log(`Current workspace: ${locallySelected.label || locallySelected.workspaceId}`);
158
- return;
159
- }
160
- const payload = await httpJson(currentUrl, 'GET', null, authHeaders);
166
+ if (action === 'current') {
167
+ const locallySelected = readSelectedWorkspace();
168
+ if (locallySelected) {
169
+ log(`Current workspace: ${locallySelected.label || locallySelected.workspaceId}`);
170
+ return;
171
+ }
172
+ if (!clientId) {
173
+ const payload = await httpJson(listUrl, 'GET', null, authHeaders);
174
+ const selected = payload.defaultOption.selected
175
+ ? payload.defaultOption
176
+ : (payload.workspaces || []).find((ws) => ws.selected) || payload.defaultOption;
177
+ log(`Current workspace: ${selected.label || 'Default Brain'}`);
178
+ return;
179
+ }
180
+ const payload = await httpJson(currentUrl, 'GET', null, authHeaders);
161
181
  log(`Current workspace: ${payload?.current?.label || 'Default Brain'}`);
162
182
  if (payload?.locked) {
163
183
  log('This OAuth client is admin-locked to a workspace.');
@@ -166,6 +186,7 @@ async function runWorkspaceCommand(args) {
166
186
  }
167
187
 
168
188
  if (action === 'select' || action === 'use') {
189
+ requireClientId(clientId);
169
190
  const [payload, teamPayload] = await Promise.all([
170
191
  httpJson(listUrl, 'GET', null, authHeaders),
171
192
  httpJson(teamBrainsUrl, 'GET', null, authHeaders),
@@ -193,13 +214,13 @@ async function runWorkspaceCommand(args) {
193
214
  if (!target?.selectionKey) throw new Error(`Workspace not found: ${requested}`);
194
215
 
195
216
  const updateUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
196
- const updated = await httpJson(updateUrl, 'POST', { key: target.selectionKey }, authHeaders);
197
- if (target.isDefault) {
198
- clearSelectedWorkspace();
199
- } else if (target.workspaceId) {
200
- writeSelectedWorkspace({ workspaceId: target.workspaceId, key: target.key, label: target.label });
201
- }
202
- log(uiSuccess(`Workspace selected: ${updated?.current?.label || target.label || target.selectionKey}`));
217
+ const updated = await httpJson(updateUrl, 'POST', { key: target.selectionKey }, authHeaders);
218
+ if (target.isDefault) {
219
+ clearSelectedWorkspace();
220
+ } else if (target.workspaceId) {
221
+ writeSelectedWorkspace({ workspaceId: target.workspaceId, key: target.key, label: target.label });
222
+ }
223
+ log(uiSuccess(`Workspace selected: ${updated?.current?.label || target.label || target.selectionKey}`));
203
224
  return;
204
225
  }
205
226
 
@@ -241,18 +262,20 @@ async function runWorkspaceCommand(args) {
241
262
 
242
263
  log(`Soft-deleting workspace ${targetDescription}...`);
243
264
  await httpJson(new URL(`/api/v1/team-brains/${encodeURIComponent(target.workspaceId)}`, baseUrl).toString(), 'DELETE', null, authHeaders);
244
- if (target.selected) {
265
+ if (target.selected && clientId) {
245
266
  const updateUrl = new URL(`/oauth/clients/${encodedClientId}/workspace-selection`, baseUrl).toString();
246
267
  const updated = await httpJson(updateUrl, 'POST', { key: 'default_brain' }, authHeaders);
247
- log(uiSuccess(`Soft-deleted workspace ${targetDescription}. Active workspace reset to ${updated?.current?.label || 'Default Brain'}.`));
248
- clearSelectedWorkspace();
249
- } else {
268
+ log(uiSuccess(`Soft-deleted workspace ${targetDescription}. Active workspace reset to ${updated?.current?.label || 'Default Brain'}.`));
269
+ clearSelectedWorkspace();
270
+ } else {
250
271
  log(uiSuccess(`Soft-deleted workspace ${targetDescription}.`));
272
+ clearSelectedWorkspace();
251
273
  }
252
274
  return;
253
275
  }
254
276
 
255
277
  if (action === 'invite') {
278
+ requireClientId(clientId);
256
279
  const inviteAction = args[2];
257
280
  const currentPayload = await httpJson(currentUrl, 'GET', null, authHeaders);
258
281
  const current = currentPayload?.current;
package/package.json CHANGED
@@ -1,14 +1,9 @@
1
1
  {
2
- "name": "@empirical-gauzza/cli",
3
- "version": "1.0.9",
2
+ "name": "@empirical-gauzza/cli",
3
+ "version": "1.0.11",
4
4
  "description": "Empirical CLI installer and local MCP wiring helper",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/mattgauzza/empirical.git",
10
- "directory": "packages/cli"
11
- },
12
7
  "homepage": "https://empirical.gauzza.com/docs/cli-details",
13
8
  "publishConfig": {
14
9
  "access": "public"