memtrace 0.3.34 → 0.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/memtrace.js CHANGED
@@ -92,7 +92,13 @@ if (args[0] === "uninstall" || args[0] === "cleanup") {
92
92
  let ran = false;
93
93
  for (const p of candidates) {
94
94
  try {
95
- require(p);
95
+ // Spawn the uninstall script as its own process so its top-level
96
+ // `if (require.main === module)` block fires. We deliberately
97
+ // avoid `require(p)` here: tests for the cleanup helpers
98
+ // import uninstall.js for its exports and would trigger the
99
+ // uninstall pipeline at import time if we relied on side effect.
100
+ const result = spawnSync(process.execPath, [p], { stdio: "inherit" });
101
+ if (result.error) continue;
96
102
  ran = true;
97
103
  break;
98
104
  } catch (e) {
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Memtrace PostToolUse hook for Claude Code — adoption telemetry.
4
+ #
5
+ # Fires after every tool call that matched the registered matcher
6
+ # (`mcp__memtrace__.*`). Logs the call locally to
7
+ # `~/.memtrace/adoption.jsonl` so we can measure whether v0.3.35's
8
+ # enforcement layers actually moved Memtrace tool-invocation rates.
9
+ #
10
+ # This is LOCAL adoption tracking — the file lives in the user's
11
+ # home directory, never leaves the machine. Aggregate counts may be
12
+ # included in opt-in telemetry pings if the user has enabled them
13
+ # (see PRIVACY.md).
14
+ #
15
+ # Hook never blocks. Always exit 0.
16
+ #
17
+ # Override:
18
+ # MEMTRACE_ADOPTION_LOG=off → unconditional no-op
19
+ #
20
+ set -euo pipefail
21
+
22
+ [[ "${MEMTRACE_ADOPTION_LOG:-on}" == "off" ]] && exit 0
23
+
24
+ # Read PostToolUse input from stdin. Shape:
25
+ # { "session_id", "cwd", "hook_event_name", "tool_name", "tool_input", ... }
26
+ input="$(cat)"
27
+
28
+ # Cheap exit if the JSON is malformed.
29
+ [[ -z "$input" ]] && exit 0
30
+
31
+ log_dir="${MEMTRACE_ADOPTION_DIR:-$HOME/.memtrace}"
32
+ log_file="$log_dir/adoption.jsonl"
33
+ mkdir -p "$log_dir" 2>/dev/null || exit 0
34
+
35
+ # Append a single-line JSON record. We strip `tool_input` because it
36
+ # can be large and may contain sensitive arguments. We keep the
37
+ # tool name + a high-resolution timestamp + session id only.
38
+ python3 - "$input" "$log_file" <<'PY' 2>/dev/null || true
39
+ import json, sys, os, time
40
+ input_str = sys.argv[1]
41
+ log_path = sys.argv[2]
42
+ try:
43
+ obj = json.loads(input_str)
44
+ except Exception:
45
+ sys.exit(0)
46
+ record = {
47
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
48
+ "session_id": obj.get("session_id", ""),
49
+ "tool_name": obj.get("tool_name", ""),
50
+ "hook_event_name": obj.get("hook_event_name", ""),
51
+ }
52
+ with open(log_path, "a", encoding="utf-8") as f:
53
+ f.write(json.dumps(record) + "\n")
54
+ PY
55
+
56
+ exit 0
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Memtrace UserPromptSubmit hook for Claude Code.
4
+ #
5
+ # Fires once per user turn (NOT per tool call). Reads the user's
6
+ # prompt; if it looks like a code-discovery question and the
7
+ # Memtrace daemon is reachable, injects an `additionalContext`
8
+ # nudge so Claude considers Memtrace MCP tools BEFORE falling back
9
+ # to Read/Grep/Glob.
10
+ #
11
+ # Why UserPromptSubmit instead of PreToolUse-on-Read|Grep|Glob:
12
+ # - fires once per turn, not per tool call → no per-Read latency
13
+ # - gets the user's actual prompt → can decide based on intent,
14
+ # not on which file the model chose to grep
15
+ # - non-blocking → just adds context, never denies a tool call
16
+ #
17
+ # Exit codes:
18
+ # 0 : success (stdout is parsed for hook output)
19
+ # 2 : would block the prompt (we never want this)
20
+ #
21
+ # Hook output JSON shape (per code.claude.com/docs/en/hooks-guide.md):
22
+ # { "decision": "continue", "additionalContext": "..." }
23
+ #
24
+ # Override:
25
+ # MEMTRACE_HOOK_MODE=off → unconditional no-op
26
+ # MEMTRACE_HEALTH_URL=... → custom health endpoint (default 3030)
27
+ #
28
+ set -euo pipefail
29
+
30
+ mode="${MEMTRACE_HOOK_MODE:-advisory}"
31
+ [[ "$mode" == "off" ]] && exit 0
32
+
33
+ # ── Daemon liveness (portable: works on macOS/Linux/Windows-WSL) ──
34
+ #
35
+ # We use the Memtrace UI's status endpoint instead of `pgrep` so
36
+ # Windows + restricted-shell environments work the same way.
37
+ # 1-second timeout — must not slow Claude down.
38
+ health_url="${MEMTRACE_HEALTH_URL:-http://localhost:3030/api/health}"
39
+ if ! curl -sf --max-time 1 "$health_url" >/dev/null 2>&1; then
40
+ # Daemon unreachable: silent no-op. We never inject memtrace
41
+ # nudges when memtrace itself isn't running.
42
+ exit 0
43
+ fi
44
+
45
+ # ── Read prompt from stdin ──────────────────────────────────────────
46
+ input="$(cat)"
47
+
48
+ # Use python3 for JSON parsing — every macOS/Linux/Windows-with-Python
49
+ # has it; jq is more concise but less universal.
50
+ prompt="$(printf '%s' "$input" | python3 -c '
51
+ import json, sys
52
+ try:
53
+ obj = json.load(sys.stdin)
54
+ print(obj.get("prompt", ""), end="")
55
+ except Exception:
56
+ pass
57
+ ' 2>/dev/null || true)"
58
+
59
+ # If the prompt is empty or unparseable, no-op.
60
+ [[ -z "$prompt" ]] && exit 0
61
+
62
+ # ── Match code-discovery intent ─────────────────────────────────────
63
+ #
64
+ # The match list is intentionally generous on the "ask Memtrace" side
65
+ # and is anchored against directive verbs and possessive phrases that
66
+ # a real user prompt looks like. The agent's planner also uses these
67
+ # (e.g. "trace through", "find the function that") so this catches
68
+ # both human-typed and agent-internal phrasings.
69
+ shopt -s nocasematch
70
+ should_nudge=0
71
+ case "$prompt" in
72
+ *"where is"*|*"where's"*|*"how does"*|*"how is"*) should_nudge=1 ;;
73
+ *"what calls"*|*"who calls"*|*"callers of"*|*"callees of"*) should_nudge=1 ;;
74
+ *"why does"*|*"why is"*|*"why was"*) should_nudge=1 ;;
75
+ *"find the function"*|*"find the class"*|*"find the type"*) should_nudge=1 ;;
76
+ *"trace through"*|*"trace this"*|*"trace the"*) should_nudge=1 ;;
77
+ *"investigate"*|*"debug"*|*"diagnose"*) should_nudge=1 ;;
78
+ *"explain this"*|*"explain the code"*|*"understand this"*) should_nudge=1 ;;
79
+ *"audit"*|*"review"*|*"refactor"*) should_nudge=1 ;;
80
+ *"fix bug"*|*"fix the bug"*|*"broken"*|*"failing"*) should_nudge=1 ;;
81
+ *"impact of"*|*"what breaks if"*|*"safe to remove"*|*"safe to rename"*) should_nudge=1 ;;
82
+ *"locate"*|*"look up"*|*"search for "*) should_nudge=1 ;;
83
+ *"recent changes"*|*"what changed"*|*"evolution of"*) should_nudge=1 ;;
84
+ *"call graph"*|*"dependency"*|*"depend on"*|*"dependencies of"*) should_nudge=1 ;;
85
+ esac
86
+ shopt -u nocasematch
87
+
88
+ [[ "$should_nudge" -eq 0 ]] && exit 0
89
+
90
+ # ── Emit the nudge ──────────────────────────────────────────────────
91
+ #
92
+ # Per Anthropic's hook docs, UserPromptSubmit accepts a top-level
93
+ # `decision: continue` (don't block) plus `additionalContext` (which
94
+ # is injected as a system-style reminder Claude reads alongside the
95
+ # user's prompt). The wording is concise on purpose — this is added
96
+ # to context for every matching prompt, so token cost is real.
97
+ cat <<'EOF'
98
+ {
99
+ "decision": "continue",
100
+ "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."
101
+ }
102
+ EOF
package/install.js CHANGED
@@ -6,6 +6,7 @@ const path = require("path");
6
6
  const fs = require("fs");
