@rulemetric/hooks 0.6.9 → 0.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/hooks",
3
- "version": "0.6.9",
3
+ "version": "0.7.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,7 +14,19 @@ _rulemetric_config_dir() {
14
14
  printf '%s' "$RULEMETRIC_CONFIG_DIR"
15
15
  return
16
16
  fi
17
- printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}/rulemetric"
17
+ # XDG-aware, BUT the writers (`rulemetric auth login`, the Python reporter)
18
+ # always use ~/.config/rulemetric. On a Linux host with XDG_CONFIG_HOME set
19
+ # somewhere else, honoring XDG alone means the hooks look where login never
20
+ # wrote — no credentials, capture silently dead. Prefer the XDG dir only if
21
+ # it actually holds a config artifact; otherwise fall back to where the CLI
22
+ # writes.
23
+ local xdg_dir="${XDG_CONFIG_HOME:-$HOME/.config}/rulemetric"
24
+ local default_dir="$HOME/.config/rulemetric"
25
+ if [ "$xdg_dir" != "$default_dir" ] && [ ! -e "$xdg_dir/env" ] && [ ! -e "$xdg_dir/auth.json" ] && [ -d "$default_dir" ]; then
26
+ printf '%s' "$default_dir"
27
+ return
28
+ fi
29
+ printf '%s' "$xdg_dir"
18
30
  }
19
31
 
20
32
  # Resolve the active org ID into RULEMETRIC_ORG_ID. Env beats file. A missing
