memtrace 0.3.81 → 0.3.85

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
@@ -10,6 +10,12 @@ const { getBinaryPath } = require("../install.js");
10
10
  const { platformBinary, spawnOptionsForPlatform } = require("../lib/spawn-helper");
11
11
  const { shouldPromptForUpgrade, isPromptDisabled } = require("../lib/update-prompt");
12
12
  const { fetchLatestVersion, readCachedVersion } = require("../lib/update-check");
13
+ // ─── Honest-lifecycle (Leaf B / fortress-round-1 T5) ──────────────────────
14
+ // Pure exit-code translator — pins child(0) → 0, child(75) → 75,
15
+ // SIGKILL → 137, SIGTERM → 143, SIGINT → 130. Pre-fix the shim
16
+ // translated SIGKILL → 1 (mcp branch) or → 0 (sync branch) which
17
+ // is the silent-disappearance bug from /tmp/mt-debug/exit.
18
+ const { propagateChildExit } = require("../lib/exit-code");
13
19
 
14
20
  // ── Handle `memtrace uninstall` before delegating to the Rust binary ────────
15
21
  // npm v7+ does NOT fire preuninstall hooks for global packages (npm/cli#3042).
@@ -37,6 +43,29 @@ if (args[0] === "install" || args[0] === "update" || args[0] === "upgrade") {
37
43
  const npmCmd = platformBinary("npm", process.platform);
38
44
  const memtraceCmd = platformBinary("memtrace", process.platform);
39
45
 
46
+ // ── Pre-commit hook drift check (agent-precommit-optin, 2026-05-08) ──
47
+ // `memtrace install` is the natural place a user re-runs after an
48
+ // upgrade. If a prior version installed the pre-commit hook (now
49
+ // opt-in by default), surface a one-line note so partners hitting
50
+ // the ~2 min agentic-pipeline tax know the off-ramp. We DO NOT
51
+ // remove the hook automatically — that's the user's call.
52
+ try {
53
+ const cwdHook = path.join(process.cwd(), ".git", "hooks", "pre-commit");
54
+ if (fs.existsSync(cwdHook)) {
55
+ const body = fs.readFileSync(cwdHook, "utf8");
56
+ // Match the canonical BEGIN marker we ship in hooks.rs.
57
+ if (body.includes("BEGIN MEMTRACE BLOCK")) {
58
+ process.stdout.write(
59
+ "memtrace: pre-commit hook is installed in this repo — " +
60
+ "uninstall with `memtrace uninstall-hooks --pre-commit` " +
61
+ "if it's slowing your agentic pipeline.\n"
62
+ );
63
+ }
64
+ }
65
+ } catch (_) {
66
+ // Best-effort — never fail the install on a hook-detection hiccup.
67
+ }
68
+
40
69
  process.stdout.write("memtrace: fetching latest from npm registry…\n");
41
70
  const installResult = spawnSync(
42
71
  npmCmd,
@@ -269,7 +298,12 @@ if (args[0] === "mcp") {
269
298
  process.exit(1);
270
299
  });
271
300
  child.on("exit", (code, signal) => {
272
- process.exit(signal ? 1 : (code ?? 0));
301
+ // ─── Leaf B T5 ─── propagate the child's typed exit code (or
302
+ // signal-derived 128+sig) so CI / supervisors can grep $? and
303
+ // distinguish exit 75 (Phase-2 partial) from SIGKILL (137) from a
304
+ // clean exit (0). Pre-fix this folded SIGKILL into 1 → silent jet-
305
+ // sam disappearance.
306
+ process.exit(propagateChildExit({ code, signal }));
273
307
  });
274
308
 
275
309
  } else {
@@ -296,6 +330,9 @@ if (args[0] === "mcp") {
296
330
  console.error(`Failed to run memtrace: ${result.error.message}`);
297
331
  process.exit(1);
298
332
  }
299
- process.exit(result.status ?? 0);
333
+ // ─── Leaf B T5 ─── same propagation contract as the mcp branch.
334
+ // `spawnSync` returns `{status, signal}` — `status` is null when
335
+ // the child was killed; `?? 0` would silently drop signal info.
336
+ process.exit(propagateChildExit({ code: result.status, signal: result.signal }));
300
337
  })();