7
7
  const { spawnSync } = require("child_process");
8
8
  const { platformBinary, spawnOptionsForPlatform } = require("./lib/spawn-helper");
9
+ const { installToFs } = require("./lib/claude-integration");
9
10
 
10
11
  // ── Platform binary resolution (preserved from legacy) ───────────────────────
11
12
 
@@ -128,7 +129,40 @@ if (require.main === module) {
128
129
  console.warn("memtrace: run 'memtrace install' manually after the package is built.");
129
130
  }
130
131
 
131
- // 3. Persist uninstall script to ~/.memtrace/ (survives `npm uninstall -g`)
132
+ // 3. Claude Code adoption layer (v0.3.35+):
133
+ // - write ~/.claude/MEMTRACE.md (sidecar directive we own)
134
+ // - add a breadcrumb block to ~/.claude/CLAUDE.md if it exists
135
+ // - register UserPromptSubmit + PostToolUse hooks in
136
+ // ~/.claude/settings.json under `_managed_by: "memtrace"`
137
+ //
138
+ // This layer is what makes Claude Code consistently reach for
139
+ // Memtrace MCP tools FIRST instead of falling back to Read/Grep
140
+ // on indexed repos. Heuristic skill auto-invocation is too soft
141
+ // on its own — see lib/claude-integration.js for the why.
142
+ try {
143
+ const hooksDir = path.join(__dirname, "hooks");
144
+ // Make hook scripts executable on Unix (npm tarballs lose +x on
145
+ // some pack/unpack paths; doing this defensively).
146
+ if (os.platform() !== "win32" && fs.existsSync(hooksDir)) {
147
+ for (const entry of fs.readdirSync(hooksDir)) {
148
+ if (entry.endsWith(".sh")) {
149
+ try { fs.chmodSync(path.join(hooksDir, entry), 0o755); }
150
+ catch { /* best-effort */ }
151
+ }
152
+ }
153
+ }
154
+ const claudeDir = path.join(os.homedir(), ".claude");
155
+ const summary = installToFs({ claudeDir, hooksDir });
156
+ if (summary.wrote.length > 0) {
157
+ console.log(
158
+ `memtrace: configured Claude Code integration (${summary.wrote.length} file(s) updated)`
159
+ );
160
+ }
161
+ } catch (e) {
162
+ console.warn(`memtrace: Claude Code integration setup skipped: ${e.message}`);
163
+ }
164
+
165
+ // 4. Persist uninstall script to ~/.memtrace/ (survives `npm uninstall -g`)
132
166
  try {
133
167
  const memtraceDir = path.join(os.homedir(), ".memtrace");
134
168
  fs.mkdirSync(memtraceDir, { recursive: true });
@@ -0,0 +1,447 @@
1
+ "use strict";
2
+
3
+ // Claude Code integration for Memtrace.
4
+ //
5
+ // Manages three pieces of user-level Claude Code configuration:
6
+ //
7
+ // 1. ~/.claude/MEMTRACE.md — durable directive that Claude
8
+ // reads as ambient context. Owned
9
+ // entirely by us. We control the
10
+ // full file content; uninstall
11
+ // removes it cleanly.
12
+ //
13
+ // 2. ~/.claude/CLAUDE.md — user-authored. We DO NOT
14
+ // overwrite. We append a single
15
+ // sentinel-bracketed breadcrumb
16
+ // line pointing at MEMTRACE.md,
17
+ // only if the file already exists.
18
+ // User edits outside our markers
19
+ // are preserved on uninstall.
20
+ //
21
+ // 3. ~/.claude/settings.json — JSON. We add hook entries tagged
22
+ // with `_managed_by: "memtrace"`.
23
+ // All other entries pass through
24
+ // untouched. Uninstall walks the
25
+ // hooks arrays and drops only
26
+ // entries with our tag.
27
+ //
28
+ // Pure functions where possible — anything that touches the filesystem
29
+ // is wrapped at the bottom for the install.js / uninstall.js entry
30
+ // points. Pure parts are unit + property tested in
31
+ // `test/claude-integration.test.js`.
32
+
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+ const os = require("os");
36
+
37
+ // ── Constants ─────────────────────────────────────────────────────────
38
+
39
+ const BLOCK_BEGIN = "<!-- BEGIN MEMTRACE BLOCK · managed by `memtrace install` · remove with `memtrace uninstall` -->";
40
+ const BLOCK_END = "<!-- END MEMTRACE BLOCK -->";
41
+ const BLOCK_VERSION = "0.3.35";
42
+
43
+ const MEMTRACE_MD_HEADER = `<!-- This file is managed by \`memtrace install\`.
44
+ User edits here will be overwritten on next install.
45
+ For your own Claude Code directives, edit CLAUDE.md instead. -->
46
+ `;
47
+
48
+ const MEMTRACE_MD_BODY = `# Memtrace is active on this machine
49
+
50
+ For ANY code-discovery question — finding symbols, callers, impacts,
51
+ evolution, "where is", "how does", "what calls", "why does",
52
+ "trace through", "find the function that…" — invoke
53
+ \`mcp__memtrace__find_symbol\` or \`mcp__memtrace__find_code\` FIRST.
54
+ Memtrace returns exact \`file:start_line:end_line\` in one call.
55
+
56
+ After a search hit, follow up with the graph tools that file/grep can't
57
+ match: \`mcp__memtrace__get_symbol_context\` for callers/callees,
58
+ \`mcp__memtrace__get_impact\` for blast radius,
59
+ \`mcp__memtrace__get_evolution\` for history. Read source ONLY at the
60
+ exact spans Memtrace returned, with a few lines of context.
61
+
62
+ \`Read\` / \`Grep\` / \`Glob\` remain correct ONLY for:
63
+
64
+ - Config files (\`.env\`, \`package.json\`, README, raw JSON/YAML/TOML)
65
+ - File-inventory questions ("how many \`*.test.ts\` files exist")
66
+ - Files confirmed outside every indexed repo
67
+ - Reading exact lines you already have from a Memtrace result
68
+
69
+ If Memtrace returns 0 results, broaden the query or call
70
+ \`mcp__memtrace__list_indexed_repositories\` to confirm scope. Zero
71
+ results, missing language stats, or partial-looking output are NOT
72
+ permission to silently fall back to \`Grep\`.
73
+
74
+ This file is generated by \`memtrace install\` and removed by
75
+ \`memtrace uninstall\`. Edit CLAUDE.md for your own Claude Code rules
76
+ — that file is not touched (we only append a single breadcrumb line
77
+ pointing here, inside our marker block).
78
+ `;
79
+
80
+ const CLAUDE_MD_BREADCRUMB_LINES = [
81
+ BLOCK_BEGIN,
82
+ `<!-- block-version: ${BLOCK_VERSION} -->`,
83
+ "> 🧠 Memtrace is installed on this machine. **For code-discovery questions, call \\`mcp__memtrace__find_symbol\\` or \\`mcp__memtrace__find_code\\` BEFORE \\`Read\\` / \\`Grep\\` / \\`Glob\\`** — see [`MEMTRACE.md`](./MEMTRACE.md) for the full rule.",
84
+ BLOCK_END,
85
+ ];
86
+ const CLAUDE_MD_BREADCRUMB = CLAUDE_MD_BREADCRUMB_LINES.join("\n");
87
+
88
+ const HOOK_MANAGED_TAG = "memtrace";
89
+
90
+ // ── Pure functions: MEMTRACE.md content ───────────────────────────────
91
+
92
+ /**
93
+ * Build the canonical MEMTRACE.md body. Pure.
94
+ * @returns {string}
95
+ */
96
+ function buildMemtraceMdContent() {
97
+ return MEMTRACE_MD_HEADER + "\n" + MEMTRACE_MD_BODY;
98
+ }
99
+
100
+ // ── Pure functions: CLAUDE.md sentinel-block management ───────────────
101
+
102
+ /**
103
+ * Add or refresh the Memtrace breadcrumb block in an existing
104
+ * CLAUDE.md. Pure — takes current content, returns new content.
105
+ *
106
+ * Strict-symmetry design: the bytes WE own are exactly
107
+ * `CLAUDE_MD_BREADCRUMB + "\n"`
108
+ * and we always append them at the very end of the file (or
109
+ * replace an existing block in place with the same shape). We
110
+ * never add a leading separator; the user's content is left
111
+ * byte-identical above us. This makes `apply` and `remove` exact
112
+ * inverses for any user content (idempotency + round-trip
113
+ * byte-identity hold).
114
+ *
115
+ * - No existing block: append `CLAUDE_MD_BREADCRUMB + "\n"`.
116
+ * - Existing block: replace the inclusive span
117
+ * BLOCK_BEGIN..BLOCK_END(+optional \n)
118
+ * with `CLAUDE_MD_BREADCRUMB + "\n"`.
119
+ *
120
+ * @param {string} existing
121
+ * @returns {string}
122
+ */
123
+ function applyClaudeMdBreadcrumb(existing) {
124
+ const beginIdx = existing.indexOf(BLOCK_BEGIN);
125
+ if (beginIdx === -1) {
126
+ return existing + CLAUDE_MD_BREADCRUMB + "\n";
127
+ }
128
+ const endIdx = existing.indexOf(BLOCK_END, beginIdx);
129
+ if (endIdx === -1) {
130
+ throw new Error(
131
+ "CLAUDE.md contains a BEGIN MEMTRACE BLOCK marker without a matching END marker. " +
132
+ "Repair the file manually or delete the partial block before retrying."
133
+ );
134
+ }
135
+ const blockEnd = endIdx + BLOCK_END.length;
136
+ const tailNewline = existing.charAt(blockEnd) === "\n" ? 1 : 0;
137
+ return (
138
+ existing.slice(0, beginIdx) +
139
+ CLAUDE_MD_BREADCRUMB + "\n" +
140
+ existing.slice(blockEnd + tailNewline)
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Remove the Memtrace breadcrumb block from CLAUDE.md content.
146
+ * Pure. Idempotent. Strict inverse of `applyClaudeMdBreadcrumb`:
147
+ * removes exactly the bytes apply added, leaving everything else
148
+ * byte-identical.
149
+ *
150
+ * @param {string} existing
151
+ * @returns {string}
152
+ */
153
+ function removeClaudeMdBreadcrumb(existing) {
154
+ const beginIdx = existing.indexOf(BLOCK_BEGIN);
155
+ if (beginIdx === -1) return existing;
156
+ const endIdx = existing.indexOf(BLOCK_END, beginIdx);
157
+ if (endIdx === -1) {
158
+ throw new Error(
159
+ "CLAUDE.md contains a BEGIN MEMTRACE BLOCK marker without a matching END marker. " +
160
+ "Repair the file manually before retrying."
161
+ );
162
+ }
163
+ const blockEnd = endIdx + BLOCK_END.length;
164
+ const tailNewline = existing.charAt(blockEnd) === "\n" ? 1 : 0;
165
+ return existing.slice(0, beginIdx) + existing.slice(blockEnd + tailNewline);
166
+ }
167
+
168
+ // ── Pure functions: settings.json hook merge ──────────────────────────
169
+
170
+ /**
171
+ * Add Memtrace's hook entries to a parsed settings.json object.
172
+ * Pure — does NOT touch the filesystem; works on objects.
173
+ *
174
+ * The added hook entries carry `_managed_by: "memtrace"` so the
175
+ * uninstall path can identify and remove only our entries.
176
+ *
177
+ * @param {object} settings parsed contents of settings.json (may be {})
178
+ * @param {string} hooksDir absolute directory containing the hook scripts
179
+ * @returns {object} new settings object (deep-copied)
180
+ */
181
+ function applySettingsHooks(settings, hooksDir) {
182
+ const out = JSON.parse(JSON.stringify(settings || {}));
183
+ out.hooks = out.hooks || {};
184
+
185
+ const userPromptHook = {
186
+ type: "command",
187
+ command: path.join(hooksDir, "userprompt-claude.sh"),
188
+ _managed_by: HOOK_MANAGED_TAG,
189
+ _hook_kind: "userprompt-advisory",
190
+ };
191
+ const postToolHook = {
192
+ type: "command",
193
+ command: path.join(hooksDir, "posttool-mcp-telemetry.sh"),
194
+ _managed_by: HOOK_MANAGED_TAG,
195
+ _hook_kind: "posttool-mcp-telemetry",
196
+ };
197
+
198
+ // UserPromptSubmit — single matcher-less entry.
199
+ out.hooks.UserPromptSubmit = mergeHookList(
200
+ out.hooks.UserPromptSubmit || [],
201
+ { hooks: [userPromptHook] },
202
+ userPromptHook,
203
+ );
204
+
205
+ // PostToolUse — matcher narrowed to our MCP tools.
206
+ out.hooks.PostToolUse = mergeHookList(
207
+ out.hooks.PostToolUse || [],
208
+ { matcher: "mcp__memtrace__.*", hooks: [postToolHook] },
209
+ postToolHook,
210
+ );
211
+
212
+ return out;
213
+ }
214
+
215
+ /**
216
+ * Merge our hook into an existing hooks array. If a memtrace-managed
217
+ * entry already exists in any of the matchers, we update it in place
218
+ * (keep the surrounding matcher block) instead of duplicating.
219
+ *
220
+ * @param {Array} existing
221
+ * @param {object} ourEntry shape `{ matcher?, hooks: [...] }`
222
+ * @param {object} ourHook the inner hook object (with _managed_by)
223
+ * @returns {Array}
224
+ */
225
+ function mergeHookList(existing, ourEntry, ourHook) {
226
+ const out = existing.map((block) => ({ ...block }));
227
+ let foundManagedBlock = -1;
228
+ for (let i = 0; i < out.length; i++) {
229
+ const block = out[i];
230
+ if (!block.hooks) continue;
231
+ const hasOurs = block.hooks.some(
232
+ (h) => h && h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind,
233
+ );
234
+ if (hasOurs) {
235
+ foundManagedBlock = i;
236
+ break;
237
+ }
238
+ }
239
+ if (foundManagedBlock === -1) {
240
+ // Add our block as a new entry.
241
+ out.push(ourEntry);
242
+ return out;
243
+ }
244
+ // Replace just our hook entries inside the existing block, keep
245
+ // any user-added hooks in the same matcher untouched.
246
+ const block = { ...out[foundManagedBlock] };
247
+ block.hooks = block.hooks.filter(
248
+ (h) => !(h && h._managed_by === HOOK_MANAGED_TAG && h._hook_kind === ourHook._hook_kind),
249
+ );
250
+ block.hooks.push(ourHook);
251
+ out[foundManagedBlock] = block;
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * Remove all memtrace-managed hook entries from a parsed settings.json.
257
+ * Pure. Walks every hooks event type, drops entries tagged
258
+ * `_managed_by: "memtrace"`, and prunes empty containers.
259
+ *
260
+ * @param {object} settings
261
+ * @returns {object}
262
+ */
263
+ function removeSettingsHooks(settings) {
264
+ const out = JSON.parse(JSON.stringify(settings || {}));
265
+ if (!out.hooks) return out;
266
+
267
+ for (const eventName of Object.keys(out.hooks)) {
268
+ const blocks = out.hooks[eventName];
269
+ if (!Array.isArray(blocks)) continue;
270
+ const cleaned = [];
271
+ for (const block of blocks) {
272
+ if (!block || !Array.isArray(block.hooks)) {
273
+ cleaned.push(block);
274
+ continue;
275
+ }
276
+ const filteredHooks = block.hooks.filter(
277
+ (h) => !(h && h._managed_by === HOOK_MANAGED_TAG),
278
+ );
279
+ if (filteredHooks.length === 0) {
280
+ // Block was entirely ours — drop it.
281
+ continue;
282
+ }
283
+ cleaned.push({ ...block, hooks: filteredHooks });
284
+ }
285
+ if (cleaned.length === 0) {
286
+ delete out.hooks[eventName];
287
+ } else {
288
+ out.hooks[eventName] = cleaned;
289
+ }
290
+ }
291
+ if (out.hooks && Object.keys(out.hooks).length === 0) {
292
+ delete out.hooks;
293
+ }
294
+ return out;
295
+ }
296
+
297
+ // ── Side-effecting wrappers for the installer entry point ─────────────
298
+
299
+ /**
300
+ * Install Memtrace's Claude Code integration: write MEMTRACE.md,
301
+ * breadcrumb CLAUDE.md if it exists, register hooks in settings.json.
302
+ *
303
+ * @param {object} [opts]
304
+ * @param {string} [opts.claudeDir] default `~/.claude`
305
+ * @param {string} [opts.hooksDir] absolute dir where hook scripts live
306
+ * @returns {object} summary `{ wrote: [...], skipped: [...] }`
307
+ */
308
+ function installToFs(opts = {}) {
309
+ const claudeDir = opts.claudeDir || path.join(os.homedir(), ".claude");
310
+ const hooksDir = opts.hooksDir;
311
+ if (!hooksDir) {
312
+ throw new Error("installToFs: opts.hooksDir is required (absolute path to hooks/)");
313
+ }
314
+
315
+ const summary = { wrote: [], skipped: [] };
316
+
317
+ fs.mkdirSync(claudeDir, { recursive: true });
318
+
319
+ // 1. Write MEMTRACE.md (always overwrite — we own this file).
320
+ const memtraceMdPath = path.join(claudeDir, "MEMTRACE.md");
321
+ fs.writeFileSync(memtraceMdPath, buildMemtraceMdContent());
322
+ summary.wrote.push(memtraceMdPath);
323
+
324
+ // 2. Breadcrumb in CLAUDE.md (only if file exists; we don't create it).
325
+ const claudeMdPath = path.join(claudeDir, "CLAUDE.md");
326
+ if (fs.existsSync(claudeMdPath)) {
327
+ const existing = fs.readFileSync(claudeMdPath, "utf8");
328
+ const updated = applyClaudeMdBreadcrumb(existing);
329
+ if (updated !== existing) {
330
+ fs.writeFileSync(claudeMdPath, updated);
331
+ summary.wrote.push(claudeMdPath);
332
+ } else {
333
+ summary.skipped.push(claudeMdPath + " (already up to date)");
334
+ }
335
+ } else {
336
+ summary.skipped.push(claudeMdPath + " (does not exist; not creating)");
337
+ }
338
+
339
+ // 3. Register hooks in settings.json.
340
+ const settingsPath = path.join(claudeDir, "settings.json");
341
+ let settings = {};
342
+ if (fs.existsSync(settingsPath)) {
343
+ try {
344
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
345
+ } catch (e) {
346
+ throw new Error(
347
+ `Failed to parse ${settingsPath}: ${e.message}. ` +
348
+ "Repair manually before retrying."
349
+ );
350
+ }
351
+ }
352
+ const updated = applySettingsHooks(settings, hooksDir);
353
+ fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
354
+ summary.wrote.push(settingsPath);
355
+
356
+ return summary;
357
+ }
358
+
359
+ /**
360
+ * Reverse of installToFs. Removes MEMTRACE.md, removes our
361
+ * breadcrumb from CLAUDE.md, removes our hooks from settings.json.
362
+ * Leaves all user-authored content intact.
363
+ *
364
+ * @param {object} [opts]
365
+ * @param {string} [opts.claudeDir] default `~/.claude`
366
+ * @returns {object} summary `{ removed: [...], skipped: [...] }`
367
+ */
368
+ function uninstallFromFs(opts = {}) {
369
+ const claudeDir = opts.claudeDir || path.join(os.homedir(), ".claude");
370
+ const summary = { removed: [], skipped: [] };
371
+
372
+ // 1. Remove MEMTRACE.md.
373
+ const memtraceMdPath = path.join(claudeDir, "MEMTRACE.md");
374
+ if (fs.existsSync(memtraceMdPath)) {
375
+ fs.unlinkSync(memtraceMdPath);
376
+ summary.removed.push(memtraceMdPath);
377
+ } else {
378
+ summary.skipped.push(memtraceMdPath + " (not present)");
379
+ }
380
+
381
+ // 2. Strip breadcrumb from CLAUDE.md.
382
+ const claudeMdPath = path.join(claudeDir, "CLAUDE.md");
383
+ if (fs.existsSync(claudeMdPath)) {
384
+ const existing = fs.readFileSync(claudeMdPath, "utf8");
385
+ const updated = removeClaudeMdBreadcrumb(existing);
386
+ if (updated === "") {
387
+ // Our breadcrumb was the only content — remove the file.
388
+ fs.unlinkSync(claudeMdPath);
389
+ summary.removed.push(claudeMdPath + " (file was empty after cleanup)");
390
+ } else if (updated !== existing) {
391
+ fs.writeFileSync(claudeMdPath, updated);
392
+ summary.removed.push(claudeMdPath + " (breadcrumb removed)");
393
+ } else {
394
+ summary.skipped.push(claudeMdPath + " (no memtrace breadcrumb found)");
395
+ }
396
+ } else {
397
+ summary.skipped.push(claudeMdPath + " (not present)");
398
+ }
399
+
400
+ // 3. Strip our hooks from settings.json.
401
+ const settingsPath = path.join(claudeDir, "settings.json");
402
+ if (fs.existsSync(settingsPath)) {
403
+ try {
404
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
405
+ const updated = removeSettingsHooks(settings);
406
+ const before = JSON.stringify(settings);
407
+ const after = JSON.stringify(updated);
408
+ if (before !== after) {
409
+ if (Object.keys(updated).length === 0) {
410
+ fs.unlinkSync(settingsPath);
411
+ summary.removed.push(settingsPath + " (file was empty after cleanup)");
412
+ } else {
413
+ fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n");
414
+ summary.removed.push(settingsPath + " (memtrace hook entries removed)");
415
+ }
416
+ } else {
417
+ summary.skipped.push(settingsPath + " (no memtrace hook entries found)");
418
+ }
419
+ } catch (e) {
420
+ summary.skipped.push(settingsPath + ` (parse error: ${e.message})`);
421
+ }
422
+ } else {
423
+ summary.skipped.push(settingsPath + " (not present)");
424
+ }
425
+
426
+ return summary;
427
+ }
428
+
429
+ module.exports = {
430
+ // Constants — exported for tests
431
+ BLOCK_BEGIN,
432
+ BLOCK_END,
433
+ BLOCK_VERSION,
434
+ HOOK_MANAGED_TAG,
435
+ CLAUDE_MD_BREADCRUMB,
436
+
437
+ // Pure functions
438
+ buildMemtraceMdContent,
439
+ applyClaudeMdBreadcrumb,
440
+ removeClaudeMdBreadcrumb,
441
+ applySettingsHooks,
442
+ removeSettingsHooks,
443
+
444
+ // Side-effecting entry points
445
+ installToFs,
446
+ uninstallFromFs,
447
+ };