memtrace 0.5.15 → 0.6.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 (35) hide show
  1. package/hooks/userprompt-claude.sh +12 -6
  2. package/install.js +0 -19
  3. package/installer/dist/commands/rail-install.d.ts +7 -0
  4. package/installer/dist/commands/rail-install.js +37 -0
  5. package/installer/dist/index.js +20 -1
  6. package/installer/dist/rail-install.d.ts +20 -0
  7. package/installer/dist/rail-install.js +183 -0
  8. package/installer/dist/transformers/claude.d.ts +21 -0
  9. package/installer/dist/transformers/claude.js +118 -3
  10. package/installer/dist/transformers/codex.js +17 -0
  11. package/installer/dist/transformers/cursor.js +23 -1
  12. package/installer/dist/transformers/gemini.d.ts +3 -0
  13. package/installer/dist/transformers/gemini.js +78 -0
  14. package/installer/dist/transformers/index.d.ts +2 -1
  15. package/installer/dist/transformers/index.js +3 -1
  16. package/installer/dist/transformers/opencode.js +31 -0
  17. package/installer/dist/transformers/rail-hooks.d.ts +56 -0
  18. package/installer/dist/transformers/rail-hooks.js +303 -0
  19. package/installer/dist/transformers/shared.js +5 -6
  20. package/installer/dist/transformers/types.d.ts +1 -1
  21. package/installer/skills/commands/memtrace-fleet-publish-intent.md +51 -0
  22. package/installer/skills/commands/memtrace-fleet-record-episode.md +48 -0
  23. package/installer/skills/commands/memtrace-fleet-resolve.md +59 -0
  24. package/installer/skills/workflows/memtrace-code-review.md +6 -0
  25. package/installer/skills/workflows/memtrace-first.md +2 -0
  26. package/installer/skills/workflows/memtrace-fleet-coordination.md +87 -0
  27. package/installer/skills/workflows/memtrace-fleet-first.md +132 -0
  28. package/installer/skills/workflows/memtrace-style-fingerprint.md +111 -0
  29. package/lib/claude-integration.js +6 -0
  30. package/package.json +6 -6
  31. package/skills/commands/memtrace-fleet-publish-intent.md +51 -0
  32. package/skills/commands/memtrace-fleet-record-episode.md +48 -0
  33. package/skills/commands/memtrace-fleet-resolve.md +59 -0
  34. package/skills/workflows/memtrace-fleet-coordination.md +87 -0
  35. package/skills/workflows/memtrace-fleet-first.md +132 -0
@@ -28,8 +28,10 @@
28
28
  # 0 : success (stdout is parsed for hook output)
29
29
  # 2 : would block the prompt (we never want this)
30
30
  #
31
- # Hook output JSON shape (per code.claude.com/docs/en/hooks-guide.md):
32
- # { "decision": "continue", "additionalContext": "..." }
31
+ # Hook output JSON shape (Claude Code UserPromptSubmit validator):
32
+ # { "hookSpecificOutput": { "hookEventName": "UserPromptSubmit",
33
+ # "additionalContext": "..." } }
34
+ # To inject nothing: exit 0 with empty stdout (or print "{}").
33
35
  #
34
36
  # Override:
35
37
  # MEMTRACE_HOOK_MODE=off → unconditional no-op (skips lock too)
@@ -172,8 +174,10 @@ if (( DEBOUNCE_SECS > 0 )) && [[ -f "$LOCK_FILE" ]]; then
172
174
  # hook output and exit. We do NOT probe the daemon.
173
175
  cat <<'EOF'
174
176
  {
175
- "decision": "continue",
176
- "additionalContext": ""
177
+ "hookSpecificOutput": {
178
+ "hookEventName": "UserPromptSubmit",
179
+ "additionalContext": ""
180
+ }
177
181
  }
178
182
  EOF
179
183
  exit 0
@@ -253,7 +257,9 @@ shopt -u nocasematch
253
257
  # to context for every matching prompt, so token cost is real.
254
258
  cat <<'EOF'