301
338
  }
@@ -14,6 +14,16 @@
14
14
  # not on which file the model chose to grep
15
15
  # - non-blocking → just adds context, never denies a tool call
16
16
  #
17
+ # Per-session debounce (round-2 G4 / agent-I):
18
+ # The hook still fires once per user turn, but in a long Orbit-
19
+ # style automated session that's dozens of fires per prompt.
20
+ # Each fire pings the daemon health endpoint — cheap individually,
21
+ # death-by-thousand-cuts in aggregate. We add a per-session lock
22
+ # file at $HOME/.memtrace/hook-debounce/<session_id>.lock; if its
23
+ # mtime is within MEMTRACE_HOOK_DEBOUNCE_SECS (default 120s) we
24
+ # short-circuit to a no-op-but-well-formed JSON output and skip
25
+ # the daemon probe entirely.
26
+ #
17
27
  # Exit codes:
18
28
  # 0 : success (stdout is parsed for hook output)
19
29
  # 2 : would block the prompt (we never want this)
@@ -22,14 +32,161 @@
22
32
  # { "decision": "continue", "additionalContext": "..." }
23
33
  #
24
34
  # Override:
25
- # MEMTRACE_HOOK_MODE=off → unconditional no-op
26
- # MEMTRACE_HEALTH_URL=... → custom health endpoint (default 3030)
35
+ # MEMTRACE_HOOK_MODE=off → unconditional no-op (skips lock too)
36
+ # MEMTRACE_HEALTH_URL=... → custom health endpoint (default 3030)
37
+ # MEMTRACE_HOOK_DEBOUNCE_SECS=120 → debounce window seconds (0 disables)
38
+ # MEMTRACE_HOOK_DEBOUNCE_DIR=... → override lock dir (default $HOME/.memtrace/hook-debounce)
39
+ # CLAUDE_SESSION_ID / CLAUDE_CONVERSATION_ID → session id sources
27
40
  #
28
41
  set -euo pipefail
29
42
 
30
43
  mode="${MEMTRACE_HOOK_MODE:-advisory}"
31
44
  [[ "$mode" == "off" ]] && exit 0
32
45
 