@@ -62,7 +74,12 @@ _rulemetric_load_config() {
62
74
  # shellcheck source=/dev/null
63
75
  source "$config_dir/env"
64
76
  set +a
65
- if [ -n "${RULEMETRIC_API_URL:-}" ] && { [ -n "${RULEMETRIC_API_KEY:-}" ] || [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; }; then
77
+ if [ -n "${RULEMETRIC_API_KEY:-}" ] || [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; then
78
+ # Credentials are enough — default the API URL like the auth.json branch
79
+ # below. `auth login` against prod doesn't persist RULEMETRIC_API_URL, so
80
+ # requiring it here made every fresh npm install skip capture with
81
+ # "no_config" unless auth.json happened to survive.
82
+ export RULEMETRIC_API_URL="${RULEMETRIC_API_URL:-https://rulemetric.com}"
66
83
  _rulemetric_load_active_org
67
84
  return 0
68
85
  fi
@@ -159,14 +159,27 @@ _rulemetric_ensure_session() {
159
159
  --argjson runId "$run_id_arg" \
160
160
  '{tool: $tool, externalSessionId: $externalSessionId, projectPath: $projectPath, gitBranch: $gitBranch, metadata: $metadata, orgId: $orgId, runId: $runId}')
161
161
 
162
- local response
163
- response=$(curl -sf --noproxy '*' --max-time 4 -X POST "${RULEMETRIC_API_URL}/api/sessions" \
164
- -H "$AUTH_HEADER" \
165
- -H "Content-Type: application/json" \
166
- -d "$payload" \
167
- 2>/dev/null)
162
+ # Retry transient failures (503/429/network) instead of the old bare `curl -sf`,
163
+ # which silently dropped the whole session and every event that would have
164
+ # attached to it — on a single transient 502/503 during a load window. Uses the
165
+ # shared bounded-retry helper (echoes the 2xx body so we can read .id). Callers
166
+ # normally source _post.sh already; source it defensively if not.
167
+ if ! command -v _rulemetric_send_capture >/dev/null 2>&1; then
168
+ _RM_ES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
169
+ # shellcheck source=/dev/null
170
+ . "$_RM_ES_DIR/_post.sh" 2>/dev/null || true
171
+ fi
168
172
 
169
- if [ "$?" -ne 0 ]; then
173
+ local response
174
+ response=$(_rulemetric_send_capture POST "${RULEMETRIC_API_URL}/api/sessions" \
175
+ "$AUTH_HEADER" "$payload" "${1:-ensure-session}" 4)
176
+
177
+ if [ "$?" -ne 0 ] || [ -z "$response" ]; then
178
+ # No local log here — the caller's "skip no_rw_session" must stay the FIRST
179
+ # entry for its script (hooks.test.ts codifies that contract; an extra line
180
+ # here mis-classified the benign skip TWICE now). Remote forensics come from
181
+ # the availability beacon the capture helper fires (statusCode, server-side);
182
+ # local deep-debug remains via RULEMETRIC_HOOK_DEBUG.
170
183
  rmdir "$lock_dir" 2>/dev/null
171
184
  return 1
172
185
  fi
@@ -15,6 +15,21 @@
15
15
  # HOOK_TOOL_OUTPUT - Tool output as JSON
16
16
  # HOOK_TRANSCRIPT_PATH - Path to transcript file (empty if unavailable)
17
17
 
18
+ # Portable short content hash for synthetic session IDs. `shasum` is a perl
19
+ # script — absent on minimal Linux hosts (containers, slim distros), where its
20
+ # failure under `set -euo pipefail` killed Cursor/Copilot-CLI capture entirely.
21
+ # Prefer coreutils sha256sum (ubiquitous on Linux), then shasum (macOS), then
22
+ # cksum (POSIX — weaker, but the IDs only need stable uniqueness per input).
23
+ _rulemetric_short_hash() {
24
+ if command -v sha256sum >/dev/null 2>&1; then
25
+ printf '%s' "$1" | sha256sum | cut -c1-16
26
+ elif command -v shasum >/dev/null 2>&1; then
27
+ printf '%s' "$1" | shasum -a 256 | cut -c1-16
28
+ else
29
+ printf '%s' "$1" | cksum | tr -d ' \t' | cut -c1-16
30
+ fi
31
+ }
32
+
18
33
  _rulemetric_normalize() {
19
34
  local input="$1"
20
35
 
package/scripts/_post.sh CHANGED
@@ -18,6 +18,44 @@
18
18
  _RM_POST_MAX_ATTEMPTS="${RULEMETRIC_POST_MAX_ATTEMPTS:-3}"
19
19
  _RM_POST_RETRY_CAP_S="${RULEMETRIC_POST_RETRY_CAP_S:-2}"
20
20
 
21
+ # _rulemetric_beacon <tag> <http_code>
22
+ # Best-effort UNAUTHENTICATED availability beacon (POST /api/telemetry/cli-error,
23
+ # sent DIRECT with --noproxy — a broken local gateway is a prime cause of the
24
+ # failures this reports, so routing through it would suppress the signal). The
25
+ # hooks are the always-on capture component with NO other server-visible failure
26
+ # channel: when their sends exhaust retries, the only trace used to be a line in
27
+ # $TMPDIR — invisible for a remote device. Only availability classes beacon
28
+ # (5xx / network 000 / 408 / 429-exhausted); other 4xx is a rejection, not an
29
+ # outage. Rate-limited to once per hour per tag via a marker file (failures come
30
+ # in storms; one beacon per storm is the signal). Honors the telemetry opt-outs.
31
+ # Fire-and-forget: backgrounded, 2s bound, never fails the caller.
32
+ _rulemetric_beacon() {
33
+ local tag="$1" code="$2"
34
+ case "$code" in
35
+ 5??|000|408|429) ;;
36
+ *) return 0 ;;
37
+ esac
38
+ [ "${RULEMETRIC_NO_TELEMETRY:-0}" != "0" ] && return 0
39
+ [ "${DO_NOT_TRACK:-0}" != "0" ] && return 0
40
+ local marker_dir="${TMPDIR:-/tmp}/rulemetric" marker now mtime
41
+ marker="$marker_dir/beacon.$tag"
42
+ mkdir -p "$marker_dir" 2>/dev/null || return 0
43
+ if [ -f "$marker" ]; then
44
+ now=$(date +%s 2>/dev/null || echo 0)
45
+ mtime=$(stat -f %m "$marker" 2>/dev/null || stat -c %Y "$marker" 2>/dev/null || echo 0)
46
+ [ $((now - mtime)) -lt 3600 ] && return 0
47
+ fi
48
+ : > "$marker" 2>/dev/null || true
49
+ local code_int proxied
50
+ code_int=$((10#$code)) 2>/dev/null || code_int=0
51
+ proxied=$([ -n "${HTTPS_PROXY:-}" ] && echo true || echo false)
52
+ ( curl -s --noproxy '*' --max-time 2 -X POST \
53
+ "${RULEMETRIC_API_URL:-https://rulemetric.com}/api/telemetry/cli-error" \
54
+ -H 'Content-Type: application/json' \
55
+ --data-binary "{\"event\":\"hook_capture_failed\",\"command\":\"hooks:${tag}\",\"statusCode\":${code_int},\"proxied\":${proxied}}" \
56
+ > /dev/null 2>&1 || true ) &
57
+ }
58
+
21
59
  # _rulemetric_post_event <url> <auth_header> <payload> <log_tag> <seq>
22
60
  # Returns 0 on 2xx; 1 otherwise (after logging the final failure).
23
61
  _rulemetric_post_event() {
@@ -30,11 +68,11 @@ _rulemetric_post_event() {
30
68
  if [ -n "$hdr_file" ]; then
31
69
  body=$(curl -s --noproxy '*' --max-time 4 -D "$hdr_file" -w '\n%{http_code}' \
32
70
  -X POST "$url" -H "$auth" -H "Content-Type: application/json" \
33
- -d "$payload" 2>&1) || true
71
+ --data-binary "$payload" 2>&1) || true
34
72
  else
35
73
  body=$(curl -s --noproxy '*' --max-time 4 -w '\n%{http_code}' \
36
74
  -X POST "$url" -H "$auth" -H "Content-Type: application/json" \
37
- -d "$payload" 2>&1) || true
75
+ --data-binary "$payload" 2>&1) || true
38
76
  fi
39
77
  http_code=$(printf '%s' "$body" | tail -1)
40
78
  body=$(printf '%s' "$body" | sed '$d')
@@ -73,6 +111,7 @@ _rulemetric_post_event() {
73
111
  done
74
112
 
75
113
  _rulemetric_log "$tag" "error" "http_${http_code}_seq_${seq}_attempt_${attempt}: ${body}"
114
+ _rulemetric_beacon "$tag" "$http_code"
76
115
  [ -n "$hdr_file" ] && rm -f "$hdr_file"
77
116
  return 1
78
117
  }
@@ -94,14 +133,20 @@ _rulemetric_send() {
94
133
  hdr_file=$(mktemp "${TMPDIR:-/tmp}/rm-send.XXXXXX" 2>/dev/null || true)
95
134
 
96
135
  while [ "$attempt" -le "$max_attempts" ]; do
136
+ # --data-binary (not -d) so a payload of "@/path/to/file" streams the body
137
+ # FROM that file verbatim. Large bodies (session-end reimport can carry a
138
+ # multi-MB transcript) MUST go via @file: an inline argv element that big
139
+ # exceeds the kernel's per-arg exec limit and curl never even starts (E2BIG)
140
+ # — which silently dropped every long session's reimport. Inline JSON
141
+ # payloads (always starting "{") behave identically under --data-binary.
97
142
  if [ -n "$hdr_file" ]; then
98
143
  body=$(curl -s --noproxy '*' --max-time "$max_time" -D "$hdr_file" -w '\n%{http_code}' \
99
144
  -X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
100
- -d "$payload" 2>&1) || true
145
+ --data-binary "$payload" 2>&1) || true
101
146
  else
102
147
  body=$(curl -s --noproxy '*' --max-time "$max_time" -w '\n%{http_code}' \
103
148
  -X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
104
- -d "$payload" 2>&1) || true
149
+ --data-binary "$payload" 2>&1) || true
105
150
  fi
106
151
  http_code=$(printf '%s' "$body" | tail -1)
107
152
  body=$(printf '%s' "$body" | sed '$d')
@@ -138,6 +183,85 @@ _rulemetric_send() {
138
183
  done
139
184
 
140
185
  _rulemetric_log "$tag" "error" "http_${http_code}_attempt_${attempt}: ${body}"
186
+ _rulemetric_beacon "$tag" "$http_code"
187
+ [ -n "$hdr_file" ] && rm -f "$hdr_file"
188
+ return 1
189
+ }
190
+
191
+ # _rulemetric_send_capture <method> <url> <auth> <payload> <log_tag> [max_time_s] [max_attempts]
192
+ # Like _rulemetric_send, but ECHOES the 2xx response body to stdout so callers
193
+ # that must READ the response can. Its reason to exist: lazy session-create
194
+ # (_ensure-session.sh) needs the new session's `.id` from the response body, and
195
+ # previously used a bare `curl -sf` with NO retry — so a transient 503/429/502
196
+ # during the incident window silently dropped the whole session, and with it every
197
+ # event that would have attached to it (the session row never existed). This gives
198
+ # session-create the SAME bounded 503/429/network retry + capped Retry-After as the
199
+ # event path. 4xx is permanent (no retry). Returns 0 on 2xx (body on stdout); 1
200
+ # otherwise (nothing on stdout). Stays SILENT on failure — the caller inspects
201
+ # the return value and logs its own outcome (e.g. session-create → skip).
202
+ _rulemetric_send_capture() {
203
+ local method="$1" url="$2" auth="$3" payload="$4" tag="$5" max_time="${6:-4}" max_attempts="${7:-$_RM_POST_MAX_ATTEMPTS}"
204
+ local attempt=1 http_code="000" body="" hdr_file retry_after sleep_s
205
+ _RM_CAPTURE_LAST_CODE=""
206
+
207
+ hdr_file=$(mktemp "${TMPDIR:-/tmp}/rm-cap.XXXXXX" 2>/dev/null || true)
208
+
209
+ while [ "$attempt" -le "$max_attempts" ]; do
210
+ if [ -n "$hdr_file" ]; then
211
+ body=$(curl -s --noproxy '*' --max-time "$max_time" -D "$hdr_file" -w '\n%{http_code}' \
212
+ -X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
213
+ --data-binary "$payload" 2>&1) || true
214
+ else
215
+ body=$(curl -s --noproxy '*' --max-time "$max_time" -w '\n%{http_code}' \
216
+ -X "$method" "$url" -H "$auth" -H "Content-Type: application/json" \
217
+ --data-binary "$payload" 2>&1) || true
218
+ fi
219
+ http_code=$(printf '%s' "$body" | tail -1)
220
+ body=$(printf '%s' "$body" | sed '$d')
221
+
222
+ case "$http_code" in 2??)
223
+ printf '%s' "$body" # give the caller the response (e.g. to read .id)
224
+ [ -n "$hdr_file" ] && rm -f "$hdr_file"
225
+ return 0
226
+ ;;
227
+ esac
228
+
229
+ if [ "$http_code" = "503" ] || [ "$http_code" = "429" ] || [ "$http_code" = "000" ]; then
230
+ if [ "$attempt" -lt "$max_attempts" ]; then
231
+ retry_after=""
232
+ if [ -n "$hdr_file" ]; then
233
+ retry_after=$(grep -i '^Retry-After:' "$hdr_file" 2>/dev/null | tail -1 | tr -dc '0-9' || true)
234
+ fi
235
+ if [ -n "$retry_after" ] && [ "$retry_after" -gt 0 ] 2>/dev/null; then
236
+ sleep_s="$retry_after"
237
+ else
238
+ sleep_s="$attempt"
239
+ fi
240
+ if [ "$sleep_s" -gt "$_RM_POST_RETRY_CAP_S" ] 2>/dev/null; then
241
+ sleep_s="$_RM_POST_RETRY_CAP_S"
242
+ fi
243
+ sleep "$sleep_s" 2>/dev/null || true
244
+ attempt=$((attempt + 1))
245
+ continue
246
+ fi
247
+ fi
248
+
249
+ break
250
+ done
251
+
252
+ # Unlike _rulemetric_send / _rulemetric_post_event (fire-and-forget writes whose
253
+ # ONLY failure signal is this log line), _rulemetric_send_capture's caller
254
+ # inspects the return value and logs its OWN outcome — session-create logs
255
+ # skip/no_rw_session when the API is unreachable (see _ensure-session.sh →
256
+ # user-prompt.sh). Logging an "error" here too would prepend a second log entry
257
+ # and mis-classify that benign skip as an error (regressed hooks.test.ts). So
258
+ # the helper stays SILENT on failure; the caller owns the outcome — the final
259
+ # HTTP code is exported in _RM_CAPTURE_LAST_CODE so the caller's log can carry
260
+ # the forensics (a bare "no_rw_session" is undiagnosable from a remote device).
261
+ # The beacon is server-side, not a log entry, so it doesn't break the contract.
262
+ # Deep-debug via RULEMETRIC_HOOK_DEBUG remains available on the caller side.
263
+ _RM_CAPTURE_LAST_CODE="$http_code"
264
+ _rulemetric_beacon "$tag" "$http_code"
141
265
  [ -n "$hdr_file" ] && rm -f "$hdr_file"
142
266
  return 1
143
267
  }
@@ -31,8 +31,8 @@ trap cleanup EXIT
31
31
 
32
32
  # --- Stub server: counts requests, programmed by $MODE in a control file --------
33
33
  cat > "$WORK_ROOT/stub.py" <<'PY'
34
- import http.server, json, os, sys, threading
35
- mode = sys.argv[1] # fail-once | always-503 | always-400 | always-202
34
+ import hashlib, http.server, json, os, sys, threading
35
+ mode = sys.argv[1] # fail-once | always-503 | always-400 | always-202 | echo-len
36
36
  count_file = sys.argv[2]
37
37
  n = 0
38
38
  lock = threading.Lock()
@@ -44,7 +44,10 @@ class H(http.server.BaseHTTPRequestHandler):
44
44
  n += 1; cur = n
45
45
  open(count_file, "w").write(str(n))
46
46
  length = int(self.headers.get('Content-Length', 0) or 0)
47
- self.rfile.read(length)
47
+ body = self.rfile.read(length)
48
+ if mode == "echo-len":
49
+ resp = json.dumps({"len": len(body), "sha": hashlib.sha256(body).hexdigest()})
50
+ self.send_response(200); self.end_headers(); self.wfile.write(resp.encode()); return
48
51
  if mode == "always-400":
49
52
  self.send_response(400); self.end_headers(); self.wfile.write(b'{"error":"bad"}'); return
50
53
  if mode == "always-503":
@@ -80,6 +83,10 @@ LOG_FILE="$WORK_ROOT/log"
80
83
  _rulemetric_log() { echo "$*" >> "$LOG_FILE"; } # stub: record log calls
81
84
  export RULEMETRIC_POST_MAX_ATTEMPTS=3
82
85
  export RULEMETRIC_POST_RETRY_CAP_S=1
86
+ # The failure paths fire an availability beacon at $RULEMETRIC_API_URL — which
87
+ # in these tests is the counting stub server. Disable telemetry so the beacon
88
+ # can't inflate req_count (it's async, which would also make counts racy).
89
+ export RULEMETRIC_NO_TELEMETRY=1
83
90
  # shellcheck source=/dev/null
84
91
  source "$SCRIPT_DIR/_post.sh"
85
92
 
@@ -127,6 +134,64 @@ assert_eq "always-202: server saw exactly 1 request (no retry)" "1" "$(req_count
127
134
  assert_eq "always-202: no error logged" "0" "$(wc -l < "$LOG_FILE" | tr -d ' ')"
128
135
  stop_server
129
136
 
137
+ # --- Test 5: _rulemetric_send_capture retries a transient 503 AND returns body -
138
+ # Session-create (lazy recovery) uses this to survive a transient 503/502 that
139
+ # previously dropped the whole session. It must retry AND hand back the 2xx body
140
+ # so the caller can read the new session's .id.
141
+ start_server fail-once
142
+ : > "$LOG_FILE"
143
+ cap_body=$(_rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions" "$AUTH" "$PAYLOAD" "test" 4)
144
+ rc=$?
145
+ assert_eq "capture/fail-once: returns success (0) after retry" "0" "$rc"
146
+ assert_eq "capture/fail-once: server saw exactly 2 requests (fail + retry)" "2" "$(req_count)"
147
+ assert_eq "capture/fail-once: echoes the 2xx body for the caller to parse .id" '{"recorded":true}' "$cap_body"
148
+ stop_server
149
+
150
+ # --- Test 6: _rulemetric_send_capture gives up on sustained 503, empty stdout --
151
+ start_server always-503
152
+ : > "$LOG_FILE"
153
+ cap_body=$(_rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions" "$AUTH" "$PAYLOAD" "test" 4)
154
+ rc=$?
155
+ assert_eq "capture/always-503: returns failure (1) after exhausting attempts" "1" "$rc"
156
+ assert_eq "capture/always-503: server saw exactly MAX_ATTEMPTS (3) requests" "3" "$(req_count)"
157
+ assert_eq "capture/always-503: NO body on stdout on failure (caller sees empty → skips)" "" "$cap_body"
158
+ # The capture helper stays SILENT on failure — the caller (session-create) logs
159
+ # its own outcome (skip/no_rw_session). A stray 'error' entry here would
160
+ # mis-classify that benign skip (regressed hooks.test.ts).
161
+ assert_eq "capture/always-503: helper logs NOTHING on failure (caller owns outcome)" "0" "$(wc -l < "$LOG_FILE" | tr -d ' ')"
162
+ stop_server
163
+
164
+ # --- Test 7: _rulemetric_send_capture does NOT retry a permanent 4xx -----------
165
+ start_server always-400
166
+ : > "$LOG_FILE"
167
+ _rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions" "$AUTH" "$PAYLOAD" "test" 4 >/dev/null
168
+ rc=$?
169
+ assert_eq "capture/always-400: returns failure (1)" "1" "$rc"
170
+ assert_eq "capture/always-400: server saw exactly 1 request (no retry on 4xx)" "1" "$(req_count)"
171
+ stop_server
172
+
173
+ # --- Test 8: "@file" payload streams a multi-MB body (reimport E2BIG fix) -----
174
+ # session-end reimport passes "@/tmp/file" so transcripts never ride argv: a
175
+ # single argv element bigger than the kernel per-arg limit meant curl never
176
+ # even exec'd (E2BIG) and long sessions silently NEVER reimported. Assert a
177
+ # 2 MB body arrives complete and byte-identical via _rulemetric_send_capture
178
+ # (same curl invocation shape as _rulemetric_send, but echoes the response
179
+ # so we can read back the server-computed length + sha).
180
+ start_server echo-len
181
+ BIG_FILE="$WORK_ROOT/big-payload.json"
182
+ "$PYTHON" -c "import json; print(json.dumps({'content': 'x' * (2 * 1024 * 1024), 'tool': 'claude_code'}))" > "$BIG_FILE"
183
+ expected_len=$(wc -c < "$BIG_FILE" | tr -d ' ')
184
+ expected_sha=$("$PYTHON" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$BIG_FILE")
185
+ : > "$LOG_FILE"
186
+ big_body=$(_rulemetric_send_capture POST "http://127.0.0.1:$SERVER_PORT/api/sessions/x/reimport" "$AUTH" "@$BIG_FILE" "test" 10)
187
+ rc=$?
188
+ assert_eq "atfile: returns success (0)" "0" "$rc"
189
+ got_len=$(printf '%s' "$big_body" | "$PYTHON" -c "import json,sys; print(json.load(sys.stdin)['len'])" 2>/dev/null || echo parse-error)
190
+ got_sha=$(printf '%s' "$big_body" | "$PYTHON" -c "import json,sys; print(json.load(sys.stdin)['sha'])" 2>/dev/null || echo parse-error)
191
+ assert_eq "atfile: server received the full 2MB body" "$expected_len" "$got_len"
192
+ assert_eq "atfile: body byte-identical (sha256)" "$expected_sha" "$got_sha"
193
+ stop_server
194
+
130
195
  # --- Summary -----------------------------------------------------------------
131
196
  echo
132
197
  echo "Passed: $PASSES Failed: $FAILURES"
@@ -44,33 +44,46 @@ if ! _rulemetric_ensure_session "session-end"; then
44
44
  fi
45
45
 
46
46
  # Try to reimport with full transcript, fall back to close
47
+ REIMPORT_FILE=""
47
48
  if [ -n "$HOOK_TRANSCRIPT_PATH" ] && [ -f "$HOOK_TRANSCRIPT_PATH" ]; then
48
- CONTENT=$(cat "$HOOK_TRANSCRIPT_PATH")
49
- REIMPORT_PAYLOAD="{\"content\":$(echo "$CONTENT" | jq -Rs .),\"tool\":\"$HOOK_TOOL\"}"
50
- # Retry-on-503 (max-time 8 for the large transcript body); this is FOREGROUND and
51
- # blocks the hook, so cap at 2 attempts (worst-case tail ~18s on a hard API hang,
52
- # vs ~32s at 3) the common fast-503 wedge still recovers in <1s. Fall back to
53
- # close if it still fails after retries — a dropped reimport leaves the session EMPTY.
54
- # NOTE: under headless `claude -p`, Claude Code's print-mode teardown cancels this
55
- # hook ("SessionEnd hook … failed: Hook cancelled") regardless of whether this runs
56
- # in the foreground or is backgrounded verified 2026-07-05. It is a Claude Code
57
- # print-mode behavior, not a capture failure (per-turn hooks still land events); do
58
- # NOT try to "fix" it by detaching this call (adds no benefit, and headless is a
59
- # deliberately-excluded capture vehicle).
60
- _rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/reimport" \
61
- "$AUTH_HEADER" "$REIMPORT_PAYLOAD" "session-end-reimport" 8 2 \
62
- > /dev/null 2>&1 || {
63
- _rulemetric_log "session-end" "fallback" "reimport_failed"
64
- curl -sf --noproxy '*' --max-time 4 -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
65
- -H "$AUTH_HEADER" \
66
- > /dev/null 2>&1 || \
49
+ # Build the payload in a TEMP FILE and stream it with curl's @file form. It
50
+ # must never ride argv: a long session's transcript blows the kernel per-arg
51
+ # exec limit (E2BIG) and curl never starts long sessions silently NEVER
52
+ # reimported. jq builds {content, tool} straight from the transcript so the
53
+ # whole body is also never held in a shell variable.
54
+ REIMPORT_FILE=$(mktemp "${TMPDIR:-/tmp}/rm-reimport.XXXXXX" 2>/dev/null || true)
55
+ if [ -n "$REIMPORT_FILE" ] && jq -Rs --arg tool "$HOOK_TOOL" '{content: ., tool: $tool}' \
56
+ < "$HOOK_TRANSCRIPT_PATH" > "$REIMPORT_FILE" 2>/dev/null; then
57
+ # Retry-on-503; FOREGROUND, blocks the hook, so cap at 2 attempts. max-time 20
58
+ # gives a multi-MB body room to actually transfer on a slow uplink (worst-case
59
+ # hard-hang tail 20+1+20 = 41s, still under Claude Code's 60s hook timeout);
60
+ # the common fast-503 wedge still recovers in <1s. Fall back to close if it
61
+ # still fails after retries — a dropped reimport leaves the session EMPTY.
62
+ # NOTE: under headless `claude -p`, Claude Code's print-mode teardown cancels this
63
+ # hook ("SessionEnd hook … failed: Hook cancelled") regardless of whether this runs
64
+ # in the foreground or is backgrounded — verified 2026-07-05. It is a Claude Code
65
+ # print-mode behavior, not a capture failure (per-turn hooks still land events); do
66
+ # NOT try to "fix" it by detaching this call (adds no benefit, and headless is a
67
+ # deliberately-excluded capture vehicle).
68
+ _rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/reimport" \
69
+ "$AUTH_HEADER" "@$REIMPORT_FILE" "session-end-reimport" 20 2 \
70
+ > /dev/null 2>&1 || {
71
+ _rulemetric_log "session-end" "fallback" "reimport_failed"
72
+ _rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
73
+ "$AUTH_HEADER" '{}' "session-end-close" 4 2 > /dev/null 2>&1 || \
74
+ _rulemetric_log "session-end" "error" "close_failed"
75
+ }
76
+ else
77
+ _rulemetric_log "session-end" "fallback" "reimport_payload_build_failed"
78
+ _rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
79
+ "$AUTH_HEADER" '{}' "session-end-close" 4 2 > /dev/null 2>&1 || \
67
80
  _rulemetric_log "session-end" "error" "close_failed"
68
- }
81
+ fi
82
+ [ -n "$REIMPORT_FILE" ] && rm -f "$REIMPORT_FILE" 2>/dev/null || true
69
83
  else
70
84
  # No transcript available — just close the session
71
- curl -sf --noproxy '*' --max-time 4 -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
72
- -H "$AUTH_HEADER" \
73
- > /dev/null 2>&1 || \
85
+ _rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
86
+ "$AUTH_HEADER" '{}' "session-end-close" 4 2 > /dev/null 2>&1 || \
74
87
  _rulemetric_log "session-end" "error" "close_failed"
75
88
  fi
76
89
 
@@ -86,19 +99,23 @@ _rulemetric_log "session-end" "success"
86
99
  if [ "$HOOK_TOOL" = "claude_code" ]; then
87
100
  INSTR_SNAPSHOT=$(rulemetric hooks emit-instructions --project-dir "${HOOK_CWD:-$(pwd)}" 2>/dev/null || true)
88
101
  if [ -n "$INSTR_SNAPSHOT" ]; then
89
- curl -sf --noproxy '*' --max-time 5 -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/context-snapshots" \
90
- -H "$AUTH_HEADER" \
91
- -H "Content-Type: application/json" \
92
- -d "$INSTR_SNAPSHOT" > /dev/null 2>&1 || _rulemetric_log "session-end" "warn" "instruction_snapshot_failed"
102
+ # Bounded 503/429 retry this snapshot is the ONLY instruction-linking
103
+ # signal a hooks-only (no-proxy) session ever emits; a one-shot curl made
104
+ # one transient shed permanently unlink the session's instructions.
105
+ _rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/context-snapshots" \
106
+ "$AUTH_HEADER" "$INSTR_SNAPSHOT" "session-end-instr-snapshot" 5 2 \
107
+ > /dev/null 2>&1 || _rulemetric_log "session-end" "warn" "instruction_snapshot_failed"
93
108
  fi
94
109
  fi
95
110
 
96
111
  # ── Auto-link instructions from context snapshots ──
97
- # Fire-and-forget: link any captured context to instruction records
98
- (curl -sf --noproxy '*' --max-time 8 -X POST "${RULEMETRIC_API_URL}/api/sessions/link-instructions" \
99
- -H "$AUTH_HEADER" \
100
- -H "Content-Type: application/json" \
101
- -d '{}' > /dev/null 2>&1) &
112
+ # Fire-and-forget (backgrounded), but with the same bounded 503/429 retry as
113
+ # the other session-end writes one transient shed used to drop it silently.
114
+ # Scoped to THIS session: the API treats an unscoped body as a no-op (the
115
+ # account-wide scan is explicit-backfill only). RW_SESSION_ID is
116
+ # UUID-validated by _ensure-session, so it is safe to interpolate into JSON.
117
+ (_rulemetric_send POST "${RULEMETRIC_API_URL}/api/sessions/link-instructions" \
118
+ "$AUTH_HEADER" "{\"sessionId\":\"$RW_SESSION_ID\"}" "session-end-link" 8 > /dev/null 2>&1) &
102
119
 
103
120
  # ── Auto-enrich from local session-meta ──
104
121
  # For Claude Code: read local session-meta file
@@ -59,21 +59,15 @@ _rulemetric_check_auth() {
59
59
 
60
60
  _rulemetric_check_auth
61
61
 
62
- # ── Gateway auto-recovery ──
62
+ # ── Gateway auto-recovery + stale-addon convergence ──
63
63
  # Ensure the gateway proxy is running so Claude Code can always connect.
64
64
  # The gateway routes direct when mitmproxy is off, so this is non-breaking.
65
- GATEWAY_PID_FILE="$HOME/.config/rulemetric/gateway.pid"
66
-
67
- if [ -f "$GATEWAY_PID_FILE" ]; then
68
- GATEWAY_PID=$(cat "$GATEWAY_PID_FILE" 2>/dev/null)
69
- if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
70
- # Gateway crashed respawn it
71
- rulemetric gateway ensure 2>/dev/null || true
72
- fi
73
- else
74
- # No PID file — try to start gateway
75
- rulemetric gateway ensure 2>/dev/null || true
76
- fi
65
+ # UNCONDITIONAL (not gated on the PID quick-check): `gateway ensure` is also
66
+ # the component that detects a HEALTHY proxy running a stale addon after an
67
+ # `npm i -g` upgrade (the gateway↔addon drift restart is circular — both
68
+ # compare versions frozen at their own spawn) and restarts it. Backgrounded so
69
+ # the node boot never delays the hook.
70
+ (rulemetric gateway ensure > /dev/null 2>&1 || true) &
77
71
 
78
72
  # ── Proxy auto-recovery ──
79
73
  # Ensure the capture proxy is running so events + context are captured.