@rulemetric/hooks 0.7.27 → 0.7.29

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.7.27",
3
+ "version": "0.7.29",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -117,16 +117,46 @@ _rulemetric_ensure_session() {
117
117
  is_headless="true"
118
118
  fi
119
119
 
120
+ # Harness version, read from the transcript the hook was handed.
121
+ #
122
+ # `claudeCodeVersion` used to arrive ONLY on the SessionEnd transcript
123
+ # reimport, so any session that never ended cleanly — still running, crashed,
124
+ # or killed by a "Hook cancelled" — never got one. Measured 2026-07-27:
125
+ # harness_version was null on 96 of 326 claude_code sessions over 14 days
126
+ # (~30%), including long-lived live sessions, which is what blocks
127
+ # version-scoped segment comparisons (§6c.f attribution depth).
128
+ #
129
+ # Claude Code stamps `version` on every transcript entry from the first user
130
+ # message onward, so scanning the head of the file finds it. The first line is
131
+ # an `operation` record with no version, hence the scan rather than a
132
+ # first-line read. Reposts merge metadata server-side (jsonb ||), so if the
133
+ # transcript had no entries yet at SessionStart, a later hook call fills it in.
134
+ # Extracted with grep, NOT jq, on purpose: the bundled `jq` shim supports only
135
+ # the narrow filter subset already used here and returns EMPTY for anything
136
+ # else (verified — `select(.version != null) | .version` and even `.version`
137
+ # yield nothing). On a host without real jq that would silently capture no
138
+ # version at all, which is the exact failure being fixed. The semver-shaped
139
+ # pattern keeps prose in message content from matching.
140
+ local harness_version=""
141
+ if [ -n "${HOOK_TRANSCRIPT_PATH:-}" ] && [ -f "$HOOK_TRANSCRIPT_PATH" ]; then
142
+ harness_version=$(head -n 50 "$HOOK_TRANSCRIPT_PATH" 2>/dev/null \
143
+ | grep -oE '"version"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+[0-9A-Za-z.-]*"' \
144
+ | head -n 1 \
145
+ | sed -E 's/.*"([0-9][^"]*)"$/\1/') || harness_version=""
146
+ fi
147
+
120
148
  local metadata
121
149
  metadata=$(jq -n \
122
150
  --arg gitRemote "$git_remote" \
123
151
  --arg gitRootCommit "$git_root_commit" \
124
152
  --arg launchProjectPath "$launch_project_path" \
153
+ --arg claudeCodeVersion "$harness_version" \
125
154
  --argjson isWorktree "$is_worktree" \
126
155
  --argjson isHeadless "$is_headless" \
127
156
  '{} + (if $gitRemote != "" then {gitRemote: $gitRemote} else {} end)
128
157
  + (if $gitRootCommit != "" then {gitRootCommit: $gitRootCommit} else {} end)
129
158
  + (if $launchProjectPath != "" then {launchProjectPath: $launchProjectPath} else {} end)
159
+ + (if $claudeCodeVersion != "" then {claudeCodeVersion: $claudeCodeVersion} else {} end)
130
160
  + {isWorktree: $isWorktree, headless: $isHeadless}')
131
161
 
132
162
  # Optional org attribution (Phase 8.1). RULEMETRIC_ORG_ID is exported by
package/scripts/jq CHANGED
@@ -48,9 +48,22 @@ def parse_args(argv):
48
48
  return flags, vars_, filter_ or ".", files
49
49
 
50
50
 
51
+ class Unsupported(Exception):
52
+ """
53
+ A filter this shim does not implement.
54
+
55
+ Raised rather than returning EMPTY because EMPTY prints nothing and exits 0,
56
+ and every hook caller guards on the EXIT CODE (`... || return 0`). So an
57
+ unsupported filter looked exactly like a successful empty result and the
58
+ guards never fired — which is how `.memories | length` and
59
+ `.suggestions | length` both stayed silently dead. Failing loudly turns the
60
+ next unsupported filter into a clean skip instead of wrong data.
61
+ """
62
+
63
+
51
64
  def path_get(obj, expr):
52
65
  if not expr.startswith("."):
53
- return EMPTY
66
+ raise Unsupported(expr)
54
67
  cur = obj
55
68
  rest = expr[1:]
56
69
  if rest == "":
@@ -109,7 +122,14 @@ def eval_simple(expr, data, vars_):
109
122
  if m:
110
123
  return isinstance(data, dict) and m.group(1) in data
111
124
 
112
- m = re.fullmatch(r"(.+)\\|\\s*length", expr)
125
+ # NOT r"(.+)\\|\\s*length" — in a RAW string that is a literal backslash,
126
+ # so the pattern read as "(.+)\" OR "\s*length" and never matched a real
127
+ # `.foo | length`. It silently fell through to path_get(".memories | length"),
128
+ # which returns EMPTY, which prints nothing with exit 0 — so
129
+ # `count=$(... | jq -r '.memories | length')` came back empty and every
130
+ # caller's `|| return 0` guard saw success. That is the whole reason memory
131
+ # injection was a silent no-op on hosts without real jq.
132
+ m = re.fullmatch(r"(.+)\|\s*length", expr)
113
133
  if m:
114
134
  value = eval_filter(m.group(1), data, vars_)
115
135
  if value is EMPTY or value is None:
@@ -129,6 +149,19 @@ def eval_simple(expr, data, vars_):
129
149
  if expr.startswith("{") and expr.endswith("}"):
130
150
  return eval_object_literal(expr, data, vars_, drop_null=False)
131
151
 
152
+ # JSON scalar literals. Without this, a bare `5` in an object literal
153
+ # (`{..., limit: 5}`) fell through to path_get, which returns None for
154
+ # anything not starting with ".", so the request body went out carrying
155
+ # "limit": null — valid JSON with a wrong value, the worst failure shape.
156
+ if re.fullmatch(r"-?\d+", expr):
157
+ return int(expr)
158
+ if re.fullmatch(r"-?\d*\.\d+([eE][-+]?\d+)?|-?\d+[eE][-+]?\d+", expr):
159
+ return float(expr)
160
+ if expr == "true":
161
+ return True
162
+ if expr == "false":
163
+ return False
164
+
132
165
  return path_get(data, expr)
133
166
 
134
167
 
@@ -270,13 +303,55 @@ def build_known_n_filter(filter_, vars_):
270
303
  return eval_filter(filter_, {}, vars_)
271
304
 
272
305
 
306
+ def eval_inputs_filter(filter_, stdin, raw_input=False):
307
+ """
308
+ `inputs` — consume the remaining input stream. Supports exactly the shape the
309
+ hook scripts use, `[inputs | select(length>0)]` under -Rn, which collects the
310
+ non-empty lines of a file into a JSON array (the seen-ids list).
311
+
312
+ Previously unsupported entirely: with -n set, main() went straight to
313
+ build_known_n_filter, nothing matched, and path_get returned EMPTY — printing
314
+ nothing with exit 0, so the caller read an empty seen-list and could not tell
315
+ that from "no ids seen yet".
316
+ """
317
+ lines = stdin.split("\n")
318
+ # A trailing newline is a terminator, not an empty final record — real jq
319
+ # yields 3 inputs for "a\n\nb\n", not 4.
320
+ if lines and lines[-1] == "":
321
+ lines.pop()
322
+
323
+ if raw_input:
324
+ values = lines
325
+ else:
326
+ values = []
327
+ for line in lines:
328
+ if not line.strip():
329
+ continue
330
+ try:
331
+ values.append(json.loads(line))
332
+ except Exception:
333
+ return EMPTY
334
+
335
+ compact = " ".join(filter_.strip().split())
336
+ if "select(length>0)" in compact.replace(" ", ""):
337
+ values = [v for v in values if v is not None and len(v) > 0]
338
+
339
+ # Wrapped in [...] collects into one array; bare `inputs` streams each value.
340
+ if compact.startswith("[") and compact.endswith("]"):
341
+ return values
342
+ return values
343
+
344
+
273
345
  def emit(value, raw=False, compact=False):
274
346
  if value is EMPTY:
275
347
  return
276
348
  if raw and isinstance(value, str):
277
349
  sys.stdout.write(value + "\n")
278
350
  elif raw and value is None:
279
- sys.stdout.write("\n")
351
+ # `jq -r` prints the four characters "null" for a null result; only
352
+ # `empty` produces no output. Writing a bare newline here diverged from
353
+ # real jq, and invisible divergence is what made this shim dangerous.
354
+ sys.stdout.write("null\n")
280
355
  elif raw and isinstance(value, bool):
281
356
  sys.stdout.write(("true" if value else "false") + "\n")
282
357
  else:
@@ -284,6 +359,21 @@ def emit(value, raw=False, compact=False):
284
359
 
285
360
 
286
361
  def main():
362
+ try:
363
+ run()
364
+ except Unsupported as err:
365
+ # Exit 3 = jq's own "compile error" code, i.e. "this program is not one I
366
+ # can run" — distinct from 4 (bad input) and from 0 with empty output.
367
+ sys.stderr.write(
368
+ "rulemetric jq shim: unsupported filter %r. This is a narrow fallback, "
369
+ "not a jq implementation — install real jq, or add support in "
370
+ "packages/hooks/scripts/jq and cover it in test/jq-fallback.test.ts.\n"
371
+ % (str(err),)
372
+ )
373
+ sys.exit(3)
374
+
375
+
376
+ def run():
287
377
  flags, vars_, filter_, files = parse_args(sys.argv[1:])
288
378
  if files:
289
379
  with open(files[0], "r", encoding="utf-8") as handle:
@@ -295,6 +385,16 @@ def main():
295
385
  emit(stdin, raw=False, compact="c" in flags)
296
386
  return
297
387
 
388
+ # `inputs` reads the stream even under -n, so it must be handled before the
389
+ # -n fast path (which assumes the filter is built purely from --arg vars).
390
+ if "n" in flags and re.search(r"\binputs\b", filter_):
391
+ emit(
392
+ eval_inputs_filter(filter_, stdin, raw_input="R" in flags),
393
+ raw="r" in flags,
394
+ compact="c" in flags,
395
+ )
396
+ return
397
+
298
398
  if "n" in flags:
299
399
  emit(build_known_n_filter(filter_, vars_), raw="r" in flags, compact="c" in flags)
300
400
  return