@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 +1 -1
- package/scripts/_ensure-session.sh +30 -0
- package/scripts/jq +103 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|