memtrace-skills 1.2.8 → 1.2.10

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.
Files changed (41) hide show
  1. package/dist/rail-hook.d.ts +7 -1
  2. package/dist/rail-hook.js +176 -57
  3. package/dist/rail-install.js +3 -6
  4. package/dist/rail-lifecycle.js +6 -3
  5. package/dist/rail-runtime.d.ts +37 -3
  6. package/dist/rail-runtime.js +79 -7
  7. package/dist/tool-names.js +6 -0
  8. package/dist/transformers/claude.d.ts +14 -2
  9. package/dist/transformers/claude.js +50 -23
  10. package/dist/transformers/opencode.js +6 -6
  11. package/dist/transformers/rail-hooks.d.ts +6 -1
  12. package/dist/transformers/rail-hooks.js +31 -9
  13. package/dist/transformers/shared.d.ts +2 -1
  14. package/dist/transformers/shared.js +22 -10
  15. package/dist/transformers/vscode.js +3 -1
  16. package/package.json +2 -2
  17. package/plugins/memtrace-skills/references/mcp-parameters.md +14 -3
  18. package/plugins/memtrace-skills/skills/memtrace-codebase-exploration/SKILL.md +4 -0
  19. package/plugins/memtrace-skills/skills/memtrace-daily/SKILL.md +4 -0
  20. package/plugins/memtrace-skills/skills/memtrace-first/SKILL.md +14 -8
  21. package/plugins/memtrace-skills/skills/memtrace-graph/SKILL.md +4 -0
  22. package/plugins/memtrace-skills/skills/memtrace-guided-refactor/SKILL.md +88 -0
  23. package/plugins/memtrace-skills/skills/memtrace-impact/SKILL.md +4 -0
  24. package/plugins/memtrace-skills/skills/memtrace-index/SKILL.md +21 -0
  25. package/plugins/memtrace-skills/skills/memtrace-preflight/SKILL.md +4 -0
  26. package/plugins/memtrace-skills/skills/memtrace-quality/SKILL.md +16 -2
  27. package/plugins/memtrace-skills/skills/memtrace-refactoring-guide/SKILL.md +4 -0
  28. package/plugins/memtrace-skills/skills/memtrace-search/SKILL.md +1 -0
  29. package/plugins/memtrace-skills/skills/memtrace-session-continuity/SKILL.md +4 -0
  30. package/skills/commands/memtrace-daily.md +4 -0
  31. package/skills/commands/memtrace-graph.md +4 -0
  32. package/skills/commands/memtrace-impact.md +4 -0
  33. package/skills/commands/memtrace-index.md +22 -0
  34. package/skills/commands/memtrace-preflight.md +4 -0
  35. package/skills/commands/memtrace-quality.md +19 -2
  36. package/skills/commands/memtrace-search.md +1 -0
  37. package/skills/workflows/memtrace-codebase-exploration.md +4 -0
  38. package/skills/workflows/memtrace-first.md +14 -8
  39. package/skills/workflows/memtrace-guided-refactor.md +89 -0
  40. package/skills/workflows/memtrace-refactoring-guide.md +4 -0
  41. package/skills/workflows/memtrace-session-continuity.md +4 -0
@@ -1 +1,7 @@
1
- export {};
1
+ /**
2
+ * Post-phase gate, before any lease read or network call (design 4.1): only a
3
+ * native Grep, or a shell command that runs a search program and did not
4
+ * fail, is worth one loopback POST. Glob, Read and every other shell command
5
+ * exit here.
6
+ */
7
+ export declare function enrichEligible(payload: any): boolean;
package/dist/rail-hook.js CHANGED
@@ -1,77 +1,186 @@
1
1
  /** Cached host hooks stop here when Memtrace is absent. No output and no network. */
2
2
  import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs';
3
4
  import http from 'node:http';
