memtrace-skills 1.2.11 → 1.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ export interface InstallOptions {
3
3
  only?: string[];
4
4
  skipMcp?: boolean;
5
5
  yes?: boolean;
6
+ mode?: 'refresh';
6
7
  }
7
8
  export declare function resolveMemtraceBinary(): Promise<string | null>;
8
9
  export declare function runInstall(options: InstallOptions): Promise<void>;
@@ -48,7 +48,7 @@ export async function runInstall(options) {
48
48
  console.error(' Error: No skills found. Ensure skills/ directory exists.\n');
49
49
  process.exit(1);
50
50
  }
51
- const memtraceBin = await resolveMemtraceBinary();
51
+ const memtraceBin = options.mode === 'refresh' ? '' : await resolveMemtraceBinary();
52
52
  if (memtraceBin === null) {
53
53
  console.warn(' ⚠ memtrace binary not found on PATH.');
54
54
  console.warn(' MCP registration will be skipped for every agent.\n');
@@ -58,7 +58,8 @@ export async function runInstall(options) {
58
58
  scope: options.scope,
59
59
  cwd: process.cwd(),
60
60
  memtraceBinary: memtraceBin ?? '',
61
- skipMcp: options.skipMcp || memtraceBin === null,
61
+ skipMcp: options.mode === 'refresh' || options.skipMcp || memtraceBin === null,
62
+ mode: options.mode,
62
63
  };
63
64
  for (const t of transformers) {
64
65
  console.log(` [${t.name}] installing...`);
package/dist/index.js CHANGED
@@ -30,6 +30,15 @@ program
30
30
  };
31
31
  await runInstall(options);
32
32
  });
33
+ program
34
+ .command('refresh')
35
+ .description('Update skills without registering MCP servers or enabling plugins and hooks')
36
+ .option('--only <agents>', 'comma-separated agent names', parseOnly)
37
+ .option('--local', 'refresh project-scoped skills', false)
38
+ .option('-y, --yes', 'non-interactive')
39
+ .action(async (opts) => {
40
+ await runInstall({ scope: opts.local ? 'local' : 'global', only: opts.only, yes: opts.yes, mode: 'refresh' });
41
+ });
33
42
  program
34
43
  .command('doctor')
35
44
  .description('Check memtrace install health across all agents')
package/dist/rail-hook.js CHANGED
@@ -5,7 +5,7 @@ import http from 'node:http';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { activeRailRuntimes, enrichEnabled, pidAlive, runtimeForCwd, ENRICH_HOOK_KILL_MS, SEARCH_COMMAND_RE, } from './rail-runtime.js';
8
+ import { activeRailRuntimes, bindSearch, enrichEnabled, pidAlive, ENRICH_HOOK_KILL_MS, SEARCH_COMMAND_RE, } from './rail-runtime.js';
9
9
  const argv = process.argv;
10
10
  function arg(name) {
11
11
  const index = argv.indexOf(name);
@@ -132,19 +132,34 @@ async function main() {
132
132
  if (typeof cwd !== 'string')
133
133
  return;
134
134
  // Leases are read ONCE per call.
135
- const runtime = runtimeForCwd(cwd, activeRailRuntimes());
136
- if (!runtime)
137
- return;
138
- const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
135
+ const runtimes = activeRailRuntimes();
139
136
  if (event === 'precompact') {
137
+ // The dedupe lives in whichever runtime answered this session's
138
+ // searches, which a session above its repositories cannot tell.
140
139
  const session = typeof payload.session_id === 'string' ? payload.session_id : '';
141
- await postToDaemon(runtime, `/api/rail/enrich/reset?${new URLSearchParams({ session })}`, input, ENRICH_HOOK_KILL_MS);
140
+ const owner = bindSearch(payload, cwd, runtimes).runtime;
141
+ await Promise.all((owner ? [owner] : runtimes).map(runtime => postToDaemon(runtime, `/api/rail/enrich/reset?${new URLSearchParams({ session })}`, input, ENRICH_HOOK_KILL_MS)));
142
+ return;
143
+ }
144
+ const bound = bindSearch(payload, cwd, runtimes);
145
+ const runtime = bound.runtime;
146
+ if (!runtime) {
147
+ // Say why nothing was added: Memtrace runs, but for other folders. With
148
+ // nothing running at all the call stays silent and writes nothing.
149
+ if (phase === 'post' && runtimes.length) {
150
+ logHookTiming({ ts: new Date().toISOString(), host, phase: 'post', tool: payload.tool_name ?? null,
151
+ status: 0, outcome: 'no_runtime_for_cwd', bytes: 0, stdout_bytes: 0,
152
+ server_ms: 0, hook_ms: Date.now() - started });
153
+ }
142
154
  return;
143
155
  }
156
+ // The daemon resolves the repository from `cwd`: send the one bound.
157
+ const body = bound.cwd === cwd ? input : JSON.stringify({ ...payload, cwd: bound.cwd });
158
+ const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
144
159
  if (phase === 'post') {
145
160
  const query = new URLSearchParams({ host, phase: 'post', enrich: env.MEMTRACE_ENRICH || '',
146
161
  mode: env.MEMTRACE_RAIL || '', escape: env.MEMTRACE_RAW_SEARCH || '' });
147
- const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, input, ENRICH_HOOK_KILL_MS);
162
+ const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, body, ENRICH_HOOK_KILL_MS);
148
163
  let wrote = 0;
149
164
  let malformed = false;
150
165
  if (result.status === 200 && result.body && alive()) {
@@ -172,7 +187,7 @@ async function main() {
172
187
  if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
173
188
  const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '', enrich: env.MEMTRACE_ENRICH || '',
174
189
  escape: env.MEMTRACE_RAW_SEARCH || '' });
175
- const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, input, 5500);
190
+ const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, body, 5500);
176
191
  if (result.status === 200 && result.body && alive()) {
177
192
  JSON.parse(result.body); // never emit malformed endpoint output into an agent
178
193
  process.stdout.write(result.body);
@@ -180,7 +195,7 @@ async function main() {
180
195
  return;
181
196
  }
182
197
  const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
183
- cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
198
+ cwd: bound.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
184
199
  env: { ...env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
185
200
  });