46
+ # ── Session-id resolution (G4.2) ────────────────────────────────────
47
+ #
48
+ # Source priority:
49
+ # 1. CLAUDE_SESSION_ID env (preferred — Claude Code may set this)
50
+ # 2. CLAUDE_CONVERSATION_ID env (alternate naming)
51
+ # 3. fallback: stable hash of $PPID + parent process start time
52
+ # (so the same parent process tree always resolves to the same
53
+ # id, but a new shell parent gets its own id).
54
+ #
55
+ # Output is sanitised (only [A-Za-z0-9_-]) so it's safe to use as a
56
+ # filename component on every supported OS.
57
+ resolve_session_id() {
58
+ local raw=""
59
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
60
+ raw="$CLAUDE_SESSION_ID"
61
+ elif [[ -n "${CLAUDE_CONVERSATION_ID:-}" ]]; then
62
+ raw="$CLAUDE_CONVERSATION_ID"
63
+ else
64
+ # Hash of PPID + parent start-time. ps prints lstart for
65
+ # the parent process; combined with PPID this is stable
66
+ # within the parent's lifetime and changes when a new
67
+ # parent process is spawned.
68
+ local ppid="${PPID:-0}"
69
+ local pstart=""
70
+ pstart="$(ps -o lstart= -p "$ppid" 2>/dev/null || true)"
71
+ # Fold to a short hash so the lock file name stays short.
72
+ # md5/shasum availability varies; prefer shasum (POSIX-ish),
73
+ # fall back to a python one-liner, then to the raw string.
74
+ local input="ppid=${ppid};start=${pstart}"
75
+ if command -v shasum >/dev/null 2>&1; then
76
+ raw="$(printf '%s' "$input" | shasum -a 1 | awk '{print $1}')"
77
+ elif command -v sha1sum >/dev/null 2>&1; then
78
+ raw="$(printf '%s' "$input" | sha1sum | awk '{print $1}')"
79
+ elif command -v python3 >/dev/null 2>&1; then
80
+ raw="$(printf '%s' "$input" | python3 -c '
81
+ import hashlib, sys
82
+ print(hashlib.sha1(sys.stdin.buffer.read()).hexdigest())
83
+ ' 2>/dev/null || true)"
84
+ else
85
+ raw="$input"
86
+ fi
87
+ fi
88
+
89
+ # Sanitise for filesystem safety: keep only [A-Za-z0-9_-], replace
90
+ # everything else with `_`. Also collapse to at most 128 chars so
91
+ # we don't blow path-length limits.
92
+ local cleaned
93
+ cleaned="$(printf '%s' "$raw" | tr -c 'A-Za-z0-9_-' '_' | cut -c1-128)"
94
+ if [[ -z "$cleaned" ]]; then
95
+ cleaned="unknown_session"
96
+ fi
97
+ printf '%s' "$cleaned"
98
+ }
99
+
100
+ # ── Debounce window parsing ─────────────────────────────────────────
101
+ #
102
+ # Validates that MEMTRACE_HOOK_DEBOUNCE_SECS is a non-negative
103
+ # integer. Anything malformed falls back to 120s. A literal `0`
104
+ # disables debounce entirely (every fire proceeds).
105
+ parse_debounce_secs() {
106
+ local raw="${MEMTRACE_HOOK_DEBOUNCE_SECS:-120}"
107
+ if [[ "$raw" =~ ^[0-9]+$ ]]; then
108
+ printf '%s' "$raw"
109
+ else
110
+ printf '%s' "120"
111
+ fi
112
+ }
113
+
114
+ # ── Orphan cleanup (G4.5) ───────────────────────────────────────────
115
+ #
116
+ # Opportunistic + bounded: at hook entry, remove lock files older
117
+ # than 24h, but cap how many we touch per fire so we don't stat
118
+ # thousands of files on every prompt. `find ... -mtime +1 -delete`
119
+ # is the portable form (BSD + GNU find both support it). We pipe
120
+ # through `head -n N` to bound the work.
121
+ ORPHAN_CLEANUP_MAX="${MEMTRACE_HOOK_ORPHAN_CLEANUP_MAX:-32}"
122
+ orphan_cleanup() {
123
+ local dir="$1"
124
+ local max="$2"
125
+ [[ -d "$dir" ]] || return 0
126
+ # `-mmin +1440` matches files modified more than 1440 minutes
127
+ # (24h) ago. We deliberately use mmin (not -mtime +1) because
128
+ # BSD `find` truncates `-mtime` to whole days then strict-
129
+ # compares — so a 25h-old file does NOT match `-mtime +1`. The
130
+ # mmin form is unambiguous on both BSD (macOS) and GNU (Linux).
131
+ # We list candidates, head-bound them, then rm. This avoids
132
+ # walking a giant directory linearly on every fire.
133
+ local f
134
+ while IFS= read -r f; do
135
+ [[ -z "$f" ]] && continue
136
+ rm -f -- "$f" 2>/dev/null || true
137
+ done < <(find "$dir" -maxdepth 1 -type f -name '*.lock' -mmin +1440 2>/dev/null | head -n "$max")
138
+ }
139
+
140
+ # ── Lock-file gate ──────────────────────────────────────────────────
141
+ LOCK_DIR="${MEMTRACE_HOOK_DEBOUNCE_DIR:-${HOME:-/tmp}/.memtrace/hook-debounce}"
142
+ DEBOUNCE_SECS="$(parse_debounce_secs)"
143
+
144
+ # Ensure lock dir exists. If we can't create it (read-only home,
145
+ # permission denied, etc.) the hook still works — we just skip the
146
+ # debounce gate this fire.
147
+ mkdir -p "$LOCK_DIR" 2>/dev/null || true
148
+
149
+ # Cleanup orphans BEFORE evaluating the gate so a stale lock that's
150
+ # been orphaned for 24h+ doesn't accidentally suppress the fire.
151
+ if [[ -d "$LOCK_DIR" ]]; then
152
+ orphan_cleanup "$LOCK_DIR" "$ORPHAN_CLEANUP_MAX"
153
+ fi
154
+
155
+ SESSION_ID="$(resolve_session_id)"
156
+ LOCK_FILE="$LOCK_DIR/$SESSION_ID.lock"
157
+
158
+ # Debounce gate: if lock exists and is fresh, short-circuit.
159
+ # DEBOUNCE_SECS==0 is the explicit disable knob; we skip the gate
160
+ # entirely so every fire proceeds (useful for debugging & tests).
161
+ if (( DEBOUNCE_SECS > 0 )) && [[ -f "$LOCK_FILE" ]]; then
162
+ NOW=$(date +%s)
163
+ # `stat -f %m` is BSD/macOS, `stat -c %Y` is GNU/Linux. Try
164
+ # both; if neither works (extremely unusual) we treat the lock
165
+ # as fresh enough to suppress, on the principle that "we just
166
+ # touched it" is the safer default than "spam the daemon".
167
+ LAST="$(stat -f %m "$LOCK_FILE" 2>/dev/null || stat -c %Y "$LOCK_FILE" 2>/dev/null || printf '%s' "$NOW")"
168
+ if [[ "$LAST" =~ ^[0-9]+$ ]]; then
169
+ AGE=$((NOW - LAST))
170
+ if (( AGE < DEBOUNCE_SECS )); then
171
+ # Within debounce window → emit a well-formed no-op
172
+ # hook output and exit. We do NOT probe the daemon.
173
+ cat <<'EOF'
174
+ {
175
+ "decision": "continue",
176
+ "additionalContext": ""
177
+ }
178
+ EOF
179
+ exit 0
180
+ fi
181
+ fi
182
+ fi
183
+
184
+ # Outside the window (or first fire): touch the lock and proceed.
185
+ # `touch` creates the file if missing and updates mtime if present;
186
+ # this is the canonical "I just fired" signal for the next gate
187
+ # evaluation in the same session.
188
+ touch "$LOCK_FILE" 2>/dev/null || true
189
+
33
190
  # ── Daemon liveness (portable: works on macOS/Linux/Windows-WSL) ──