255
259
  {
256
- "decision": "continue",
257
- "additionalContext": "Memtrace is active for this repository. For this code-discovery question, prefer the Memtrace MCP tools FIRST — `mcp__memtrace__find_code` for natural-language search, `mcp__memtrace__find_symbol` for exact lookup, `mcp__memtrace__get_symbol_context` for callers/callees, `mcp__memtrace__get_impact` for blast radius. They return exact file:start_line:end_line in one round-trip. Fall back to Read/Grep/Glob only for: (a) config files (.env, package.json, README, raw JSON/YAML/TOML), (b) file inventory questions, (c) paths confirmed outside any indexed repo, (d) reading exact lines you already have from a Memtrace result."
260
+ "hookSpecificOutput": {
261
+ "hookEventName": "UserPromptSubmit",
262
+ "additionalContext": "Memtrace is active for this repository. For this code-discovery question, prefer the Memtrace MCP tools FIRST - `mcp__memtrace__find_code` for natural-language search, `mcp__memtrace__find_symbol` for exact lookup, `mcp__memtrace__get_symbol_context` for callers/callees, `mcp__memtrace__get_impact` for blast radius. They return exact file:start_line:end_line in one round-trip. Fall back to Read/Grep/Glob only for: (a) config files (.env, package.json, README, raw JSON/YAML/TOML), (b) file inventory questions, (c) paths confirmed outside any indexed repo, (d) reading exact lines you already have from a Memtrace result."
263
+ }
258
264
  }
259
265
  EOF
package/install.js CHANGED
@@ -294,25 +294,6 @@ if (require.main === module) {
294
294
  console.warn(`memtrace: failed to persist uninstall script: ${e.message}`);
295
295
  }
296
296
 
297
- // 6. Background-daemon discovery hint (Leaf H / fortress-round-2).
298
- // Operators on long-running setups (Sivant on Windows, Orbit on
299
- // Linux, macOS lab hosts) shouldn't have to keep a terminal open
300
- // to keep the indexer alive — point them at the subcommand. The
301
- // hint is post-install only, never on update; CI systems don't
302
- // benefit and we don't want to nag.
303
- //
304
- // The exact substring `memtrace daemon install` is contract-pinned
305
- // by the F2F test row H-035
306
- // (`npm_shim_post_install_prints_daemon_install_hint`). Renaming
307
- // the subcommand here without updating that test is a regression.
308
- try {
309
- if (process.env.MEMTRACE_SUPPRESS_DAEMON_HINT !== "1") {
310
- console.log(
311
- "memtrace: want it to run as a background service? `memtrace daemon install` " +
312
- "(macOS launchd / Linux systemd-user / Windows Service)."
313
- );
314
- }
315
- } catch { /* best-effort — never fail install on a logging hiccup */ }
316
297
  }
317
298
 
318
299
  module.exports = {
@@ -0,0 +1,7 @@
1
+ export interface RailInstallOptions {
2
+ /** Explicit memtrace binary (from `memtrace rail enable`); wins over PATH lookup. */
3
+ binary?: string;
4
+ scope?: 'global' | 'local';
5
+ }
6
+ export declare function runInstallRail(options?: RailInstallOptions): Promise<void>;
7
+ export declare function runUninstallRail(options?: RailInstallOptions): Promise<void>;
@@ -0,0 +1,37 @@
1
+ import { resolveMemtraceBinary } from './install.js';
2
+ import { installRailHooks, printRailInstallSummary, uninstallRailHooks, } from '../rail-install.js';
3
+ export async function runInstallRail(options = {}) {
4
+ const memtraceBin = options.binary?.trim() ||
5
+ (await resolveMemtraceBinary()) ||
6
+ '';
7
+ if (!memtraceBin) {
8
+ console.error(' Error: memtrace binary not found. Pass --binary or ensure memtrace is on PATH.\n');
9
+ process.exit(1);
10
+ }
11
+ const ctx = {
12
+ scope: options.scope ?? 'global',
13
+ cwd: process.cwd(),
14
+ memtraceBinary: memtraceBin,
15
+ skipMcp: true,
16
+ };
17
+ const results = installRailHooks(ctx);
18
+ printRailInstallSummary(results, 'installed');
19
+ const wired = results.filter((r) => r.registered).map((r) => r.host);
20
+ if (wired.length > 0) {
21
+ console.log(' Next steps:');
22
+ console.log(' 1. memtrace start (daemon for find_code)');
23
+ console.log(' 2. Reload your IDE (Cursor: reload window; Claude: new session)');
24
+ console.log(' 3. Shell-based search (Cursor native Grep is not hooked)\n');
25
+ }
26
+ }
27
+ export async function runUninstallRail(options = {}) {
28
+ const ctx = {
29
+ scope: options.scope ?? 'global',
30
+ cwd: process.cwd(),
31
+ memtraceBinary: options.binary?.trim() ?? '',
32
+ skipMcp: true,
33
+ };
34
+ const results = uninstallRailHooks(ctx);
35
+ printRailInstallSummary(results, 'removed');
36
+ console.log('');
37
+ }
@@ -2,6 +2,7 @@
2
2
  import { Command } from 'commander';
3
3
  import { runInstall, runUninstall } from './commands/install.js';
4
4
  import { runDoctor } from './commands/doctor.js';
5
+ import { runInstallRail, runUninstallRail } from './commands/rail-install.js';
5
6
  const program = new Command();
6
7
  program
7
8
  .name('memtrace-skills')
@@ -13,7 +14,7 @@ function parseOnly(val) {
13
14
  program
14
15
  .command('install')
15
16
  .description('Install memtrace skills and register MCP for selected agents')
16
- .option('--only <agents>', 'comma-separated agent names (claude,cursor,codex,windsurf,vscode,hermes,opencode,kiro)', parseOnly)
17
+ .option('--only <agents>', 'comma-separated agent names (claude,cursor,codex,gemini,windsurf,vscode,hermes,opencode,kiro)', parseOnly)
17
18
  .option('--local', 'install into the current project where the selected agent supports project scope', false)
18
19
  .option('--global', 'install globally (~/.claude/, ~/.cursor/, ~/.agents/) [default]', false)
19
20
  .option('--skip-mcp', 'write skills only, skip MCP server registration', false)
@@ -45,6 +46,24 @@ program
45
46
  only: opts.only,
46
47
  });