186
201
  let output = '';
@@ -190,7 +205,7 @@ async function main() {
190
205
  child.stderr.on('data', chunk => { if (stderr.length < 2_000_000)
191
206
  stderr += chunk; });
192
207
  child.stdin.on('error', () => { });
193
- child.stdin.end(input);
208
+ child.stdin.end(body);
194
209
  const timer = setTimeout(() => child.kill(), 5500);
195
210
  // If the daemon exits while a request is in flight, discard the response.
196
211
  const monitor = setInterval(() => { if (!alive())
@@ -14,6 +14,48 @@ const port = Number(arg('--port'));
14
14
  const binary = arg('--binary');
15
15
  const cwd = arg('--cwd');
16
16
  const routeBinary = arg('--route-binary') || undefined;
17
+ /** Every repository the owner serves (`--root`, repeated). */
18
+ const roots = process.argv.flatMap((value, index) => process.argv[index - 1] === '--root' && path.isAbsolute(value) ? [value] : []);
19
+ const settingsFile = arg('--settings-file');
20
+ /** Saved Settings keys that decide which hooks are registered. */
21
+ const SETTINGS_KEYS = ['MEMTRACE_RAIL', 'MEMTRACE_ENRICH'];
22
+ const startupEnv = Object.fromEntries(SETTINGS_KEYS.map(key => [key, process.env[key]]));
23
+ /**
24
+ * Follow a Settings change while the owner runs. The daemon handed down the
25
+ * values it applied from the settings file (MEMTRACE_SETTINGS_APPLIED); those
26
+ * - and unset keys - track the file, while a variable the user set in the
27
+ * environment still wins, as everywhere else.
28
+ */
29
+ function followSavedSettings() {
30
+ if (!settingsFile)
31
+ return;
32
+ let applied = {};
33
+ try {
34
+ applied = JSON.parse(process.env.MEMTRACE_SETTINGS_APPLIED || '{}') ?? {};
35
+ }
36
+ catch { /* none applied */ }
37
+ let saved;
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
40
+ saved = parsed && typeof parsed.values === 'object' && parsed.values ? parsed.values : {};
41
+ }
42
+ catch (error) {
43
+ // A file being rewritten keeps the current choice; a deleted one is "nothing saved".
44
+ if (error.code !== 'ENOENT')
45
+ return;
46
+ saved = {};
47
+ }
48
+ for (const key of SETTINGS_KEYS) {
49
+ const initial = startupEnv[key];
50
+ if (initial !== undefined && applied[key] !== initial)
51
+ continue;
52
+ const value = saved[key];
53
+ if (typeof value === 'string' && value.trim())
54
+ process.env[key] = value;
55
+ else
56
+ delete process.env[key];
57
+ }
58
+ }
17
59
  async function main() {
18
60
  if (process.argv.includes('--sync')) {
19
61
  const live = activeRailRuntimes();
@@ -41,10 +83,11 @@ async function main() {
41
83
  stop();
42
84
  return;
43
85
  }
44
- writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, binary, port, routeBinary });
86
+ writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, roots, binary, port, routeBinary });
87
+ followSavedSettings();
45
88
  const enabled = !railDisabled();
46
89
  // Grep enrichment and the Rail mode decide WHICH hooks are registered,