4
- import { railIsActive, runtimeForCwd, pidAlive } from './rail-runtime.js';
5
- async function main() {
6
- if (process.env.MEMTRACE_RAIL?.trim().toLowerCase() === 'off'
7
- || process.env.MEMTRACE_RAW_SEARCH?.trim().toLowerCase() === 'allow'
8
- || !railIsActive())
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { activeRailRuntimes, enrichEnabled, pidAlive, runtimeForCwd, ENRICH_HOOK_KILL_MS, SEARCH_COMMAND_RE, } from './rail-runtime.js';
9
+ const argv = process.argv;
10
+ function arg(name) {
11
+ const index = argv.indexOf(name);
12
+ return index >= 0 ? argv[index + 1] : undefined;
13
+ }
14
+ function off(value) {
15
+ return value?.trim().toLowerCase() === 'off';
16
+ }
17
+ const SEARCH_TOOLS = new Set(['grep', 'search_file_content', 'grep_search']);
18
+ const SHELL_TOOLS = new Set(['bash', 'powershell', 'shell', 'run_shell_command', 'run_terminal_cmd', 'execute_command', 'run_in_terminal']);
19
+ /**
20
+ * Post-phase gate, before any lease read or network call (design 4.1): only a
21
+ * native Grep, or a shell command that runs a search program and did not
22
+ * fail, is worth one loopback POST. Glob, Read and every other shell command
23
+ * exit here.
24
+ */
25
+ export function enrichEligible(payload) {
26
+ if (!payload || typeof payload !== 'object')
27
+ return false;
28
+ const tool = String(payload.tool_name ?? '').toLowerCase();
29
+ const input = payload.tool_input ?? {};
30
+ if (SEARCH_TOOLS.has(tool))
31
+ return true;
32
+ const command = typeof input.command === 'string' ? input.command
33
+ : (!tool && typeof payload.command === 'string' ? payload.command : undefined);
34
+ if (!(SHELL_TOOLS.has(tool) || (!tool && command !== undefined)))
35
+ return false;
36
+ if (typeof command !== 'string' || !SEARCH_COMMAND_RE.test(command))
37
+ return false;
38
+ // A failed command (exit >= 2, nothing printed) is not a search result.
39
+ // grep's exit 1 with no output is a real zero-match and stays eligible.
40
+ const response = payload.tool_response ?? payload.tool_output ?? {};
41
+ if (response && typeof response === 'object') {
42
+ const code = Number(response.exit_code ?? response.exitCode ?? response.returnCode);
43
+ const stdout = response.stdout ?? response.output ?? response.llmContent ?? '';
44
+ if (Number.isFinite(code) && code >= 2 && String(stdout).trim() === '')
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ function ledgerDirectory() {
50
+ const home = process.env.MEMTRACE_HOME?.trim();
51
+ return home || path.join(os.homedir(), '.memtrace');
52
+ }
53
+ /** One timing row per enrichment hook call, written after stdout (design 10). */
54
+ function logHookTiming(row) {
55
+ if (process.env.MEMTRACE_TELEMETRY?.trim().toLowerCase() === 'off')
9
56
  return;
57
+ try {
58
+ const dir = ledgerDirectory();
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ // One write per line: concurrent hooks never merge records.
61
+ fs.appendFileSync(path.join(dir, 'rail-enrich-hook.jsonl'), JSON.stringify(row) + '\n');
62
+ }
63
+ catch { /* timing is best effort */ }
64
+ }
65
+ /** POST to the daemon; resolves empty on timeout, error or daemon death. */
66
+ function postToDaemon(runtime, requestPath, input, killMs) {
67
+ return new Promise(resolve => {
68
+ let finished = false;
69
+ const finish = (value = { status: 0, body: '', headers: {} }) => {
70
+ if (finished)
71
+ return;
72
+ finished = true;
73
+ clearTimeout(deadline);
74
+ clearInterval(monitor);
75
+ resolve(value);
76
+ };
77
+ const request = http.request({ hostname: '127.0.0.1', port: runtime.port,
78
+ method: 'POST', path: requestPath,
79
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(input) },
80
+ }, response => {
81
+ let value = '';
82
+ response.on('data', chunk => {
83
+ value += chunk;
84
+ if (value.length > 2_000_000) {
85
+ finish();
86
+ request.destroy();
87
+ }
88
+ });
89
+ response.on('error', () => finish());
90
+ response.on('end', () => finish({ status: response.statusCode ?? 0, body: value, headers: response.headers }));
91
+ });
92
+ const deadline = setTimeout(() => { finish(); request.destroy(); }, killMs);
93
+ const monitor = setInterval(() => {
94
+ if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)) {
95
+ finish();
96
+ request.destroy();
97
+ }
98
+ }, 50);
99
+ request.on('error', () => finish());
100
+ request.end(input);
101
+ });
102
+ }
103
+ async function readStdin() {
10
104
  let input = '';
11
105
  for await (const chunk of process.stdin) {
12
106
  input += chunk;
13
107
  if (input.length > 2_000_000)
14
- return;
108
+ return undefined;
15
109
  }
110
+ return input;
111
+ }
112
+ async function main() {
113
+ const started = Date.now();
114
+ const env = process.env;
115
+ if (off(env.MEMTRACE_RAIL) || env.MEMTRACE_RAW_SEARCH?.trim().toLowerCase() === 'allow')
116
+ return;
117
+ const host = arg('--host') ?? 'generic';
118
+ const phase = arg('--phase');
119
+ const event = arg('--event');
120
+ const enrichPath = phase === 'post' || event === 'precompact';
121
+ // Grep enrichment is opt-in: off means no stdin parse, no lease read, no HTTP.
122
+ if (enrichPath && !enrichEnabled(env))
123
+ return;
124
+ const input = await readStdin();
125
+ if (input === undefined)
126
+ return;
16
127
  const payload = JSON.parse(input);
128
+ if (phase === 'post' && !enrichEligible(payload))
129
+ return;
17
130
  const cwd = payload.cwd || payload.workspace_roots?.[0] || process.cwd();
18
131
  if (typeof cwd !== 'string')
19
132
  return;
20
- const runtime = runtimeForCwd(cwd);
133
+ // Leases are read ONCE per call.
134
+ const runtime = runtimeForCwd(cwd, activeRailRuntimes());
21
135
  if (!runtime)
22
136
  return;
23
- const hostIndex = process.argv.indexOf('--host');
24
- const host = hostIndex >= 0 ? process.argv[hostIndex + 1] : 'generic';
25
- const jsonMode = process.argv.includes('--json');
137
+ const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
138
+ if (event === 'precompact') {
139
+ const session = typeof payload.session_id === 'string' ? payload.session_id : '';
140
+ await postToDaemon(runtime, `/api/rail/enrich/reset?${new URLSearchParams({ session })}`, input, ENRICH_HOOK_KILL_MS);
141
+ return;
142
+ }
143
+ if (phase === 'post') {
144
+ const query = new URLSearchParams({ host, phase: 'post', enrich: env.MEMTRACE_ENRICH || '',
145
+ mode: env.MEMTRACE_RAIL || '', escape: env.MEMTRACE_RAW_SEARCH || '' });
146
+ const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, input, ENRICH_HOOK_KILL_MS);
147
+ let wrote = 0;
148
+ let malformed = false;
149
+ if (result.status === 200 && result.body && alive()) {
150
+ try {
151
+ JSON.parse(result.body); // never emit malformed endpoint output into an agent
152
+ process.stdout.write(result.body);
153
+ wrote = Buffer.byteLength(result.body);
154
+ }
155
+ catch {
156
+ malformed = true;
157
+ }
158
+ }
159
+ logHookTiming({
160
+ ts: new Date().toISOString(), host, phase: 'post', tool: payload.tool_name ?? null,
161
+ status: result.status,
162
+ outcome: malformed ? 'error' : result.headers['x-memtrace-enrich-outcome'] ?? (result.status ? null : 'no_daemon'),
163
+ bytes: Number(result.headers['x-memtrace-enrich-bytes'] ?? 0), stdout_bytes: wrote,
164
+ server_ms: Number(result.headers['x-memtrace-enrich-server-ms'] ?? 0), hook_ms: Date.now() - started,
165
+ });
166
+ return;
167
+ }
168
+ const jsonMode = argv.includes('--json');
26
169
  // JSON-mode/OpenCode and Windsurf retain their CLI response contracts.