47
48
  });
49
+ program
50
+ .command('install-rail')
51
+ .description('Wire Memtrace Rail discovery hooks for Claude, Cursor, Codex, Gemini, OpenCode')
52
+ .option('--binary <path>', 'memtrace executable (default: on PATH)')
53
+ .option('--local', 'project-scoped paths where supported', false)
54
+ .action(async (opts) => {
55
+ await runInstallRail({
56
+ binary: opts.binary,
57
+ scope: opts.local ? 'local' : 'global',
58
+ });
59
+ });
60
+ program
61
+ .command('uninstall-rail')
62
+ .description('Remove Memtrace Rail hooks only (not skills/MCP)')
63
+ .option('--local', 'project-scoped paths where supported', false)
64
+ .action(async (opts) => {
65
+ await runUninstallRail({ scope: opts.local ? 'local' : 'global' });
66
+ });
48
67
  // Default: `memtrace-skills` with no subcommand → install with defaults (global, all agents)
49
68
  program.action(async () => {
50
69
  await runInstall({ scope: 'global' });
@@ -0,0 +1,20 @@
1
+ import type { InstallContext } from './transformers/types.js';
2
+ export interface RailHostResult {
3
+ host: string;
4
+ registered: boolean;
5
+ path?: string;
6
+ skipped?: string;
7
+ }
8
+ /** Hosts with no Rail hook surface yet (MCP/skills only). */
9
+ export declare const RAIL_UNSUPPORTED: ReadonlyArray<{
10
+ host: string;
11
+ reason: string;
12
+ }>;
13
+ /**
14
+ * Wire Rail discovery hooks for Claude, Cursor, Codex, Gemini, and OpenCode.
15
+ * Idempotent. Does not install skills or MCP — hooks only.
16
+ */
17
+ export declare function installRailHooks(ctx: InstallContext): RailHostResult[];
18
+ /** Remove only Memtrace-owned Rail hook entries (not skills/MCP). */
19
+ export declare function uninstallRailHooks(ctx: InstallContext): RailHostResult[];
20
+ export declare function printRailInstallSummary(results: RailHostResult[], action: 'installed' | 'removed'): void;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Memtrace Rail hook wiring for every host that supports discovery interception.
3
+ * Called from `memtrace rail enable` (via `install-rail` CLI) and from full
4
+ * `memtrace install` (per-transformer, same helpers).
5
+ */
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { registerRailHookInSettingsAt, removeRailHookFromSettingsAt, } from './transformers/claude.js';
10
+ import { geminiSettingsPath } from './transformers/gemini.js';
11
+ import { registerCursorRailHook, removeCursorRailHook, registerSettingsHook, removeSettingsHook, registerWindsurfRailHook, removeWindsurfRailHook, registerVsCodeRailHook, removeVsCodeRailHook, windsurfHooksPath, vscodeCopilotRailHookPath, CODEX_MATCHER, GEMINI_MATCHER, openCodePluginSource, } from './transformers/rail-hooks.js';
12
+ /** Hosts with no Rail hook surface yet (MCP/skills only). */
13
+ export const RAIL_UNSUPPORTED = [
14
+ { host: 'hermes', reason: 'Hermes pre_tool_call adapter not wired yet' },
15
+ { host: 'kiro', reason: 'Kiro IDE hooks omit tool stdin (CLI-only for now)' },
16
+ ];
17
+ function codexHooksPath(ctx) {
18
+ const base = ctx.scope === 'global' ? path.join(os.homedir(), '.codex') : path.join(ctx.cwd, '.codex');
19
+ return path.join(base, 'hooks.json');
20
+ }
21
+ function cursorHooksPath(ctx) {
22
+ const base = ctx.scope === 'global' ? os.homedir() : ctx.cwd;
23
+ return path.join(base, '.cursor', 'hooks.json');
24
+ }
25
+ function opencodeConfigDir(ctx) {
26
+ const xdg = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config');
27
+ return ctx.scope === 'global' ? path.join(xdg, 'opencode') : path.join(ctx.cwd, '.opencode');
28
+ }
29
+ function opencodeRailPluginPaths(ctx) {
30
+ const base = opencodeConfigDir(ctx);
31
+ return [
32
+ path.join(base, 'plugin', 'memtrace-rail.js'),
33
+ path.join(base, 'plugins', 'memtrace-rail.js'),
34
+ ];
35
+ }
36
+ /**
37
+ * Wire Rail discovery hooks for Claude, Cursor, Codex, Gemini, and OpenCode.
38
+ * Idempotent. Does not install skills or MCP — hooks only.
39
+ */
40
+ export function installRailHooks(ctx) {
41
+ const bin = ctx.memtraceBinary?.trim() ?? '';
42
+ if (!bin) {
43
+ throw new Error('memtrace binary path is required to install Rail hooks');
44
+ }
45
+ const results = [];
46
+ const claudePath = path.join(os.homedir(), '.claude', 'settings.json');
47
+ const claude = registerRailHookInSettingsAt(claudePath, bin);
48
+ results.push({
49
+ host: 'claude',
50
+ registered: claude.registered,
51
+ path: claudePath,
52
+ skipped: claude.registered ? undefined : 'settings.json malformed or unreadable',
53
+ });
54
+ const cursorPath = cursorHooksPath(ctx);
55
+ const cursor = registerCursorRailHook(cursorPath, bin);
56
+ results.push({
57
+ host: 'cursor',
58
+ registered: cursor.registered,
59
+ path: cursorPath,
60
+ skipped: cursor.registered ? undefined : 'hooks.json malformed or unreadable',
61
+ });
62
+ const codexPath = codexHooksPath(ctx);
63
+ const codex = registerSettingsHook(codexPath, bin, 'codex', 'PreToolUse', CODEX_MATCHER);
64
+ results.push({
65
+ host: 'codex',
66
+ registered: codex.registered,
67
+ path: codexPath,
68
+ skipped: codex.registered ? undefined : 'hooks.json malformed or unreadable',
69
+ });
70
+ const geminiPath = geminiSettingsPath(ctx);
71
+ const gemini = registerSettingsHook(geminiPath, bin, 'gemini', 'BeforeTool', GEMINI_MATCHER, {
72
+ enableHooks: true,
73
+ enableMessageBusIntegration: true,
74
+ });
75
+ results.push({
76
+ host: 'gemini',
77
+ registered: gemini.registered,
78
+ path: geminiPath,
79
+ skipped: gemini.registered ? undefined : 'settings.json malformed or unreadable',
80
+ });
81
+ try {
82
+ const src = openCodePluginSource(bin);
83
+ for (const pluginPath of opencodeRailPluginPaths(ctx)) {
84
+ fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
85
+ fs.writeFileSync(pluginPath, src);
86
+ }
87
+ results.push({
88
+ host: 'opencode',
89
+ registered: true,
90
+ path: opencodeRailPluginPaths(ctx)[0],
91
+ });
92
+ }
93
+ catch (e) {
94
+ results.push({
95
+ host: 'opencode',
96
+ registered: false,
97
+ skipped: e.message,
98
+ });
99
+ }
100
+ const windsurfPath = windsurfHooksPath();
101
+ fs.mkdirSync(path.dirname(windsurfPath), { recursive: true });
102
+ const windsurf = registerWindsurfRailHook(windsurfPath, bin);
103
+ results.push({
104
+ host: 'windsurf',
105
+ registered: windsurf.registered,
106
+ path: windsurfPath,
107
+ skipped: windsurf.registered ? undefined : 'hooks.json malformed or unreadable',
108
+ });
109
+ const vscodePath = vscodeCopilotRailHookPath();
110
+ fs.mkdirSync(path.dirname(vscodePath), { recursive: true });
111
+ const vscode = registerVsCodeRailHook(vscodePath, bin);
112
+ results.push({
113
+ host: 'vscode',
114
+ registered: vscode.registered,
115
+ path: vscodePath,
116
+ skipped: vscode.registered ? undefined : 'hook file malformed or unreadable',
117
+ });
118
+ for (const { host, reason } of RAIL_UNSUPPORTED) {
119
+ results.push({ host, registered: false, skipped: reason });
120
+ }
121
+ return results;
122
+ }
123
+ /** Remove only Memtrace-owned Rail hook entries (not skills/MCP). */
124
+ export function uninstallRailHooks(ctx) {
125
+ const results = [];
126
+ const claudePath = path.join(os.homedir(), '.claude', 'settings.json');
127
+ const claude = removeRailHookFromSettingsAt(claudePath);
128
+ results.push({ host: 'claude', registered: false, path: claudePath, skipped: claude.changed ? undefined : 'no rail hook' });
129
+ const cursorPath = cursorHooksPath(ctx);
130
+ const cursor = removeCursorRailHook(cursorPath);
131
+ results.push({ host: 'cursor', registered: false, path: cursorPath, skipped: cursor.changed ? undefined : 'no rail hook' });
132
+ const codexPath = codexHooksPath(ctx);
133
+ const codex = removeSettingsHook(codexPath, 'PreToolUse');
134
+ results.push({ host: 'codex', registered: false, path: codexPath, skipped: codex.changed ? undefined : 'no rail hook' });
135
+ const geminiPath = geminiSettingsPath(ctx);
136
+ const gemini = removeSettingsHook(geminiPath, 'BeforeTool');
137
+ results.push({ host: 'gemini', registered: false, path: geminiPath, skipped: gemini.changed ? undefined : 'no rail hook' });
138
+ for (const p of opencodeRailPluginPaths(ctx)) {
139
+ try {
140
+ fs.rmSync(p, { force: true });
141
+ }
142
+ catch { /* best effort */ }
143
+ }
144
+ results.push({ host: 'opencode', registered: false });
145
+ const windsurfPath = windsurfHooksPath();
146
+ const windsurf = removeWindsurfRailHook(windsurfPath);
147
+ results.push({
148
+ host: 'windsurf',
149
+ registered: false,
150
+ path: windsurfPath,
151
+ skipped: windsurf.changed ? undefined : 'no rail hook',
152
+ });
153
+ const vscodePath = vscodeCopilotRailHookPath();
154
+ const vscode = removeVsCodeRailHook(vscodePath);
155
+ results.push({
156
+ host: 'vscode',
157
+ registered: false,
158
+ path: vscodePath,
159
+ skipped: vscode.changed ? undefined : 'no rail hook',
160
+ });
161
+ for (const { host, reason } of RAIL_UNSUPPORTED) {
162
+ results.push({ host, registered: false, skipped: reason });
163
+ }
164
+ return results;
165
+ }
166
+ export function printRailInstallSummary(results, action) {
167
+ console.log(`\n Memtrace Rail hooks ${action}:\n`);
168
+ for (const r of results) {
169
+ if (r.registered && action === 'installed') {
170
+ console.log(` \x1b[32m✓\x1b[0m ${r.host}${r.path ? ` → ${r.path}` : ''}`);
171
+ }
172
+ else if (action === 'removed' && r.skipped === undefined) {
173
+ console.log(` \x1b[32m✓\x1b[0m ${r.host} (removed)`);
174
+ }
175
+ else if (r.skipped) {
176
+ console.log(` \x1b[2m–\x1b[0m ${r.host}: ${r.skipped}`);
177
+ }
178
+ else {
179
+ console.log(` \x1b[33m!\x1b[0m ${r.host}: not ${action}${r.path ? ` (${r.path})` : ''}`);
180
+ }
181
+ }
182
+ console.log('');
183
+ }
@@ -18,6 +18,27 @@ export interface SettingsMutationResult {
18
18
  * Never overwrites a malformed file — backs it up and returns registered=false.
19
19
  */
20
20
  export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string): SettingsMutationResult;
21
+ /**
22
+ * Register the Memtrace Rail PreToolUse hook in a Claude settings.json.
23
+ *
24
+ * This is what makes Rail "part of memtrace" rather than a manual install:
25
+ * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
26
+ * attempt through `memtrace route --hook`. The hook defaults to **observe**
27
+ * (zero behavior change) — `MEMTRACE_RAIL=nudge|rail|strict` opts into more.
28
+ * Mode is intentionally NOT baked into the command so the user can change it
29
+ * via env without re-installing.
30
+ *
31
+ * Idempotent: any prior Rail entry (identified by {@link RAIL_HOOK_MARKER}) is
32
+ * replaced, and unrelated PreToolUse hooks are preserved. Never overwrites a
33
+ * malformed file.
34
+ */
35
+ export declare function registerRailHookInSettingsAt(settingsPath: string, memtraceBinary: string): SettingsMutationResult;
36
+ /**
37
+ * Remove the Memtrace Rail PreToolUse hook from a Claude settings.json.
38
+ * Preserves unrelated hooks; prunes empty containers. Never touches a
39
+ * malformed file.
40
+ */
41
+ export declare function removeRailHookFromSettingsAt(settingsPath: string): SettingsCleanupResult;
21
42
  /**
22
43
  * Merge-add plugin + marketplace entries into a Claude settings.json file.
23
44
  * Never overwrites a malformed file.
@@ -216,6 +216,92 @@ export function registerMcpInSettingsAt(settingsPath, memtraceBinary) {
216
216
  writeJsonAtomic(settingsPath, settings);
217
217
  return { registered: true };
218
218
  }
219
+ /**
220
+ * The tool surfaces Rail intercepts. Native `Grep`/`Glob` are the high-volume
221
+ * offenders; `Bash` catches rg/grep/find/fd commands.
222
+ */
223
+ const RAIL_HOOK_MATCHER = 'Grep|Glob|Bash';
224
+ /** Substring that identifies a memtrace-owned Rail hook entry (for idempotent
225
+ * re-register + clean removal). */
226
+ const RAIL_HOOK_MARKER = 'route --hook';
227
+ /**
228
+ * Register the Memtrace Rail PreToolUse hook in a Claude settings.json.
229
+ *
230
+ * This is what makes Rail "part of memtrace" rather than a manual install:
231
+ * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
232
+ * attempt through `memtrace route --hook`. The hook defaults to **observe**
233
+ * (zero behavior change) — `MEMTRACE_RAIL=nudge|rail|strict` opts into more.
234
+ * Mode is intentionally NOT baked into the command so the user can change it
235
+ * via env without re-installing.
236
+ *
237
+ * Idempotent: any prior Rail entry (identified by {@link RAIL_HOOK_MARKER}) is
238
+ * replaced, and unrelated PreToolUse hooks are preserved. Never overwrites a
239
+ * malformed file.
240
+ */
241
+ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary) {
242
+ const { value, corrupted, backupPath } = safeReadJson(settingsPath);
243
+ if (corrupted) {
244
+ console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped Rail hook registration.`);
245
+ return { registered: false, backupPath };
246
+ }
247
+ const settings = (value ?? {});
248
+ settings.hooks = settings.hooks ?? {};
249
+ const existing = Array.isArray(settings.hooks.PreToolUse)
250
+ ? settings.hooks.PreToolUse
251
+ : [];
252
+ // Drop any prior memtrace Rail entry so re-install doesn't duplicate it.
253
+ const preserved = existing.filter((entry) => !isRailHookEntry(entry));
254
+ preserved.push({
255
+ matcher: RAIL_HOOK_MATCHER,
256
+ hooks: [
257
+ {
258
+ type: 'command',
259
+ command: `${memtraceBinary} route --hook`,
260
+ },
261
+ ],
262
+ });
263
+ settings.hooks.PreToolUse = preserved;
264
+ writeJsonAtomic(settingsPath, settings);
265
+ return { registered: true };
266
+ }
267
+ /** True when a PreToolUse entry is a memtrace-owned Rail hook. */
268
+ function isRailHookEntry(entry) {
269
+ if (!isRecord(entry) || !Array.isArray(entry.hooks))
270
+ return false;
271
+ return entry.hooks.some((h) => isRecord(h) && typeof h.command === 'string' && h.command.includes(RAIL_HOOK_MARKER));
272
+ }
273
+ /**
274
+ * Remove the Memtrace Rail PreToolUse hook from a Claude settings.json.
275
+ * Preserves unrelated hooks; prunes empty containers. Never touches a
276
+ * malformed file.
277
+ */
278
+ export function removeRailHookFromSettingsAt(settingsPath) {
279
+ const { value, corrupted, backupPath } = safeReadJson(settingsPath);
280
+ if (corrupted) {
281
+ console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped Rail hook cleanup.`);
282
+ return { changed: false, backupPath };
283
+ }
284
+ if (!value)
285
+ return { changed: false };
286
+ const settings = value;
287
+ if (!isRecord(settings.hooks) || !Array.isArray(settings.hooks.PreToolUse)) {
288
+ return { changed: false };
289
+ }
290
+ const before = settings.hooks.PreToolUse;
291
+ const after = before.filter((entry) => !isRailHookEntry(entry));
292
+ if (after.length === before.length)
293
+ return { changed: false };
294
+ if (after.length === 0) {
295
+ delete settings.hooks.PreToolUse;
296
+ }
297
+ else {
298
+ settings.hooks.PreToolUse = after;
299
+ }
300
+ if (Object.keys(settings.hooks).length === 0)
301
+ delete settings.hooks;
302
+ writeJsonAtomic(settingsPath, settings);
303
+ return { changed: true };
304
+ }
219
305
  /**
220
306
  * Merge-add plugin + marketplace entries into a Claude settings.json file.
221
307
  * Never overwrites a malformed file.
@@ -275,6 +361,22 @@ export function removeClaudeSettingsEntriesAt(settingsPath) {
275
361
  if (Object.keys(mcpServers).length === 0)
276
362
  delete settings.mcpServers;
277
363
  }
364
+ // ─── memtrace-rail ─── Remove the Rail PreToolUse hook on uninstall.
365
+ if (isRecord(settings.hooks) && Array.isArray(settings.hooks.PreToolUse)) {
366
+ const pre = settings.hooks.PreToolUse;
367
+ const after = pre.filter((entry) => !isRailHookEntry(entry));
368
+ if (after.length !== pre.length) {
369
+ changed = true;
370
+ if (after.length === 0) {
371
+ delete settings.hooks.PreToolUse;
372
+ }
373
+ else {
374
+ settings.hooks.PreToolUse = after;
375
+ }
376
+ if (Object.keys(settings.hooks).length === 0)
377
+ delete settings.hooks;
378
+ }
379
+ }
278
380
  for (const containerName of MARKETPLACE_SETTING_CONTAINERS) {
279
381
  const container = settings[containerName];
280
382
  if (!isRecord(container))
@@ -321,12 +423,24 @@ function removeClaudeInstalledPluginMetadata() {
321
423
  * This adds the MCP server config so Claude Code can connect to memtrace tools.
322
424
  */