47
- // so a rail.json change to either re-wires them without a restart.
90
+ // so a rail.json or Settings change to either re-wires them without a restart.
48
91
  const signature = enabled ? `on:${enrichEnabled()}:${railMode()}` : 'off';
49
92
  if (signature === wired)
50
93
  return;
@@ -7,6 +7,11 @@ export interface RailRuntime {
7
7
  port: number;
8
8
  /** Local development override; packaged hooks normally use the daemon HTTP API. */
9
9
  routeBinary?: string;
10
+ /**
11
+ * Repository roots the runtime serves besides `cwd` (its workspace
12
+ * members), so a session in a member it was not started in still reaches it.
13
+ */
14
+ roots?: string[];
10
15
  }
11
16
  export declare const LEASE_MAX_AGE_MS = 3000;
12
17
  export declare const LEASE_REFRESH_INTERVAL_MS = 1000;
@@ -47,7 +52,37 @@ export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig):
47
52
  */
48
53
  export declare function activeRailRuntimes(): RailRuntime[];
49
54
  export declare function railIsActive(): boolean;
55
+ /** The live runtime whose start folder or served repository contains `cwd`; the innermost wins. */
50
56
  export declare function runtimeForCwd(cwd: string, runtimes?: RailRuntime[]): RailRuntime | undefined;
57
+ /** The git checkout containing `start` (a directory or a file), if any. */
58
+ export declare function gitRootOf(start: string): string | undefined;
59
+ /**
60
+ * A linked worktree (`git worktree add ../x`) to its main checkout, through
61
+ * `.git` -> gitdir -> commondir. Anything else (a normal checkout, a
62
+ * submodule, an unreadable file) is returned unchanged.
63
+ */
64
+ export declare function mainCheckout(root: string): string;
65
+ /**
66
+ * Where a search looked, from the search itself: the Grep path, a leading
67
+ * `cd`, absolute path arguments, and the files its results name. Existing
68
+ * paths only, resolved against the session's working directory.
69
+ */
70
+ export declare function searchTargets(payload: any, cwd: string): string[];
71
+ /**
72
+ * Which runtime answers a search, and the directory to send it as `cwd`.
73
+ *
74
+ * The session's cwd alone misses common layouts: an orchestrator (and every
75
+ * subagent, which keeps its cwd) opened in a folder ABOVE the repositories, a
76
+ * daemon started in a repository's subfolder, and a linked worktree beside
77
+ * the main checkout. A session inside a covered repository keeps the plain
78
+ * cwd match; otherwise the repository the search itself names is bound, by
79
+ * its git root or its worktree's main checkout, and sent as the cwd so the
80
+ * daemon resolves that repository.
81
+ */
82
+ export declare function bindSearch(payload: any, cwd: string, runtimes: RailRuntime[]): {
83
+ runtime?: RailRuntime;
84
+ cwd: string;
85
+ };
51
86
  /**
52
87
  * Exec-form hook invocation. `extra` selects the hook's role: none for the
53
88
  * Rail PreToolUse router, `--phase post` for grep enrichment, `--event
@@ -63,7 +63,7 @@ export const ENRICH_HOOK_KILL_MS = 600;
63
63
  * searches worth a loopback round trip (design 4.1). `git tag v1` must not
64
64
  * match `ag`, so a program must start a word.
65
65
  */
66
- export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep)(\s|$)/;
66
+ export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep|select-string|sls|findstr(?:\.exe)?)(\s|$)/i;
67
67
  /** `~/.memtrace/rail.json` (or `MEMTRACE_RAIL_CONFIG`); `{}` when absent or unreadable. */
68
68
  export function railConfig() {
69
69
  const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
@@ -146,6 +146,10 @@ export function activeRailRuntimes() {
146
146
  || typeof runtime.cwd !== 'string' || !path.isAbsolute(runtime.cwd)
147
147
  || !Number.isInteger(runtime.port) || runtime.port < 1 || runtime.port > 65535)
148
148
  return [];
149
+ // Older leases have no roots; a malformed entry never widens a lease.
150
+ runtime.roots = Array.isArray(runtime.roots)
151
+ ? runtime.roots.filter(root => typeof root === 'string' && path.isAbsolute(root))
152
+ : [];
149
153
  return [runtime];
150
154
  }
151
155
  catch {
@@ -164,16 +168,136 @@ function normalizedWorkspacePath(directory) {
164
168
  resolved = fs.realpathSync.native(resolved);
165
169
  }
166
170
  catch { /* Unavailable paths retain the existing lexical matching behavior. */ }
167
- const normalized = resolved.replace(/\\/g, '/').replace(/\/$/, '');
171
+ // A verbatim `\\?\C:\x` (a canonical Windows path) is the same folder as `C:\x`.
172
+ const normalized = resolved.replace(/\\/g, '/').replace(/^\/\/\?\/(UNC\/)?/i, (_m, unc) => unc ? '//' : '')
173
+ .replace(/\/$/, '');
168
174
  return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
169
175
  }