27
170
  // Other hosts use the in-process daemon router with no native binary spawn.
28
171
  if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
29
- const query = new URLSearchParams({ host, mode: process.env.MEMTRACE_RAIL || '',
30
- escape: process.env.MEMTRACE_RAW_SEARCH || '' });
31
- const body = await new Promise(resolve => {
32
- let finished = false;
33
- const finish = (value = '') => {
34
- if (finished)
35
- return;
36
- finished = true;
37
- clearTimeout(deadline);
38
- clearInterval(monitor);
39
- resolve(value);
40
- };
41
- const request = http.request({ hostname: '127.0.0.1', port: runtime.port,
42
- method: 'POST', path: `/api/rail/hook?${query}`,
43
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(input) },
44
- }, response => {
45
- let value = '';
46
- response.on('data', chunk => {
47
- value += chunk;
48
- if (value.length > 2_000_000) {
49
- finish();
50
- request.destroy();
51
- }
52
- });
53
- response.on('error', () => finish());
54
- response.on('end', () => finish(response.statusCode === 200 ? value : ''));
55
- });
56
- const deadline = setTimeout(() => { finish(); request.destroy(); }, 5500);
57
- const monitor = setInterval(() => {
58
- if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)) {
59
- finish();
60
- request.destroy();
61
- }
62
- }, 50);
63
- request.on('error', () => finish());
64
- request.end(input);
65
- });
66
- if (body && runtimeForCwd(cwd)) {
67
- JSON.parse(body); // never emit malformed endpoint output into an agent
68
- process.stdout.write(body);
172
+ const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '',
173
+ escape: env.MEMTRACE_RAW_SEARCH || '' });
174
+ const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, input, 5500);
175
+ if (result.status === 200 && result.body && alive()) {
176
+ JSON.parse(result.body); // never emit malformed endpoint output into an agent
177
+ process.stdout.write(result.body);
69
178
  }