323
425
  async function registerMcpServer(memtraceBinaryPath) {
426
+ const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
427
+ // ─── memtrace-rail ─── Always wire the Memtrace-first discovery hook so it
428
+ // ships as part of `memtrace install` (not a manual step). Hooks live in
429
+ // settings.json regardless of which MCP-registration strategy wins, and the
430
+ // hook defaults to observe mode (zero behavior change) until the user opts
431
+ // into MEMTRACE_RAIL=nudge|rail|strict. Failure here must not block MCP
432
+ // registration.
433
+ try {
434
+ registerRailHookInSettingsAt(settingsFile, memtraceBinaryPath);
435
+ }
436
+ catch (e) {
437
+ console.warn(`memtrace: failed to register Rail hook: ${e.message}`);
438
+ }
324
439
  // Strategy 1 (preferred): claude CLI's own add-json path
325
440
  const viaCli = await tryMcpAddJson(memtraceBinaryPath);
326
441
  if (viaCli)
327
442
  return;
328
443
  // Strategy 2 (fallback): direct settings.json merge (safe + atomic)
329
- const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
330
444
  registerMcpInSettingsAt(settingsFile, memtraceBinaryPath);
331
445
  }
332
446
  /**
@@ -380,8 +494,9 @@ export async function installClaudePlugin(skills, memtraceBinaryPath) {
380
494
  enablePluginInSettings();
381
495
  // Step 6: Register MCP server
382
496
  await registerMcpServer(memtraceBinaryPath);
383
- // Step 7: Write user-level skills for SDK-based integrations
384
- writeUserLevelSkills(skills);
497
+ // Skills ship via the memtrace-skills@memtrace plugin only. Writing
498
+ // bare copies to ~/.claude/skills/memtrace-* duplicates every skill
499
+ // in Claude Code when the plugin is also enabled (issue #11).
385
500
  return { cacheDir, skillCount: skills.length };
386
501
  }
387
502
  /**
@@ -2,7 +2,12 @@ import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
4
  import { commandExists, execCommand } from '../utils.js';
5
+ import { registerSettingsHook, removeSettingsHook, CODEX_MATCHER, } from './rail-hooks.js';
5
6
  const MCP_SERVER_NAME = 'memtrace';
7
+ function codexRailHooksPath(ctx) {
8
+ const base = ctx.scope === 'global' ? path.join(os.homedir(), '.codex') : path.join(ctx.cwd, '.codex');
9
+ return path.join(base, 'hooks.json');
10
+ }
6
11
  function skillsRoot(ctx) {
7
12
  const base = ctx.scope === 'global' ? os.homedir() : ctx.cwd;
8
13
  return path.join(base, '.agents', 'skills');
@@ -173,6 +178,14 @@ export const codexTransformer = {
173
178
  }
174
179
  }
175
180
  }
181
+ // ─── memtrace-rail ─── Wire the discovery hook into .codex/hooks.json
182
+ // (PreToolUse, Bash matcher — Codex code discovery runs through the shell).
183
+ try {
184
+ registerSettingsHook(codexRailHooksPath(ctx), ctx.memtraceBinary, 'codex', 'PreToolUse', CODEX_MATCHER);
185
+ }
186
+ catch (e) {
187
+ warnings.push(`failed to register Codex Rail hook: ${e.message}`);
188
+ }
176
189
  return {
177
190
  agent: 'codex',
178
191
  skillsWritten: skills.length,
@@ -191,6 +204,10 @@ export const codexTransformer = {
191
204
  }
192
205
  }
193
206
  }
207
+ try {
208
+ removeSettingsHook(codexRailHooksPath(ctx), 'PreToolUse');
209
+ }
210
+ catch { /* best effort */ }
194
211
  const configFile = codexConfigPath(ctx);