176
+ function leaseRoots(runtime) {
177
+ return [runtime.cwd, ...(runtime.roots ?? [])];
178
+ }
179
+ /** The live runtime whose start folder or served repository contains `cwd`; the innermost wins. */
170
180
  export function runtimeForCwd(cwd, runtimes = activeRailRuntimes()) {
171
181
  const target = normalizedWorkspacePath(cwd);
172
182
  return runtimes
173
- .map(runtime => ({ runtime, root: normalizedWorkspacePath(runtime.cwd) }))
174
- .filter(({ root }) => target === root || target.startsWith(root + '/'))
183
+ .map(runtime => ({ runtime, root: leaseRoots(runtime).map(normalizedWorkspacePath)
184
+ .filter(root => target === root || target.startsWith(root + '/'))
185
+ .sort((a, b) => b.length - a.length)[0] ?? '' }))
186
+ .filter(({ root }) => root !== '')
175
187
  .sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
176
188
  }
189
+ function samePath(a, b) {
190
+ return normalizedWorkspacePath(a) === normalizedWorkspacePath(b);
191
+ }
192
+ /** The git checkout containing `start` (a directory or a file), if any. */
193
+ export function gitRootOf(start) {
194
+ let dir = path.resolve(start);
195
+ for (let depth = 0; depth < 64; depth++) {
196
+ if (fs.existsSync(path.join(dir, '.git')))
197
+ return dir;
198
+ const parent = path.dirname(dir);
199
+ if (parent === dir)
200
+ return undefined;
201
+ dir = parent;
202
+ }
203
+ return undefined;
204
+ }
205
+ /**
206
+ * A linked worktree (`git worktree add ../x`) to its main checkout, through
207
+ * `.git` -> gitdir -> commondir. Anything else (a normal checkout, a
208
+ * submodule, an unreadable file) is returned unchanged.
209
+ */
210
+ export function mainCheckout(root) {
211
+ try {
212
+ const dotGit = path.join(root, '.git');
213
+ if (!fs.statSync(dotGit).isFile())
214
+ return root;
215
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'))?.[1]?.trim();
216
+ if (!gitdir)
217
+ return root;
218
+ const absoluteGitdir = path.resolve(root, gitdir);
219
+ const common = path.resolve(absoluteGitdir, fs.readFileSync(path.join(absoluteGitdir, 'commondir'), 'utf8').trim());
220
+ return path.basename(common) === '.git' ? path.dirname(common) : root;
221
+ }
222
+ catch {
223
+ return root;
224
+ }
225
+ }
226
+ const CD_RE = /(?:^|&&|;|\|\|)\s*(?:cd|pushd|set-location|sl)\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/i;
227
+ const ABSOLUTE_ARG_RE = /"((?:[A-Za-z]:)?[\\/][^"]+)"|'((?:[A-Za-z]:)?[\\/][^']+)'|(?:^|\s)((?:[A-Za-z]:)?[\\/][^\s"';|&]+)/g;
228
+ const RESULT_LINE_RE = /^((?:[A-Za-z]:)?[^:\r\n]+?):\d+[:-]/;
229
+ /**
230
+ * Where a search looked, from the search itself: the Grep path, a leading
231
+ * `cd`, absolute path arguments, and the files its results name. Existing
232
+ * paths only, resolved against the session's working directory.
233
+ */
234
+ export function searchTargets(payload, cwd) {
235
+ const found = [];
236
+ const add = (value) => {
237
+ if (typeof value !== 'string' || !value.trim() || found.length >= 12)
238
+ return;
239
+ const resolved = path.resolve(cwd, value.trim().replace(/^["']|["']$/g, ''));
240
+ if (!found.some(existing => samePath(existing, resolved)) && fs.existsSync(resolved))
241
+ found.push(resolved);
242
+ };
243
+ const input = payload?.tool_input ?? {};
244
+ add(input.path);
245
+ add(input.file_path);
246
+ const command = typeof input.command === 'string' ? input.command : '';
247
+ const cd = CD_RE.exec(command);
248
+ if (cd)
249
+ add(cd[1] ?? cd[2] ?? cd[3]);
250
+ for (const match of command.matchAll(ABSOLUTE_ARG_RE))
251
+ add(match[1] ?? match[2] ?? match[3]);
252
+ const response = payload?.tool_response ?? payload?.tool_output ?? {};
253
+ if (Array.isArray(response?.filenames))
254
+ response.filenames.slice(0, 5).forEach(add);
255
+ const text = typeof response === 'string' ? response
256
+ : String(response?.stdout ?? response?.content ?? response?.output ?? '');
257
+ for (const line of text.split('\n').slice(0, 20))
258
+ add(RESULT_LINE_RE.exec(line.trim())?.[1]);
259
+ return found;
260
+ }
261
+ /**
262
+ * Which runtime answers a search, and the directory to send it as `cwd`.
263
+ *
264
+ * The session's cwd alone misses common layouts: an orchestrator (and every
265
+ * subagent, which keeps its cwd) opened in a folder ABOVE the repositories, a
266
+ * daemon started in a repository's subfolder, and a linked worktree beside
267
+ * the main checkout. A session inside a covered repository keeps the plain
268
+ * cwd match; otherwise the repository the search itself names is bound, by
269
+ * its git root or its worktree's main checkout, and sent as the cwd so the
270
+ * daemon resolves that repository.
271
+ */
272
+ export function bindSearch(payload, cwd, runtimes) {
273
+ const direct = runtimeForCwd(cwd, runtimes);
274
+ const cwdRoot = gitRootOf(cwd);
275
+ if (direct && cwdRoot)
276
+ return { runtime: direct, cwd };
277
+ const forRepository = (root) => {
278
+ const main = mainCheckout(root);
279
+ const runtime = runtimeForCwd(root, runtimes) ?? runtimeForCwd(main, runtimes)
280
+ ?? runtimes.find(candidate => leaseRoots(candidate).some(leaseRoot => {
281
+ const leaseRepository = gitRootOf(leaseRoot);
282
+ return leaseRepository !== undefined && (samePath(leaseRepository, root) || samePath(leaseRepository, main));
283
+ }));
284
+ return { main, runtime };
285
+ };
286
+ for (const target of searchTargets(payload, cwd)) {
287
+ const root = gitRootOf(target);
288
+ if (!root)
289
+ continue;
290
+ const { main, runtime } = forRepository(root);
291
+ if (runtime)
292
+ return { runtime, cwd: main };
293
+ }
294
+ if (cwdRoot) {
295
+ const { main, runtime } = forRepository(cwdRoot);
296
+ if (runtime)
297
+ return { runtime, cwd: samePath(main, cwdRoot) ? cwd : path.join(main, path.relative(cwdRoot, cwd)) };
298
+ }
299
+ return { runtime: direct, cwd };
300
+ }
177
301
  /**
178
302
  * Exec-form hook invocation. `extra` selects the hook's role: none for the
179
303
  * Rail PreToolUse router, `--phase post` for grep enrichment, `--event
@@ -62,6 +62,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
62
62
  'get_codebase_briefing',
63
63
  'get_daily_briefing',
64
64
  'get_directory_tree',
65
+ 'get_env_contract',
65
66
  'get_episode_replay',
66
67
  'get_evolution',
67
68
  'get_function_quality_metrics',
@@ -88,7 +88,9 @@ export declare function claudeUserConfigFile(): string;
88
88
  * 2. Fall back to manual: write cache files + update settings.json
89
89
  * 3. Register MCP server for memtrace tools
90
90
  */
91
- export declare function installClaudePlugin(skills: Skill[], memtraceBinaryPath: string): Promise<{
91
+ export declare function installClaudePlugin(skills: Skill[], memtraceBinaryPath: string, options?: {
92
+ skipMcp?: boolean;
93
+ }): Promise<{
92
94
  cacheDir: string;
93
95
  skillCount: number;
94
96
  }>;
@@ -554,7 +554,7 @@ export function claudeUserConfigFile() {
554
554
  * 2. Fall back to manual: write cache files + update settings.json
555
555
  * 3. Register MCP server for memtrace tools
556
556
  */
557
- export async function installClaudePlugin(skills, memtraceBinaryPath) {
557
+ export async function installClaudePlugin(skills, memtraceBinaryPath, options = {}) {
558
558
  // Retire known stock bare copies when moving to plugin delivery. Customized
559
559
  // strict policies/opt-outs survive; names alone never prove ownership.
560
560
  removeManagedSkills(path.join(os.homedir(), '.claude', 'skills'));
@@ -597,12 +597,39 @@ export async function installClaudePlugin(skills, memtraceBinaryPath) {
597
597
  }
598
598
  enablePluginInSettings();
599
599
  // Step 6: Register MCP server
600
- await registerMcpServer(memtraceBinaryPath);
600
+ if (!options.skipMcp)
601
+ await registerMcpServer(memtraceBinaryPath);
601
602
  // Skills ship via the memtrace-skills@memtrace plugin only. Writing
602
603
  // bare copies to ~/.claude/skills/memtrace-* duplicates every skill
603
604
  // in Claude Code when the plugin is also enabled (issue #11).
604
605
  return { cacheDir, skillCount };
605
606
  }
607
+ function refreshEnabledClaudePlugin(skills) {
608
+ const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
609
+ if (!fs.existsSync(settingsFile))
610
+ return 0;
611
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
612
+ if (settings.enabledPlugins?.[PLUGIN_KEY] !== true)
613
+ return 0;
614
+ const root = getPluginCacheRoot();
615
+ if (!fs.existsSync(root))
616
+ return 0;
617
+ let skillCount = 0;
618
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
619
+ if (!entry.isDirectory())
620
+ continue;
621
+ const cacheDir = path.join(root, entry.name);
622
+ copyBundledPluginAssetsAt(cacheDir);
623
+ for (const skill of skills) {
624
+ for (const asset of transformForClaude(skill)) {
625
+ const name = path.basename(path.dirname(asset.relativePath));
626
+ if (writeManagedSkill(path.join(cacheDir, 'skills'), name, asset.content))
627
+ skillCount++;
628
+ }
629
+ }
630
+ }
631
+ return skillCount;
632
+ }
606
633
  /**
607
634
  * Write skills to ~/.claude/skills/ for SDK-based integrations.
608
635
  */
@@ -677,7 +704,9 @@ export const claudeTransformer = {
677
704
  name: 'claude',
678
705
  async install(skills, ctx) {
679
706
  if (ctx.scope === 'global') {
680
- const { skillCount } = await installClaudePlugin(skills, ctx.memtraceBinary);
707
+ const skillCount = ctx.mode === 'refresh'
708
+ ? refreshEnabledClaudePlugin(skills)
709
+ : (await installClaudePlugin(skills, ctx.memtraceBinary, { skipMcp: ctx.skipMcp })).skillCount;
681
710
  return {
682
711
  agent: 'claude',
683
712
  skillsWritten: skillCount,
@@ -0,0 +1,2 @@
1
+ export declare function hasCodexMemtraceServer(raw: string): boolean;
2
+ export declare function refreshCodexCommand(raw: string, command: string): string;
@@ -0,0 +1,33 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { parse } from 'smol-toml';
3
+ const readConfig = (raw) => parse(raw, { integersAsBigInt: 'asNeeded' });
4
+ function memtraceServer(config) {
5
+ const servers = config.mcp_servers;
6
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers))
7
+ return undefined;
8
+ const server = servers.memtrace;
9
+ return server && typeof server === 'object' && !Array.isArray(server) ? server : undefined;
10
+ }
11
+ export function hasCodexMemtraceServer(raw) {
12
+ return memtraceServer(readConfig(raw)) !== undefined;
13
+ }
14
+ export function refreshCodexCommand(raw, command) {
15
+ const expected = readConfig(raw);
16
+ const server = memtraceServer(expected);
17
+ if (!server || typeof server.command !== 'string' || server.command === command)
18
+ return raw;
19
+ server.command = command;
20
+ let inServer = false;
21
+ const next = raw.split(/(?<=\n)/).map(line => {
22
+ if (/^\s*\[/.test(line)) {
23
+ inServer = /^\s*\[\s*mcp_servers\s*\.\s*(?:memtrace|"memtrace"|'memtrace')\s*\]\s*(?:#.*)?$/.test(line.trimEnd());
24
+ }
25
+ if (!inServer)
26
+ return line;
27
+ return line.replace(/^(\s*command\s*=\s*)(["'])(.*?)\2([ \t]*(?:#.*)?)(\r?\n)?$/, (_match, prefix, _quote, _value, comment, newline) => (`${prefix}${JSON.stringify(command)}${comment}${newline ?? ''}`));
28
+ }).join('');
29
+ if (!isDeepStrictEqual(readConfig(next), expected)) {
30
+ throw new Error('Cannot safely update the Codex MCP command; existing configuration was left unchanged.');
31
+ }
32
+ return next;
33
+ }
@@ -3,6 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
5
5
  import { commandExists, execCommand } from '../utils.js';
6
+ import { hasCodexMemtraceServer, refreshCodexCommand } from './codex-config.js';
6
7
  import { registerSettingsHook, removeSettingsHook, CODEX_MATCHER, } from './rail-hooks.js';
7
8
  const MCP_SERVER_NAME = 'memtrace';
8
9
  const LEGACY_MCP_SERVER_NAME = 'memfleet';
@@ -123,6 +124,12 @@ function extractMemtraceEnvBlock(raw) {
123
124
  }
124
125
  export function registerCodexMcpAt(configFile, binary) {
125
126
  const existing = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf-8') : '';
127
+ if (hasCodexMemtraceServer(existing)) {
128
+ const next = refreshCodexCommand(existing, binary);
129
+ if (next !== existing)
130
+ writeTextAtomic(configFile, next);
131
+ return { registered: true };
132
+ }
126
133
  // Strip leaves the env sub-table intact; split it out so we can reorder it
127
134
  // after the freshly written parent block for readability.
128
135
  const stripped = stripCodexMcpServer(existing);
@@ -144,6 +151,8 @@ export function registerCodexMcpAt(configFile, binary) {
144
151
  if (envBlock)
145
152
  parts.push(envBlock);
146
153
  const next = `${parts.join('\n\n')}\n`;
154
+ if (!hasCodexMemtraceServer(next))
155
+ throw new Error('Codex MCP registration could not be validated.');
147
156
  writeTextAtomic(configFile, next);
148
157
  return { registered: true };
149
158
  }
@@ -172,7 +181,8 @@ export const codexTransformer = {
172
181
  const warnings = [];
173
182
  const mcpConfigPath = codexConfigPath(ctx);
174
183
  if (!ctx.skipMcp) {
175
- const cliRegistered = ctx.scope === 'global' && await tryCodexMcpAdd(ctx.memtraceBinary);
184
+ const cliRegistered = ctx.scope === 'global' && !fs.existsSync(mcpConfigPath)
185
+ && await tryCodexMcpAdd(ctx.memtraceBinary);
176
186
  registerCodexMcpAt(mcpConfigPath, ctx.memtraceBinary);
177
187
  mcpRegistered = true;
178
188
  if (ctx.scope === 'global' && !cliRegistered) {
@@ -182,7 +192,8 @@ export const codexTransformer = {
182
192
  // ─── memtrace-rail ─── Wire the discovery hook into .codex/hooks.json
183
193
  // (PreToolUse, Bash matcher — Codex code discovery runs through the shell).
184
194
  try {
185
- registerSettingsHook(codexRailHooksPath(ctx), ctx.memtraceBinary, 'codex', 'PreToolUse', CODEX_MATCHER);
195
+ if (ctx.mode !== 'refresh')
196
+ registerSettingsHook(codexRailHooksPath(ctx), ctx.memtraceBinary, 'codex', 'PreToolUse', CODEX_MATCHER);
186
197
  }
187
198
  catch (e) {
188
199
  warnings.push(`failed to register Codex Rail hook: ${e.message}`);
@@ -107,7 +107,8 @@ export const cursorTransformer = {
107
107
  // ─── memtrace-rail ─── Wire the Memtrace-first discovery hook (default
108
108
  // observe). Failure must not block the rest of the install.
109
109
  try {
110
- registerCursorRailHook(railHooksPath(ctx), ctx.memtraceBinary);
110
+ if (ctx.mode !== 'refresh')
111
+ registerCursorRailHook(railHooksPath(ctx), ctx.memtraceBinary);
111
112
  }
112
113
  catch (e) {
113
114
  console.warn(`memtrace: failed to register Cursor Rail hook: ${e.message}`);
@@ -46,10 +46,11 @@ export const geminiTransformer = {
46
46
  // (PR #14460) — without them a registered BeforeTool hook NEVER fires, so we
47
47
  // enable both here (only if the user hasn't already set them).
48
48
  try {
49
- registerSettingsHook(settingsPath, ctx.memtraceBinary, 'gemini', 'BeforeTool', GEMINI_MATCHER, {
50
- enableHooks: true,
51
- enableMessageBusIntegration: true,
52
- });
49
+ if (ctx.mode !== 'refresh')
50
+ registerSettingsHook(settingsPath, ctx.memtraceBinary, 'gemini', 'BeforeTool', GEMINI_MATCHER, {
51
+ enableHooks: true,
52
+ enableMessageBusIntegration: true,
53
+ });
53
54
  }
54
55
  catch (e) {
55
56
  warnings.push(`failed to register Gemini Rail hook: ${e.message}`);
@@ -31,13 +31,14 @@ export const opencodeTransformer = {
31
31
  // ─── memtrace-rail ─── Write the in-process discovery plugin (OpenCode
32
32
  // has no stdio hook). Defaults to observe; fail-open. Best effort.
33
33
  try {
34
- const src = openCodePluginSource(ctx.memtraceBinary);
35
- if (railIsActive()) {
36
- writeOpenCodeRailPlugin(ctx, src);
37
- }
38
- else {
39
- for (const pluginPath of openCodeRailPluginPaths(ctx)) {
40
- fs.rmSync(pluginPath, { force: true });
34
+ if (ctx.mode !== 'refresh') {
35
+ if (railIsActive()) {
36
+ writeOpenCodeRailPlugin(ctx, openCodePluginSource(ctx.memtraceBinary));
37
+ }
38
+ else {
39
+ for (const pluginPath of openCodeRailPluginPaths(ctx)) {
40
+ fs.rmSync(pluginPath, { force: true });
41
+ }
41
42
  }
42
43
  }
43
44
  }
@@ -153,7 +153,9 @@ export const piTransformer = {
153
153
  warnings: [`${e.message}`],
154
154
  };
155
155
  }
156
- const result = registerPiPackageAt(settingsFile, packagePath);
156
+ const result = ctx.mode === 'refresh'
157
+ ? { registered: false, packagePath, backupPath: undefined }
158
+ : registerPiPackageAt(settingsFile, packagePath);
157
159
  if (!result.registered && result.backupPath) {
158
160
  warnings.push(`Pi settings.json was malformed; backed up to ${result.backupPath}.`);
159
161
  }
@@ -20,6 +20,8 @@ export interface InstallContext {
20
20
  memtraceBinary: string;
21
21
  /** When true, write skills but skip MCP registration. */
22
22
  skipMcp?: boolean;
23
+ /** Refresh resources without changing integration registrations or activation. */
24
+ mode?: 'refresh';
23
25
  }
24
26
  export interface InstallResult {
25
27
  agent: AgentName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrace-skills",
3
- "version": "1.2.11",
3
+ "version": "1.2.14",
4
4
  "description": "Memtrace skills for AI coding agents — codebase exploration, temporal evolution, impact analysis, and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,12 +17,13 @@
17
17
  "prebuild": "node scripts/copy-skills.js",
18
18
  "build": "tsc",
19
19
  "prepublishOnly": "npm run build",
20
- "test": "vitest run && tsc && node --test tests/runtime/rail-lifecycle.test.mjs tests/runtime/rail-enrich-hook.test.mjs tests/runtime/opencode-rail-plugin.test.mjs",
20
+ "test": "vitest run && tsc && node --test tests/runtime/rail-lifecycle.test.mjs tests/runtime/rail-enrich-hook.test.mjs tests/runtime/opencode-rail-plugin.test.mjs tests/runtime/upgrade-integrations.test.mjs tests/runtime/rail-search-binding.test.mjs",
21
21
  "test:watch": "vitest"
22
22
  },
23
23
  "dependencies": {
24
24
  "commander": "^12.0.0",
25
- "fs-extra": "^11.0.0"
25
+ "fs-extra": "^11.0.0",
26
+ "smol-toml": "1.9.1"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@types/fs-extra": "^11.0.0",
@@ -364,6 +364,17 @@ CALLS/REFERENCES/MAKES_API_CALL. Each result includes `confidence`,
364
364
  | `include_external` | boolean | no | `false` | Include calls to services outside indexed repos |
365
365
  | `repo_id` | string | no | — | Omit for full cross-service topology |
366
366
 
367
+ ### `get_env_contract`
368
+ Lays the environment variables a deployment **sets** (Azure Bicep: Container Apps `env`, App Service `appSettings`, Container Instances `environmentVariables`, the `appsettings` config resource) against the ones the code **reads** (`process.env.X`, `os.getenv`, `System.getenv`, `std::env::var`, ...). Answers `matched`, `read_but_never_set` and `set_but_never_read`.
369
+
370
+ | Field | Type | Required | Default | Notes |
371
+ |---|---|---|---|---|
372
+ | `repo_id` | string | no | — | Omit to compare across the whole workspace — the infrastructure repo and the services it deploys are usually different repos |
373
+ | `name` | string | no | — | Exact variable name: every place it is read and set |
374
+ | `include_platform_provided` | boolean | no | `false` | List `PORT`, `WEBSITE_*`, `NODE_ENV`, ... under `read_but_never_set` instead of setting them apart |
375
+
376
+ Only literal names are found, and only Bicep is read as an infrastructure source today; the answer's `caveats` say so.
377
+
367
378
  ### `link_repositories`
368
379
  Connects already-indexed repos so `get_api_topology` can stitch their HTTP graphs. Parameters vary — see `link_repositories` tool schema via the MCP `list_tools` call.
369
380
 
@@ -408,7 +419,9 @@ No parameters. Returns `{ watches: [{ path, repo_id, branch, started_at, origin
408
419
  | Field | Type | Required | Default |
409
420
  |---|---|---|---|
410
421
  | `repo_id` | string | yes | — |
411
- | `days` | integer | no | all history |
422
+ | `days` | integer | no | as far back as the newest `MEMTRACE_REPLAY_MAX_COMMITS` commits reach (500 unless set; 0 = no limit) |
423
+
424
+ Returns `replay_window`: `full_history` is true only when every reachable commit was read; `stopped_at_max_commits` and its `note` say when the limit left older history unread.
412
425
 
413
426
  ## Session bookends & pre-edit checks
414
427