34
191
  #
35
192
  # We use the Memtrace UI's status endpoint instead of `pgrep` so
package/install.js CHANGED
@@ -293,6 +293,26 @@ if (require.main === module) {
293
293
  } catch (e) {
294
294
  console.warn(`memtrace: failed to persist uninstall script: ${e.message}`);
295
295
  }
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 */ }
296
316
  }
297
317
 
298
318
  module.exports = {
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+
3
+ // ─── Honest-lifecycle (Leaf B / fortress-round-1 T5) ─────────────────────────
4
+ //
5
+ // The npm shim spawns the native `memtrace` binary as a child process and
6
+ // MUST propagate the child's exit code (or signal-derived equivalent) so
7
+ // CI / supervisors can distinguish a clean exit (0), Phase-2 partial
8
+ // failure (75 / EX_TEMPFAIL — the new contract from cmd_start), and
9
+ // signal-driven exits (SIGKILL → 137, SIGINT → 130, etc.).
10
+ //
11
+ // Pre-fix shipped two divergent behaviours:
12
+ // * `mcp` branch: `process.exit(signal ? 1 : (code ?? 0))` —
13
+ // SIGKILL became exit 1, NOT 137. That's the silent-disappearance
14
+ // bug from /tmp/mt-debug/exit: macOS jetsam'd the embedder, the
15
+ // shim returned 0/1, and the user saw "memtrace silently
16
+ // disappeared" with no way to grep the exit code.
17
+ // * non-mcp `spawnSync` branch: `process.exit(result.status ?? 0)`
18
+ // — `result.status` is null when the child was killed by signal,
19
+ // and `?? 0` collapses that to a clean exit. Same bug, different
20
+ // surface.
21
+ //
22
+ // The contract this file pins (rows B-013 / B-014 / B-015 / B-022):
23
+ // * child exit(0) → shim exit 0
24
+ // * child exit(75) → shim exit 75
25
+ // * child killed by SIGKILL → shim exit 137 (= 128 + 9)
26
+ // * child killed by SIGTERM → shim exit 143 (= 128 + 15)
27
+ // * child killed by SIGINT → shim exit 130 (= 128 + 2)
28
+ //
29
+ // Pure function; safe to require from anywhere; no side effects.
30
+
31
+ // Numeric signal map for the platforms the shim runs on (POSIX). On
32
+ // Windows, `child.kill(signal)` is best-effort and the OS doesn't
33
+ // surface a real signal number — the helper falls back to 1 (the
34
+ // generic failure code) when the signal name isn't recognised.
35
+ const SIGNAL_NUMBERS = Object.freeze({
36
+ SIGHUP: 1,
37
+ SIGINT: 2,
38
+ SIGQUIT: 3,
39
+ SIGILL: 4,
40
+ SIGTRAP: 5,
41
+ SIGABRT: 6,
42
+ SIGBUS: 7,
43
+ SIGFPE: 8,
44
+ SIGKILL: 9,
45
+ SIGUSR1: 10,
46
+ SIGSEGV: 11,
47
+ SIGUSR2: 12,
48
+ SIGPIPE: 13,
49
+ SIGALRM: 14,
50
+ SIGTERM: 15,
51
+ SIGCHLD: 17,
52
+ SIGCONT: 18,
53
+ SIGSTOP: 19,
54
+ SIGTSTP: 20,
55
+ SIGTTIN: 21,
56
+ SIGTTOU: 22,
57
+ });
58
+
59
+ /**
60
+ * Pure decision: given the `(code, signal)` shape Node hands back from
61
+ * `child.on('exit')` or `spawnSync`'s `{status, signal}`, return the
62
+ * exit code the shim should pass to `process.exit`.
63
+ *
64
+ * @param {object} args
65
+ * @param {number|null|undefined} args.code Numeric exit code, or null when killed by signal.
66
+ * @param {string|null|undefined} args.signal Signal name (e.g. "SIGKILL"), or null on clean exit.
67
+ * @returns {number} Exit code to forward to `process.exit`.
68
+ */
69
+ function propagateChildExit({ code, signal } = {}) {
70
+ // Clean exit takes priority. Node guarantees `code` is non-null
71
+ // when the child exited normally, even if it exited with 0.
72
+ if (typeof code === "number") {
73
+ // Sanitise: process.exit clamps to [0, 255] anyway, but pin
74
+ // the contract so ill-behaved children producing > 255 don't
75
+ // confuse the supervisor.
76
+ return code & 0xff;
77
+ }
78
+ // Signal-driven exit. POSIX shells use `128 + signal_number` as
79
+ // the exit-status convention so callers can disambiguate "child
80
+ // returned 9" from "child got SIGKILL". We follow the same rule.
81
+ if (signal && typeof signal === "string") {
82
+ const n = SIGNAL_NUMBERS[signal];
83
+ if (typeof n === "number") {
84
+ return 128 + n;
85
+ }
86
+ // Unknown signal name (Windows, exotic platforms): fall back
87
+ // to 1 — generic failure — rather than guessing.
88
+ return 1;
89
+ }
90
+ // Neither code nor signal — should not happen on Node ≥ 0.12,
91
+ // but pin a deterministic default so the shim never silently
92
+ // exits 0 on a malformed child shape.
93
+ return 1;
94
+ }
95
+
96
+ module.exports = {
97
+ propagateChildExit,
98
+ SIGNAL_NUMBERS,
99
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrace",
3
- "version": "0.3.81",
3
+ "version": "v0.3.85",
4
4
  "description": "Code intelligence graph — MCP server + AI agent skills + visualization UI",
5
5
  "keywords": [
6
6
  "mcp",