70
179
  return;
71
180
  }
72
181
  const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
73
182
  cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
74
- env: { ...process.env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
183
+ env: { ...env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
75
184
  });
76
185
  let output = '';
77
186
  let stderr = '';
@@ -83,17 +192,15 @@ async function main() {
83
192
  child.stdin.end(input);
84
193
  const timer = setTimeout(() => child.kill(), 5500);
85
194
  // If the daemon exits while a request is in flight, discard the response.
86
- const monitor = setInterval(() => {
87
- if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid))
88
- child.kill();
89
- }, 50);
195
+ const monitor = setInterval(() => { if (!alive())
196
+ child.kill(); }, 50);
90
197
  const code = await new Promise(resolve => {
91
198
  child.once('error', () => resolve(null));
92
199
  child.once('close', resolve);
93
200
  });
94
201
  clearTimeout(timer);
95
202
  clearInterval(monitor);
96
- if (!runtimeForCwd(cwd))
203
+ if (!alive())
97
204
  return;
98
205
  if (code === 0)
99
206
  process.stdout.write(output);
@@ -102,4 +209,16 @@ async function main() {
102
209
  process.exitCode = 2;
103
210
  }
104
211
  }
105
- main().catch(() => { });
212
+ // Only run as a hook process, not when a test imports `enrichEligible`.
213
+ const invokedDirectly = (() => {
214
+ try {
215
+ const self = fs.realpathSync(fileURLToPath(import.meta.url));
216
+ return fs.realpathSync(path.resolve(process.argv[1] ?? '')) === self;
217
+ }
218
+ catch {
219
+ return true;
220
+ }
221
+ })();
222
+ if (invokedDirectly) {
223
+ main().catch(() => { });
224
+ }
@@ -9,7 +9,7 @@ import path from 'path';
9
9
  import { railIsActive } from './rail-runtime.js';