195
212
  if (fs.existsSync(configFile)) {
196
213
  const next = stripCodexMcpServer(fs.readFileSync(configFile, 'utf-8'));
@@ -3,6 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
5
5
  import { MEMTRACE_MCP_ENV } from './shared.js';
6
+ import { registerCursorRailHook, removeCursorRailHook } from './rail-hooks.js';
6
7
  function skillsRoot(ctx) {
7
8
  const base = ctx.scope === 'global' ? os.homedir() : ctx.cwd;
8
9
  return path.join(base, '.cursor', 'skills');
@@ -11,6 +12,10 @@ function mcpPath(ctx) {
11
12
  const base = ctx.scope === 'global' ? os.homedir() : ctx.cwd;
12
13
  return path.join(base, '.cursor', 'mcp.json');
13
14
  }
15
+ function railHooksPath(ctx) {
16
+ const base = ctx.scope === 'global' ? os.homedir() : ctx.cwd;
17
+ return path.join(base, '.cursor', 'hooks.json');
18
+ }
14
19
  function writeSkill(skill, rootDir) {
15
20
  const name = skill.filename.replace(/\.md$/, '');
16
21
  const outDir = path.join(rootDir, name);
@@ -37,7 +42,11 @@ export function registerCursorMcpAt(mcpFile, binary) {
37
42
  const existingEnv = (isRecord(existing) && isRecord(existing.env))
38
43
  ? existing.env
39
44
  : {};
40
- const mergedEnv = { ...existingEnv, ...MEMTRACE_MCP_ENV };
45
+ const mergedEnv = {
46
+ ...existingEnv,
47
+ ...MEMTRACE_MCP_ENV,
48
+ MEMTRACE_WORKSPACE_ROOT: '${workspaceFolder}',
49
+ };
41
50
  cfg.mcpServers['memtrace'] = {
42
51
  command: binary,
43
52
  args: ['mcp'],
@@ -64,6 +73,14 @@ export const cursorTransformer = {
64
73
  const result = registerCursorMcpAt(mcpPath(ctx), ctx.memtraceBinary);
65
74
  mcpRegistered = result.registered;
66
75
  }
76
+ // ─── memtrace-rail ─── Wire the Memtrace-first discovery hook (default
77
+ // observe). Failure must not block the rest of the install.
78
+ try {
79
+ registerCursorRailHook(railHooksPath(ctx), ctx.memtraceBinary);
80
+ }
81
+ catch (e) {
82
+ console.warn(`memtrace: failed to register Cursor Rail hook: ${e.message}`);
83
+ }
67
84
  return {
68
85
  agent: 'cursor',
69
86
  skillsWritten: skills.length,
@@ -83,6 +100,11 @@ export const cursorTransformer = {
83
100
  }
84
101
  }
85
102
  }
103
+ // 1b. Remove the Rail hook.
104
+ try {
105
+ removeCursorRailHook(railHooksPath(ctx));
106
+ }
107
+ catch { /* best effort */ }
86
108
  // 2. Remove memtrace entry from mcp.json; delete file if empty
87
109
  const mcpFile = mcpPath(ctx);
88
110
  const { value, corrupted } = safeReadJson(mcpFile);
@@ -0,0 +1,3 @@
1
+ import { Transformer, InstallContext } from './types.js';
2
+ export declare function geminiSettingsPath(ctx: InstallContext): string;
3
+ export declare const geminiTransformer: Transformer;