@empirical-gauzza/cli 1.0.30 → 1.0.32

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.
@@ -41,6 +41,31 @@ function writeIntegrationMode(integrationMode) {
41
41
  writeDeviceConfig(config);
42
42
  }
43
43
 
44
+ // Hosts (codex / copilot / claude) that Empirical has actually been installed for on this
45
+ // machine, recorded by `empirical install` and `empirical plugin install`. `doctor` reads this
46
+ // so a host that merely exists on the machine (a ~/.claude folder left by another tool, or a CLI
47
+ // the user deliberately skipped in the installer) is reported as skipped, not as broken.
48
+ // Returns null when nothing was ever recorded (installs that predate this field), so callers
49
+ // can tell "unknown" apart from "none".
50
+ const KNOWN_HOST_IDS = ['codex', 'copilot', 'claude'];
51
+
52
+ function readInstalledHosts() {
53
+ const config = readDeviceConfig();
54
+ if (!Array.isArray(config.installedHosts)) return null;
55
+ return config.installedHosts.filter((id) => KNOWN_HOST_IDS.includes(id));
56
+ }
57
+
58
+ function addInstalledHosts(hostIds) {
59
+ const config = readDeviceConfig();
60
+ const existing = Array.isArray(config.installedHosts) ? config.installedHosts : [];
61
+ const merged = new Set(existing.filter((id) => KNOWN_HOST_IDS.includes(id)));
62
+ for (const id of hostIds || []) {
63
+ if (KNOWN_HOST_IDS.includes(id)) merged.add(id);
64
+ }
65
+ config.installedHosts = KNOWN_HOST_IDS.filter((id) => merged.has(id));
66
+ writeDeviceConfig(config);
67
+ }
68
+
44
69
  function readPolicyCache() {
45
70
  const config = readDeviceConfig();
46
71
  return config.empiricalPolicy || null;
@@ -101,6 +126,8 @@ module.exports = {
101
126
  writeDeviceConfig,
102
127
  readIntegrationMode,
103
128
  writeIntegrationMode,
129
+ readInstalledHosts,
130
+ addInstalledHosts,
104
131
  readPolicyCache,
105
132
  writePolicyCache,
106
133
  ensureDeviceId,
package/lib/doctor.js CHANGED
@@ -13,6 +13,39 @@ const { detectInstallTargets } = require('./installers/detect');
13
13
  const { readInstalledVersions } = require('./pluginUpdateCheck');
14
14
  const { CORE_PLUGIN_NAME, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
15
15
  const { getEmpiricalHooksDir, getCodexHooksScriptsDir, getCodexHomeDir } = require('./installers/hooks');
16
+ const { readIntegrationMode, readInstalledHosts } = require('./deviceConfig');
17
+
18
+ // ─── Which detected hosts doctor should grade ──────────────────────────────────
19
+ // Doctor output is read by agents, and a FAIL line with a "Run: ..." remedy is, to an agent, an
20
+ // instruction. So a host the user never set Empirical up for must not be graded as broken:
21
+ // - If the installer recorded which hosts it installed for, grade exactly those.
22
+ // - Otherwise (installs that predate the record), grade only hosts whose binary is on PATH; a
23
+ // bare dotfolder (e.g. ~/.claude left behind with no `claude` command) is nothing to check.
24
+ // Skipped hosts still get one informational line so the user can see why they were left out.
25
+ function partitionHostsToCheck(detectedTargets, installedHosts) {
26
+ const checked = [];
27
+ const skipped = [];
28
+ for (const target of detectedTargets) {
29
+ if (installedHosts !== null) {
30
+ if (installedHosts.includes(target.id)) {
31
+ checked.push(target);
32
+ } else {
33
+ const evidence = target.onPath
34
+ ? `${target.id} is on PATH`
35
+ : `only its ${target.configDir} folder exists; ${target.id} is not on PATH`;
36
+ skipped.push({ target, reason: `not set up for Empirical (${evidence}). To add it: empirical install.` });
37
+ }
38
+ } else if (target.onPath) {
39
+ checked.push(target);
40
+ } else {
41
+ skipped.push({
42
+ target,
43
+ reason: `only its ${target.configDir} folder exists; ${target.id} is not on PATH, so there is nothing to check. To set it up later: empirical install.`
44
+ });
45
+ }
46
+ }
47
+ return { checked, skipped };
48
+ }
16
49
 
17
50
  // ─── Host MCP config registration ─────────────────────────────────────────────
18
51
  // Each host stores "is the Empirical MCP server registered" in a different shape and location.
@@ -377,11 +410,28 @@ async function runDoctor(args) {
377
410
  }
378
411
 
379
412
  // ─── MCP wiring diagnostics (C-007) ─────────────────────────────────────
380
- const detectedTargets = detectInstallTargets();
381
- if (detectedTargets.length === 0) {
413
+ const allDetected = detectInstallTargets();
414
+ let installedHosts = null;
415
+ let integrationMode = 1;
416
+ try {
417
+ installedHosts = readInstalledHosts();
418
+ integrationMode = readIntegrationMode();
419
+ } catch (_error) {
420
+ installedHosts = null;
421
+ integrationMode = 1;
422
+ }
423
+ const { checked: detectedTargets, skipped: skippedTargets } = partitionHostsToCheck(allDetected, installedHosts);
424
+
425
+ if (allDetected.length === 0) {
382
426
  addCheck('mcp_hosts_detected', 'warn', 'No supported host CLI detected (codex/claude/copilot). Nothing to check for MCP wiring.');
427
+ } else if (detectedTargets.length === 0) {
428
+ addCheck('mcp_hosts_detected', 'warn', `Detected: ${allDetected.map((t) => t.label).join(', ')}, but none is set up for Empirical. Run: empirical install.`);
383
429
  } else {
384
- addCheck('mcp_hosts_detected', 'ok', `Detected: ${detectedTargets.map((t) => t.label).join(', ')}.`);
430
+ const modeLabel = integrationMode === 2 ? 'CLI-direct' : integrationMode === 3 ? 'MCP + CLI' : 'MCP';
431
+ addCheck('mcp_hosts_detected', 'ok', `Checking: ${detectedTargets.map((t) => t.label).join(', ')} (${modeLabel} mode).`);
432
+ }
433
+ for (const { target, reason } of skippedTargets) {
434
+ addCheck(`host_${target.id}`, 'skip', `${target.label}: ${reason}`);
385
435
  }
386
436
 
387
437
  for (const target of detectedTargets) {
@@ -393,7 +443,16 @@ async function runDoctor(args) {
393
443
  } catch (error) {
394
444
  result = { ok: false, message: `Could not inspect ${target.label} MCP config: ${error.message}` };
395
445
  }
396
- addCheck(`mcp_config_${target.id}`, result.ok ? 'ok' : 'fail', `${target.label}: ${result.message}`);
446
+ if (integrationMode === 2) {
447
+ // CLI-direct mode: agents call the `empirical` CLI, so an unregistered MCP server is the
448
+ // expected state, not a defect. Report whatever is there without failing on absence.
449
+ const message = result.ok
450
+ ? `${result.message} (CLI-direct mode; MCP registration is optional.)`
451
+ : 'CLI-direct mode: MCP server not registered, and none is expected. Agents use the empirical CLI.';
452
+ addCheck(`mcp_config_${target.id}`, 'ok', `${target.label}: ${message}`);
453
+ } else {
454
+ addCheck(`mcp_config_${target.id}`, result.ok ? 'ok' : 'fail', `${target.label}: ${result.message}`);
455
+ }
397
456
  }
398
457
 
399
458
  const mcpReachability = await checkMcpReachability(token);
@@ -464,6 +523,7 @@ async function runDoctor(args) {
464
523
  const statusLabel = (status) => {
465
524
  if (status === 'ok') return colorize(ANSI.green, '[OK] ');
466
525
  if (status === 'warn') return colorize(ANSI.yellow, '[WARN]');
526
+ if (status === 'skip') return colorize(ANSI.dim, '[SKIP]');
467
527
  return colorize(ANSI.red, '[FAIL]');
468
528
  };
469
529
  for (const check of checks) {
@@ -23,8 +23,9 @@ const { detectInstallTargets } = require('./installers/detect');
23
23
  const { installClaudeHooks, installCodexHooks, installCopilotHooks } = require('./installers/hooks');
24
24
  const { runCodex } = require('./installers/codex');
25
25
  const { installNativePlugin, OPTIONAL_PLUGIN_NAMES } = require('./installers/nativePlugin');
26
+ const { writeCoreMemoryGuidance } = require('./installers/coreMemoryBlock');
26
27
  const { policyFromInstallerOptions, savePolicy: persistPolicy } = require('./policyCommands');
27
- const { writeIntegrationMode } = require('./deviceConfig');
28
+ const { writeIntegrationMode, addInstalledHosts } = require('./deviceConfig');
28
29
 
29
30
  function pluginNamesForInstallerOptions(options = {}) {
30
31
  const names = ['empirical-memory'];
@@ -234,10 +235,13 @@ async function runInstallFlow() {
234
235
  log(uiHeader(`Step 2/2: Installing for ${target.label}...`));
235
236
  try {
236
237
  if (integrationMode === 2) {
237
- // CLI-direct: skip MCP registration; the native plugin owns guidance.
238
+ // CLI-direct: skip MCP registration only. The skill, the hooks AND the global
239
+ // instructions block are all still required -- a real Copilot install that skipped the
240
+ // instructions file here left the agent ignoring Empirical for a whole session.
238
241
  const pluginNames = pluginNamesForInstallerOptions(promptOptions);
239
242
  installNativePlugin(target.id, pluginNames, integrationMode);
240
243
  log(` - ${target.label} native plugins installed: ${pluginNames.join(', ')}.`);
244
+ writeCoreMemoryGuidance(target.id, pluginNames);
241
245
  if (target.id === 'claude') {
242
246
  try {
243
247
  installClaudeHooks(pluginNames);
@@ -260,6 +264,7 @@ async function runInstallFlow() {
260
264
  }
261
265
  }
262
266
  log(uiSuccess(`Done: ${target.label} (CLI-direct mode)`));
267
+ addInstalledHosts([target.id]);
263
268
  } else {
264
269
  // Mode 1 or 3: run MCP install normally
265
270
  const pluginNames = pluginNamesForInstallerOptions(promptOptions);
@@ -269,6 +274,7 @@ async function runInstallFlow() {
269
274
  integrationMode
270
275
  });
271
276
  log(uiSuccess(`Done: ${target.label}`));
277
+ addInstalledHosts([target.id]);
272
278
  if (target.id === 'codex') {
273
279
  codexInstalled = true;
274
280
  codexNeedsPostLogin = Boolean(installResult?.needsPostLogin);
@@ -11,7 +11,7 @@ const {
11
11
  const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
12
12
  const { installClaudeHooks } = require('./hooks');
13
13
  const { installNativePlugin, CORE_PLUGIN_NAME } = require('./nativePlugin');
14
- const { buildCoreMemoryBlock, upsertCoreMemoryBlock } = require('./coreMemoryBlock');
14
+ const { writeCoreMemoryGuidance } = require('./coreMemoryBlock');
15
15
 
16
16
  function runClaude(args, options = {}) {
17
17
  return runResolvedCommand('claude', args, options);
@@ -30,14 +30,7 @@ function installClaude(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEF
30
30
  // writes the same kind of identity-framed statement into the one file Claude reads every
31
31
  // session unconditionally, inside a clearly delimited block so any of the user's own content
32
32
  // in CLAUDE.md is left untouched.
33
- try {
34
- const claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
35
- const block = buildCoreMemoryBlock({ pluginNames: installOptions.pluginNames || [CORE_PLUGIN_NAME] });
36
- upsertCoreMemoryBlock(claudeMdPath, block);
37
- log(` - Wrote Empirical core memory guidance to ${claudeMdPath}.`);
38
- } catch (error) {
39
- warn(` - Could not write Empirical core memory guidance to CLAUDE.md: ${error.message}`);
40
- }
33
+ writeCoreMemoryGuidance('claude', installOptions.pluginNames || [CORE_PLUGIN_NAME]);
41
34
 
42
35
  // installNativePlugin throws if the plugin install/update genuinely fails (see
43
36
  // assertPluginInstallSucceeded in nativePlugin.js) - hooks are only installed AFTER this
@@ -11,7 +11,7 @@ const {
11
11
  const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
12
12
  const { installCodexHooks } = require('./hooks');
13
13
  const { installNativePlugin, CORE_PLUGIN_NAME } = require('./nativePlugin');
14
- const { buildCoreMemoryBlock, upsertCoreMemoryBlock } = require('./coreMemoryBlock');
14
+ const { writeCoreMemoryGuidance } = require('./coreMemoryBlock');
15
15
 
16
16
  function runCodex(args, options = {}) {
17
17
  return runResolvedCommand('codex', args, options);
@@ -26,14 +26,7 @@ function installCodex(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFA
26
26
  // 2026-08-24: see claude.js's identical block for why this now writes AGENTS.md instead of
27
27
  // leaving it untouched -- hooks + an installable skill alone were not enough against a
28
28
  // competing identity-framed built-in memory feature in a real session.
29
- try {
30
- const agentsMdPath = path.join(os.homedir(), '.codex', 'AGENTS.md');
31
- const block = buildCoreMemoryBlock({ pluginNames: installOptions.pluginNames || [CORE_PLUGIN_NAME] });
32
- upsertCoreMemoryBlock(agentsMdPath, block);
33
- log(` - Wrote Empirical core memory guidance to ${agentsMdPath}.`);
34
- } catch (error) {
35
- warn(` - Could not write Empirical core memory guidance to AGENTS.md: ${error.message}`);
36
- }
29
+ writeCoreMemoryGuidance('codex', installOptions.pluginNames || [CORE_PLUGIN_NAME]);
37
30
 
38
31
  // installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
39
32
  // nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
@@ -12,7 +12,7 @@ const {
12
12
  const { INSTALL_PROMPT_OPTIONS_DEFAULTS } = require('./promptBlocks');
13
13
  const { installCopilotHooks } = require('./hooks');
14
14
  const { installNativePlugin, CORE_PLUGIN_NAME } = require('./nativePlugin');
15
- const { buildCoreMemoryBlock, upsertCoreMemoryBlock } = require('./coreMemoryBlock');
15
+ const { writeCoreMemoryGuidance } = require('./coreMemoryBlock');
16
16
 
17
17
  function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DEFAULTS, installOptions = {}) {
18
18
  const home = os.homedir();
@@ -65,13 +65,7 @@ function installCopilot(headlessToken, promptOptions = INSTALL_PROMPT_OPTIONS_DE
65
65
  // 2026-08-24: see claude.js's identical block for why this now writes copilot-instructions.md
66
66
  // instead of leaving it untouched -- hooks + an installable skill alone were not enough
67
67
  // against a competing identity-framed built-in memory feature in a real session.
68
- try {
69
- const block = buildCoreMemoryBlock({ pluginNames: installOptions.pluginNames || [CORE_PLUGIN_NAME] });
70
- upsertCoreMemoryBlock(instructionsPath, block);
71
- log(` - Wrote Empirical core memory guidance to ${instructionsPath}.`);
72
- } catch (error) {
73
- warn(` - Could not write Empirical core memory guidance to copilot-instructions.md: ${error.message}`);
74
- }
68
+ writeCoreMemoryGuidance('copilot', installOptions.pluginNames || [CORE_PLUGIN_NAME]);
75
69
 
76
70
  // installNativePlugin throws on genuine failure (see assertPluginInstallSucceeded in
77
71
  // nativePlugin.js) - hooks install only AFTER this succeeds, never before, so a failed/skipped
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { readUtf8IfExists, writeFile, ensureDir } = require('../fsUtils');
4
+ const { log, warn } = require('../ui');
5
+ const os = require('os');
4
6
  const path = require('path');
5
7
  const { CORE_PLUGIN_NAME, OPTIONAL_PLUGIN_NAMES } = require('./nativePlugin');
6
8
 
@@ -88,9 +90,48 @@ function upsertCoreMemoryBlock(filePath, block) {
88
90
  writeFile(filePath, next);
89
91
  }
90
92
 
93
+ // The one instructions file each host reads unconditionally every session. Kept here, next to
94
+ // the block it receives, so every install path (full installer in any mode, `plugin install`)
95
+ // writes to the same place instead of each path re-deriving it.
96
+ const INSTRUCTIONS_FILE_BY_HOST = {
97
+ claude: ['.claude', 'CLAUDE.md'],
98
+ codex: ['.codex', 'AGENTS.md'],
99
+ copilot: ['.copilot', 'copilot-instructions.md']
100
+ };
101
+
102
+ function instructionsFilePathForHost(hostId) {
103
+ const segments = INSTRUCTIONS_FILE_BY_HOST[hostId];
104
+ if (!segments) throw new Error(`No global instructions file is known for host "${hostId}".`);
105
+ return path.join(os.homedir(), ...segments);
106
+ }
107
+
108
+ /**
109
+ * Writes (or refreshes) the core memory block in the host's global instructions file. Every
110
+ * install path must call this, whatever integration mode the user picked: a real CLI-direct
111
+ * Copilot install (2026-09-17) installed the skill and hooks but skipped this file, and the
112
+ * agent ignored Empirical for the whole session exactly as the 2026-08-24 incident predicted.
113
+ * Never throws; a failure here is logged and must not fail the install that already succeeded.
114
+ * Returns the path written, or null on failure.
115
+ */
116
+ function writeCoreMemoryGuidance(hostId, pluginNames = [CORE_PLUGIN_NAME]) {
117
+ let filePath = null;
118
+ try {
119
+ filePath = instructionsFilePathForHost(hostId);
120
+ const block = buildCoreMemoryBlock({ pluginNames: pluginNames && pluginNames.length ? pluginNames : [CORE_PLUGIN_NAME] });
121
+ upsertCoreMemoryBlock(filePath, block);
122
+ log(` - Wrote Empirical core memory guidance to ${filePath}.`);
123
+ return filePath;
124
+ } catch (error) {
125
+ warn(` - Could not write Empirical core memory guidance for ${hostId}${filePath ? ` (${filePath})` : ''}: ${error.message}`);
126
+ return null;
127
+ }
128
+ }
129
+
91
130
  module.exports = {
92
131
  BEGIN_MARKER,
93
132
  END_MARKER,
94
133
  buildCoreMemoryBlock,
95
- upsertCoreMemoryBlock
134
+ upsertCoreMemoryBlock,
135
+ instructionsFilePathForHost,
136
+ writeCoreMemoryGuidance
96
137
  };
@@ -8,33 +8,49 @@ const { installCodex } = require('./codex');
8
8
  const { installCopilot } = require('./copilot');
9
9
  const { installClaude } = require('./claude');
10
10
 
11
+ // A host counts as detected when its binary is on PATH OR its home dotfolder exists. The
12
+ // dotfolder alone is a weak signal (another tool, an uninstalled CLI, or a desktop app can leave
13
+ // one behind), so each target also carries `onPath` and `configDirExists` separately; doctor uses
14
+ // them to say "found only a folder, nothing to check" instead of failing the host.
11
15
  function detectInstallTargets() {
12
16
  const home = os.homedir();
13
17
  const targets = [];
14
18
 
15
- const codexDetected = commandExists('codex') || fs.existsSync(path.join(home, '.codex'));
16
- if (codexDetected) {
19
+ const codexOnPath = commandExists('codex');
20
+ const codexDirExists = fs.existsSync(path.join(home, '.codex'));
21
+ if (codexOnPath || codexDirExists) {
17
22
  targets.push({
18
23
  id: 'codex',
19
24
  label: 'Codex CLI',
25
+ onPath: codexOnPath,
26
+ configDirExists: codexDirExists,
27
+ configDir: path.join(home, '.codex'),
20
28
  install: installCodex
21
29
  });
22
30
  }
23
31
 
24
- const copilotDetected = commandExists('copilot') || fs.existsSync(path.join(home, '.copilot'));
25
- if (copilotDetected) {
32
+ const copilotOnPath = commandExists('copilot');
33
+ const copilotDirExists = fs.existsSync(path.join(home, '.copilot'));
34
+ if (copilotOnPath || copilotDirExists) {
26
35
  targets.push({
27
36
  id: 'copilot',
28
37
  label: 'GitHub Copilot CLI',
38
+ onPath: copilotOnPath,
39
+ configDirExists: copilotDirExists,
40
+ configDir: path.join(home, '.copilot'),
29
41
  install: installCopilot
30
42
  });
31
43
  }
32
44
 
33
- const claudeDetected = commandExists('claude') || fs.existsSync(path.join(home, '.claude'));
34
- if (claudeDetected) {
45
+ const claudeOnPath = commandExists('claude');
46
+ const claudeDirExists = fs.existsSync(path.join(home, '.claude'));
47
+ if (claudeOnPath || claudeDirExists) {
35
48
  targets.push({
36
49
  id: 'claude',
37
50
  label: 'Claude CLI',
51
+ onPath: claudeOnPath,
52
+ configDirExists: claudeDirExists,
53
+ configDir: path.join(home, '.claude'),
38
54
  install: installClaude
39
55
  });
40
56
  }
@@ -85,7 +85,9 @@ function runMemoryQuery(args) {
85
85
  appendLog('resolved direct entry point: ' + entryPoint);
86
86
  return spawnSync(process.execPath, [entryPoint].concat(args), {
87
87
  encoding: 'utf8',
88
- timeout: 10000,
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,
89
91
  shell: false
90
92
  });
91
93
  }
@@ -97,7 +99,7 @@ function runMemoryQuery(args) {
97
99
  appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
98
100
  return spawnSync('empirical', args, {
99
101
  encoding: 'utf8',
100
- timeout: 10000,
102
+ timeout: 20000,
101
103
  shell: process.platform === 'win32'
102
104
  });
103
105
  }
@@ -310,7 +312,9 @@ function runMemoryQuery(args) {
310
312
  appendLog('resolved direct entry point: ' + entryPoint);
311
313
  return spawnSync(process.execPath, [entryPoint].concat(args), {
312
314
  encoding: 'utf8',
313
- timeout: 10000,
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,
314
318
  shell: false
315
319
  });
316
320
  }
@@ -318,7 +322,7 @@ function runMemoryQuery(args) {
318
322
  appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
319
323
  return spawnSync('empirical', args, {
320
324
  encoding: 'utf8',
321
- timeout: 10000,
325
+ timeout: 20000,
322
326
  shell: process.platform === 'win32'
323
327
  });
324
328
  }
@@ -552,7 +556,9 @@ function runMemoryQuery(args) {
552
556
  appendLog('resolved direct entry point: ' + entryPoint);
553
557
  return spawnSync(process.execPath, [entryPoint].concat(args), {
554
558
  encoding: 'utf8',
555
- timeout: 10000,
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,
556
562
  shell: false
557
563
  });
558
564
  }
@@ -560,7 +566,7 @@ function runMemoryQuery(args) {
560
566
  appendLog('direct entry point not resolved, falling back to shell invocation (shell=' + (process.platform === 'win32') + ')');
561
567
  return spawnSync('empirical', args, {
562
568
  encoding: 'utf8',
563
- timeout: 10000,
569
+ timeout: 20000,
564
570
  shell: process.platform === 'win32'
565
571
  });
566
572
  }
@@ -23,19 +23,119 @@ function runPluginCommand(command, args, options = {}) {
23
23
  return runResolvedCommand(command, args, { stdio: 'inherit', ...options });
24
24
  }
25
25
 
26
+ // Install/update commands are run with their output captured (and echoed back so the user still
27
+ // sees the host CLI's own progress) because the failure text is what tells a locked plugin folder
28
+ // apart from a real install failure -- see installOneOrKeepExisting below.
29
+ function runPluginInstallCommand(command, args) {
30
+ const result = runResolvedCommand(command, args, { stdio: 'pipe' });
31
+ if (result && result.stdout) process.stdout.write(result.stdout);
32
+ if (result && result.stderr) process.stderr.write(result.stderr);
33
+ return result;
34
+ }
35
+
26
36
  function assertPluginInstallSucceeded(agentId, result) {
27
37
  if (result && result.status === 0) return;
28
38
  const detail = result ? summarizeProcessOutput(result) : 'no process result';
29
39
  throw new Error(`${agentId} plugin installation failed.${detail ? ` Details: ${detail}` : ''}`);
30
40
  }
31
41
 
42
+ // ─── Locked plugin folder (Windows) ───────────────────────────────────────────
43
+ // Reproduced 2026-09-17: on Windows, a host CLI replaces an installed plugin by renaming its
44
+ // folder, and Windows refuses to rename a folder any process has open. One open handle -- a
45
+ // running Copilot CLI session, or the Copilot extension in VS Code watching that folder -- is
46
+ // enough, and the host reports it as "Access is denied. (os error 5)". Users read that as a
47
+ // permissions problem and reach for admin rights, which does nothing. Upstream: copilot-cli
48
+ // #4151 / #4095. So when an install fails that way:
49
+ // - if the plugin is already installed, keep it and carry on (the rest of the install -- the
50
+ // instructions block, the hooks -- must not be held hostage to a refresh), and say so;
51
+ // - if it is not installed, fail with the one instruction that fixes it, naming the programs
52
+ // we can see running, instead of the host CLI's bare "Access is denied".
53
+ const LOCKED_FOLDER_PATTERN = /access is denied|os error 5\b|EBUSY|EPERM|being used by another process/i;
54
+
55
+ // Process names (as Windows reports them) that are known to hold plugin folders open per host.
56
+ const LOCK_HOLDER_PROCESS_NAMES = {
57
+ copilot: ['copilot', 'Code'],
58
+ claude: ['claude', 'Code'],
59
+ codex: ['codex', 'Code']
60
+ };
61
+
62
+ const LOCK_HOLDER_LABELS = {
63
+ copilot: 'GitHub Copilot CLI',
64
+ claude: 'Claude Code',
65
+ codex: 'Codex CLI',
66
+ Code: 'VS Code'
67
+ };
68
+
69
+ function looksLikeLockedPluginFolder(result) {
70
+ if (!result) return false;
71
+ const text = `${result.stdout || ''}\n${result.stderr || ''}\n${result.error ? result.error.message || result.error : ''}`;
72
+ return LOCKED_FOLDER_PATTERN.test(text);
73
+ }
74
+
75
+ function detectLockHolders(agentId) {
76
+ if (process.platform !== 'win32') return [];
77
+ const names = LOCK_HOLDER_PROCESS_NAMES[agentId] || [];
78
+ if (!names.length) return [];
79
+ try {
80
+ const { spawnSync } = require('child_process');
81
+ const script = `Get-Process -Name ${names.join(',')} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ProcessName -Unique`;
82
+ const probe = spawnSync('pwsh', ['-NoProfile', '-Command', script], { encoding: 'utf8', timeout: 5000 });
83
+ return String(probe.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
84
+ } catch (_error) {
85
+ return [];
86
+ }
87
+ }
88
+
89
+ function describeLockHolders(agentId, holders) {
90
+ const seen = holders.map((name) => LOCK_HOLDER_LABELS[name] || name);
91
+ const hostLabel = LOCK_HOLDER_LABELS[agentId] || agentId;
92
+ if (seen.length) {
93
+ return `Running now: ${seen.join(', ')}. Close ${seen.length === 1 ? 'it' : 'them'} (every window and terminal session)`;
94
+ }
95
+ return `Close every ${hostLabel} session and every VS Code window`;
96
+ }
97
+
98
+ function currentlyInstalledVersion(agentId, name) {
99
+ try {
100
+ // Lazy: pluginUpdateCheck requires this module, so a top-level require would be a cycle.
101
+ const { readInstalledVersions } = require('../pluginUpdateCheck');
102
+ const versions = readInstalledVersions(agentId, [name]);
103
+ return versions && versions[name] ? versions[name] : null;
104
+ } catch (_error) {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Runs one plugin install and interprets the result. Returns { name, refreshed, keptVersion? }.
111
+ * Throws only for a genuine failure, or for a locked folder when the plugin is not installed at
112
+ * all (there is nothing to keep, and the user must act).
113
+ */
114
+ function installOneOrKeepExisting(agentId, name, runInstall) {
115
+ const result = runInstall();
116
+ if (result && result.status === 0) return { name, refreshed: true };
117
+ if (!looksLikeLockedPluginFolder(result)) assertPluginInstallSucceeded(agentId, result);
118
+
119
+ const holders = detectLockHolders(agentId);
120
+ const action = describeLockHolders(agentId, holders);
121
+ const keptVersion = currentlyInstalledVersion(agentId, name);
122
+ if (keptVersion) {
123
+ warn(` - ${LOCK_HOLDER_LABELS[agentId] || agentId} could not replace the ${name} plugin folder because another program has it open (Windows reported "Access is denied"; this is not a permissions problem). Kept the installed ${name}@${keptVersion} and continued. ${action}, then run: empirical plugin install ${agentId} to update it.`);
124
+ return { name, refreshed: false, keptVersion, lockedBy: holders };
125
+ }
126
+
127
+ throw new Error(
128
+ `${agentId} plugin installation failed: ${LOCK_HOLDER_LABELS[agentId] || agentId} could not write the ${name} plugin folder because another program has it open `
129
+ + `(Windows reported "Access is denied"; running as administrator will not help). ${action}, then run: empirical install`
130
+ );
131
+ }
132
+
32
133
  function updateOrInstall(agentId, command, updateArgs, installArgs) {
33
- const updateResult = runPluginCommand(command, updateArgs);
34
- if (updateResult && updateResult.status === 0) return updateResult;
134
+ const updateResult = runPluginInstallCommand(command, updateArgs);
135
+ if (updateResult && updateResult.status === 0) return { refreshed: true };
35
136
 
36
- const installResult = runPluginCommand(command, installArgs);
37
- assertPluginInstallSucceeded(agentId, installResult);
38
- return installResult;
137
+ const name = String(installArgs[2] || '').split('@')[0];
138
+ return installOneOrKeepExisting(agentId, name, () => runPluginInstallCommand(command, installArgs));
39
139
  }
40
140
 
41
141
  function normalizePluginNames(pluginNames = [CORE_PLUGIN_NAME]) {
@@ -92,21 +192,23 @@ function installNativePlugin(agentId, pluginNames = [CORE_PLUGIN_NAME], integrat
92
192
  applySkillModeVariant(agentId, resolvedIntegrationMode);
93
193
  };
94
194
 
195
+ const outcomes = [];
196
+
95
197
  if (agentId === 'claude') {
96
198
  // Marketplace registration is intentionally best-effort: native Claude returns non-zero
97
199
  // when the same Git marketplace is already registered, but the install command is still
98
200
  // authoritative and idempotent.
99
201
  runPluginCommand('claude', ['plugin', 'marketplace', 'add', EMPIRICAL_PLUGIN_SOURCE], { stdio: 'pipe' });
100
202
  for (const name of names) {
101
- updateOrInstall(
203
+ outcomes.push(updateOrInstall(
102
204
  agentId,
103
205
  'claude',
104
206
  ['plugin', 'update', `${name}@empirical-memory`, '--scope', 'user'],
105
207
  ['plugin', 'install', name, '--scope', 'user']
106
- );
208
+ ));
107
209
  }
108
210
  applyVariantIfCore();
109
- return;
211
+ return { outcomes };
110
212
  }
111
213
 
112
214
  if (agentId === 'codex') {
@@ -114,11 +216,10 @@ function installNativePlugin(agentId, pluginNames = [CORE_PLUGIN_NAME], integrat
114
216
  // Refresh the Git marketplace snapshot before reinstalling an existing plugin.
115
217
  runPluginCommand('codex', ['plugin', 'marketplace', 'upgrade', 'empirical-memory'], { stdio: 'pipe' });
116
218
  for (const name of names) {
117
- const result = runPluginCommand('codex', ['plugin', 'add', `${name}@empirical-memory`]);
118
- assertPluginInstallSucceeded(agentId, result);
219
+ outcomes.push(installOneOrKeepExisting(agentId, name, () => runPluginInstallCommand('codex', ['plugin', 'add', `${name}@empirical-memory`])));
119
220
  }
120
221
  applyVariantIfCore();
121
- return;
222
+ return { outcomes };
122
223
  }
123
224
 
124
225
  if (agentId === 'copilot') {
@@ -127,11 +228,10 @@ function installNativePlugin(agentId, pluginNames = [CORE_PLUGIN_NAME], integrat
127
228
  runPluginCommand('copilot', ['plugin', 'marketplace', 'add', EMPIRICAL_PLUGIN_SOURCE], { stdio: 'pipe' });
128
229
  runPluginCommand('copilot', ['plugin', 'marketplace', 'update', 'empirical-memory'], { stdio: 'pipe' });
129
230
  for (const name of names) {
130
- const result = runPluginCommand('copilot', ['plugin', 'install', `${name}@empirical-memory`]);
131
- assertPluginInstallSucceeded(agentId, result);
231
+ outcomes.push(installOneOrKeepExisting(agentId, name, () => runPluginInstallCommand('copilot', ['plugin', 'install', `${name}@empirical-memory`])));
132
232
  }
133
233
  applyVariantIfCore();
134
- return;
234
+ return { outcomes };
135
235
  }
136
236
 
137
237
  throw new Error(`Unsupported plugin target: ${agentId}`);
@@ -143,5 +243,6 @@ module.exports = {
143
243
  OPTIONAL_PLUGIN_NAMES,
144
244
  normalizePluginNames,
145
245
  installNativePlugin,
146
- applySkillModeVariant
246
+ applySkillModeVariant,
247
+ looksLikeLockedPluginFolder
147
248
  };
@@ -7,6 +7,8 @@ const {
7
7
  OPTIONAL_PLUGIN_NAMES = ['empirical-work-history', 'empirical-scar-tissue', 'empirical-todo']
8
8
  } = require('./installers/nativePlugin');
9
9
  const { checkNativePluginUpdates } = require('./pluginUpdateCheck');
10
+ const { addInstalledHosts } = require('./deviceConfig');
11
+ const { writeCoreMemoryGuidance } = require('./installers/coreMemoryBlock');
10
12
 
11
13
  const SUPPORTED_TARGETS = ['codex', 'copilot', 'claude'];
12
14
 
@@ -68,6 +70,15 @@ async function runPluginCommand(args = []) {
68
70
  if (pluginNames.length === 1) installNativePlugin(target);
69
71
  else installNativePlugin(target, pluginNames);
70
72
  log(uiSuccess(`${target} plugins installed through its marketplace: ${pluginNames.join(', ')}.`));
73
+ // The skill alone does not make an agent use Empirical; the identity-framed block in the
74
+ // host's global instructions file does. Write it on this path too, not only in `install`.
75
+ writeCoreMemoryGuidance(target, pluginNames);
76
+ // Record the host so `empirical doctor` checks it, and only it, from now on.
77
+ try {
78
+ addInstalledHosts([target]);
79
+ } catch (_error) {
80
+ // Device-config bookkeeping must never fail a plugin install that already succeeded.
81
+ }
71
82
  }
72
83
  log('');
73
84
  log('Native plugin installation does not configure MCP or credentials.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@empirical-gauzza/cli",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "Persistent cross-tool memory for AI agents. Give Claude Code, Codex, Copilot, Cursor, and ChatGPT one shared memory: save project context once, recall it everywhere via CLI or MCP.",
5
5
  "keywords": [
6
6
  "memory",