10
10
  import { registerRailHookInSettingsAt, removeRailHookFromSettingsAt, } from './transformers/claude.js';
11
11
  import { geminiSettingsPath } from './transformers/gemini.js';
12
- import { registerCursorRailHook, removeCursorRailHook, registerSettingsHook, removeSettingsHook, registerWindsurfRailHook, removeWindsurfRailHook, registerVsCodeRailHook, removeVsCodeRailHook, windsurfHooksPath, vscodeCopilotRailHookPath, CODEX_MATCHER, GEMINI_MATCHER, openCodeRailPluginPaths, openCodePluginSource, } from './transformers/rail-hooks.js';
12
+ import { registerCursorRailHook, removeCursorRailHook, registerSettingsHook, removeSettingsHook, registerWindsurfRailHook, removeWindsurfRailHook, registerVsCodeRailHook, removeVsCodeRailHook, windsurfHooksPath, vscodeCopilotRailHookPath, CODEX_MATCHER, GEMINI_MATCHER, openCodeRailPluginPaths, openCodePluginSource, writeOpenCodeRailPlugin, } from './transformers/rail-hooks.js';
13
13
  /** Hosts with no Rail hook surface yet (MCP/skills only). */
14
14
  export const RAIL_UNSUPPORTED = [
15
15
  { host: 'hermes', reason: 'Hermes pre_tool_call adapter not wired yet' },
@@ -73,14 +73,11 @@ export function installRailHooks(ctx) {
73
73
  });
74
74
  try {
75
75
  const src = openCodePluginSource(bin);
76
- for (const pluginPath of openCodeRailPluginPaths(ctx)) {
77
- fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
78
- fs.writeFileSync(pluginPath, src);
79
- }
76
+ const pluginPath = writeOpenCodeRailPlugin(ctx, src);
80
77
  results.push({
81
78
  host: 'opencode',
82
79
  registered: true,
83
- path: openCodeRailPluginPaths(ctx)[0],
80
+ path: pluginPath,
84
81
  });
85
82
  }
86
83
  catch (e) {
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { installRailHooks, uninstallRailHooks } from './rail-install.js';
6
- import { runtimeDirectory, railDisabled, activeRailRuntimes, pidAlive, LEASE_REFRESH_INTERVAL_MS } from './rail-runtime.js';
6
+ import { runtimeDirectory, railDisabled, activeRailRuntimes, pidAlive, LEASE_REFRESH_INTERVAL_MS, enrichEnabled, railMode } from './rail-runtime.js';
7
7
  import { writeJsonAtomic } from './fs-safe.js';
8
8
  const arg = (name) => {
9
9
  const index = process.argv.indexOf(name);
@@ -43,13 +43,16 @@ async function main() {
43
43
  }
44
44
  writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, binary, port, routeBinary });
45
45
  const enabled = !railDisabled();
46
- if (enabled === wired)
46
+ // 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.
48
+ const signature = enabled ? `on:${enrichEnabled()}:${railMode()}` : 'off';
49
+ if (signature === wired)
47
50
  return;
48
51
  if (enabled)
49
52
  installRailHooks(ctx);
50
53
  else
51
54
  uninstallRailHooks(ctx);
52
- wired = enabled;
55
+ wired = signature;
53
56
  };
54
57
  const stop = () => {
55
58
  if (done)
@@ -15,13 +15,47 @@ export declare function runtimeDirectory(): string;
15
15
  export declare function railDisabled(): boolean;
16
16
  export declare function persistHookOptOut(): void;
17
17
  export declare function pidAlive(pid: number): boolean;
18
+ /** A lease this old whose owner and guardian are both gone is garbage. */
19
+ export declare const LEASE_PRUNE_AGE_MS = 60000;
20
+ /** Grep enrichment's hook kill (design section 10): the hook never waits longer. */
21
+ export declare const ENRICH_HOOK_KILL_MS = 600;
22
+ /**
23
+ * Cheap pre-network gate for shell commands: only these programs are code
24
+ * searches worth a loopback round trip (design 4.1). `git tag v1` must not
25
+ * match `ag`, so a program must start a word.
26
+ */
27
+ export declare const SEARCH_COMMAND_RE: RegExp;
28
+ export interface RailConfig {
29
+ mode?: string;
30
+ enrich?: boolean;
31
+ hooksEnabled?: boolean;
32
+ }
33
+ /** `~/.memtrace/rail.json` (or `MEMTRACE_RAIL_CONFIG`); `{}` when absent or unreadable. */
34
+ export declare function railConfig(): RailConfig;
35
+ /**
36
+ * Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
37
+ * OFF — the default stays off until the A/B gate passes.
38
+ */
39
+ export declare function enrichEnabled(env?: NodeJS.ProcessEnv, config?: RailConfig): boolean;
40
+ /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then observe. */
41
+ export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig): string;
42
+ /**
43
+ * Live leases. Reading them also deletes garbage: a lease whose owner and
44
+ * guardian are both dead and whose heartbeat stopped over a minute ago.
45
+ * Every hook call reads every lease, and old daemons used to leave dozens.
46
+ */
18
47
  export declare function activeRailRuntimes(): RailRuntime[];
19
48
  export declare function railIsActive(): boolean;
20
- export declare function runtimeForCwd(cwd: string): RailRuntime | undefined;
21
- export declare function railHookInvocation(host: string): {
49
+ export declare function runtimeForCwd(cwd: string, runtimes?: RailRuntime[]): RailRuntime | undefined;
50
+ /**
51
+ * Exec-form hook invocation. `extra` selects the hook's role: none for the
52
+ * Rail PreToolUse router, `--phase post` for grep enrichment, `--event
53
+ * precompact` for the enrichment dedupe reset.
54
+ */
55
+ export declare function railHookInvocation(host: string, extra?: string[]): {
22
56
  command: string;
23
57
  args: string[];
24
58
  };
25
59
  /** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
26
- export declare function railHookCommand(host: string, platform?: NodeJS.Platform): string;
60
+ export declare function railHookCommand(host: string, platform?: NodeJS.Platform, extra?: string[]): string;
27
61
  export declare function isRailCommand(command: string): boolean;
@@ -54,6 +54,64 @@ export function pidAlive(pid) {
54
54
  return error.code === 'EPERM';
55
55
  }
56
56
  }
57
+ /** A lease this old whose owner and guardian are both gone is garbage. */
58
+ export const LEASE_PRUNE_AGE_MS = 60_000;
59
+ /** Grep enrichment's hook kill (design section 10): the hook never waits longer. */
60
+ export const ENRICH_HOOK_KILL_MS = 600;
61
+ /**
62
+ * Cheap pre-network gate for shell commands: only these programs are code
63
+ * searches worth a loopback round trip (design 4.1). `git tag v1` must not
64
+ * match `ag`, so a program must start a word.
65
+ */
66
+ export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep)(\s|$)/;
67
+ /** `~/.memtrace/rail.json` (or `MEMTRACE_RAIL_CONFIG`); `{}` when absent or unreadable. */
68
+ export function railConfig() {
69
+ const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
70
+ || path.join(os.homedir(), '.memtrace', 'rail.json');
71
+ try {
72
+ const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
73
+ return value && typeof value === 'object' ? value : {};
74
+ }
75
+ catch {
76
+ return {};
77
+ }
78
+ }
79
+ function flag(value) {
80
+ switch (value?.trim().toLowerCase()) {
81
+ case 'on':
82
+ case '1':
83
+ case 'true':
84
+ case 'yes':
85
+ case 'enabled': return true;
86
+ case 'off':
87
+ case '0':
88
+ case 'false':
89
+ case 'no':
90
+ case 'disabled': return false;
91
+ default: return undefined;
92
+ }
93
+ }
94
+ /**
95
+ * Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
96
+ * OFF — the default stays off until the A/B gate passes.
97
+ */
98
+ export function enrichEnabled(env = process.env, config = railConfig()) {
99
+ return flag(env.MEMTRACE_ENRICH) ?? (typeof config.enrich === 'boolean' ? config.enrich : false);
100
+ }
101
+ /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then observe. */
102
+ export function railMode(env = process.env, config = railConfig()) {
103
+ const valid = ['off', 'observe', 'nudge', 'rail', 'strict'];
104
+ const fromEnv = env.MEMTRACE_RAIL?.trim().toLowerCase();
105
+ if (fromEnv && valid.includes(fromEnv))
106
+ return fromEnv;
107
+ const fromConfig = typeof config.mode === 'string' ? config.mode.trim().toLowerCase() : '';
108
+ return valid.includes(fromConfig) ? fromConfig : 'observe';
109
+ }
110
+ /**
111
+ * Live leases. Reading them also deletes garbage: a lease whose owner and
112
+ * guardian are both dead and whose heartbeat stopped over a minute ago.
113
+ * Every hook call reads every lease, and old daemons used to leave dozens.
114
+ */
57
115
  export function activeRailRuntimes() {
58
116
  if (railDisabled())
59
117
  return [];
@@ -61,8 +119,17 @@ export function activeRailRuntimes() {
61
119
  const now = Date.now();
62
120
  return fs.readdirSync(runtimeDirectory()).filter(name => name.endsWith('.json')).flatMap(name => {
63
121
  try {
64
- const runtime = JSON.parse(fs.readFileSync(path.join(runtimeDirectory(), name), 'utf8'));
122
+ const file = path.join(runtimeDirectory(), name);
123
+ const runtime = JSON.parse(fs.readFileSync(file, 'utf8'));
65
124
  const age = now - runtime.updatedAt;
125
+ if (Number.isFinite(age) && age > LEASE_PRUNE_AGE_MS
126
+ && !pidAlive(runtime.pid) && !pidAlive(runtime.guardianPid)) {
127
+ try {
128
+ fs.rmSync(file, { force: true });
129
+ }
130
+ catch { /* best effort */ }
131
+ return [];
132
+ }
66
133
  if (!Number.isFinite(age) || age < -1000 || age > LEASE_MAX_AGE_MS
67
134
  || !pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)
68
135
  || typeof runtime.binary !== 'string' || !path.isAbsolute(runtime.binary)
@@ -91,20 +158,25 @@ function normalizedWorkspacePath(directory) {
91
158
  const normalized = resolved.replace(/\\/g, '/').replace(/\/$/, '');
92
159
  return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
93
160
  }
94
- export function runtimeForCwd(cwd) {
161
+ export function runtimeForCwd(cwd, runtimes = activeRailRuntimes()) {
95
162
  const target = normalizedWorkspacePath(cwd);
96
- return activeRailRuntimes()
163
+ return runtimes
97
164
  .map(runtime => ({ runtime, root: normalizedWorkspacePath(runtime.cwd) }))
98
165
  .filter(({ root }) => target === root || target.startsWith(root + '/'))
99
166
  .sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
100
167
  }
101
- export function railHookInvocation(host) {
168
+ /**
169
+ * Exec-form hook invocation. `extra` selects the hook's role: none for the
170
+ * Rail PreToolUse router, `--phase post` for grep enrichment, `--event
171
+ * precompact` for the enrichment dedupe reset.
172
+ */
173
+ export function railHookInvocation(host, extra = []) {
102
174
  return { command: process.execPath,
103
- args: [fileURLToPath(new URL('./rail-hook.js', import.meta.url)), '--host', host] };
175
+ args: [fileURLToPath(new URL('./rail-hook.js', import.meta.url)), '--host', host, ...extra] };
104
176
  }
105
177
  /** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
106
- export function railHookCommand(host, platform = process.platform) {
107
- const invocation = railHookInvocation(host);
178
+ export function railHookCommand(host, platform = process.platform, extra = []) {
179
+ const invocation = railHookInvocation(host, extra);
108
180
  const quote = platform === 'win32'
109
181
  ? (s) => '"' + s.replace(/\\/g, '/').replace(/"/g, '\\"') + '"'
110
182
  : (s) => "'" + s.replace(/'/g, "'\\''") + "'";
@@ -18,6 +18,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
18
18
  'detect_changes',
19
19
  'embed_diag',
20
20
  'embed_reset_breaker',
21
+ 'explain',
21
22
  'export_query_audit',
22
23
  'find_api_calls',
23
24
  'find_api_endpoints',
@@ -66,6 +67,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
66
67
  'get_function_quality_metrics',
67
68
  'get_impact',
68
69
  'get_process_flow',
70
+ 'get_query_revision',
69
71
  'get_repository_stats',
70
72
  'get_service_diagram',
71
73
  'get_source_window',
@@ -74,6 +76,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
74
76
  'get_timeline',
75
77
  'governing_contracts',
76
78
  'governing_rules',
79
+ 'guided_refactor',
77
80
  'index_directory',
78
81
  'ingest_graph_fragment',
79
82
  'link_repositories',
@@ -86,10 +89,13 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
86
89
  'list_watched_paths',
87
90
  'list_worktrees',
88
91
  'mem_diag',
92
+ 'pdg_query',
89
93
  'preflight_check',
94
+ 'query_graph',
90
95
  'read_doc',
91
96
  'recall_decision',
92
97
  'record_external_episode',
98
+ 'rename_symbol',
93
99
  'replay_history',
94
100
  'review_agent_sessions',
95
101
  'review_github_pr',
@@ -19,7 +19,7 @@ export interface SettingsMutationResult {
19
19
  * Merge-add the memtrace MCP server into a Claude settings.json file.
20
20
  * Never overwrites a malformed file — backs it up and returns registered=false.
21
21
  */
22
- export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string, legacyEnv?: Record<string, string>): SettingsMutationResult;
22
+ export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string, legacyEnv?: Record<string, string>, legacyArgs?: string[]): SettingsMutationResult;
23
23
  /**
24
24
  * Every host enters a local liveness guard before contacting Memtrace. The
25
25
  * daemon lifecycle registers/removes this command; a cached registration is
@@ -41,7 +41,19 @@ export declare function claudeRailHookCommand(_memtraceBinary: string, platform?
41
41
  * is replaced, and unrelated PreToolUse hooks are preserved. Never overwrites
42
42
  * a malformed file.
43
43
  */
44
- export declare function registerRailHookInSettingsAt(settingsPath: string, memtraceBinary: string, platform?: NodeJS.Platform): SettingsMutationResult;
44
+ export declare function registerRailHookInSettingsAt(settingsPath: string, memtraceBinary: string, platform?: NodeJS.Platform, options?: RailHookOptions): SettingsMutationResult;
45
+ /** Grep enrichment rides PostToolUse on these tools (design section 9). Claude Code on
46
+ * Windows runs shell commands through its PowerShell tool, so rg/grep/git grep there
47
+ * arrive as PowerShell calls. */
48
+ export declare const ENRICH_HOOK_MATCHER = "Grep|Bash|PowerShell";
49
+ /** Claude hook timeout for the enrichment hooks, in seconds. */
50
+ export declare const ENRICH_HOOK_TIMEOUT_S = 2;
51
+ export interface RailHookOptions {
52
+ /** Register the grep-enrichment hooks. Default: `MEMTRACE_ENRICH`, then rail.json, then off. */
53
+ enrich?: boolean;
54
+ /** The Rail mode the PreToolUse decision depends on. Default: env, then rail.json. */
55
+ mode?: string;
56
+ }
45
57
  /**
46
58
  * Remove the Memtrace Rail PreToolUse hook from a Claude settings.json.
47
59
  * Preserves unrelated hooks; prunes empty containers. Never touches a