@rulemetric/hooks 0.2.1 → 0.2.2

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.2.1",
3
+ "version": "0.2.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -138,6 +138,16 @@ _rulemetric_ensure_session() {
138
138
  org_id_arg=$(jq -n --arg v "$RULEMETRIC_ORG_ID" '$v')
139
139
  fi
140
140
 
141
+ # Multi-session-run attribution (Task 3.1). RULEMETRIC_RUN_ID is injected
142
+ # into the spawned session's env by the launcher when this session is part
143
+ # of a fanned-out run. The API validates ownership and silently nulls a
144
+ # bad/unowned runId, so capture never breaks. Encode `null` when unset —
145
+ # normal (non-run) sessions are unaffected.
146
+ local run_id_arg='null'
147
+ if [ -n "${RULEMETRIC_RUN_ID:-}" ]; then
148
+ run_id_arg=$(jq -n --arg v "$RULEMETRIC_RUN_ID" '$v')
149
+ fi
150
+
141
151
  local payload
142
152
  payload=$(jq -n \
143
153
  --arg tool "$HOOK_TOOL" \
@@ -146,7 +156,8 @@ _rulemetric_ensure_session() {
146
156
  --arg gitBranch "$git_branch" \
147
157
  --argjson metadata "$metadata" \
148
158
  --argjson orgId "$org_id_arg" \
149
- '{tool: $tool, externalSessionId: $externalSessionId, projectPath: $projectPath, gitBranch: $gitBranch, metadata: $metadata, orgId: $orgId}')
159
+ --argjson runId "$run_id_arg" \
160
+ '{tool: $tool, externalSessionId: $externalSessionId, projectPath: $projectPath, gitBranch: $gitBranch, metadata: $metadata, orgId: $orgId, runId: $runId}')
150
161
 
151
162
  local response
152
163
  response=$(curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions" \
@@ -31,7 +31,28 @@ _rulemetric_normalize() {
31
31
  has_conversation_id=$(echo "$input" | jq -r 'has("conversation_id")' 2>/dev/null)
32
32
  has_hook_event_name=$(echo "$input" | jq -r 'has("hook_event_name")' 2>/dev/null)
33
33
 
34
- if [ "$has_toolName" = "true" ] && [ "$has_sessionId" != "true" ]; then
34
+ # Antigravity: keyed on RULEMETRIC_HOOK_TOOL env var (set by our hook
35
+ # config in ~/.gemini/config/hooks.json). The payload uses camelCase
36
+ # `conversationId` + `workspacePaths[]` + `transcriptPath`, with
37
+ # tool args nested under `toolCall.{name,args}`.
38
+ if [ "${RULEMETRIC_HOOK_TOOL:-}" = "antigravity" ]; then
39
+ HOOK_TOOL="antigravity"
40
+ HOOK_SESSION_ID=$(echo "$input" | jq -r '.conversationId // empty' 2>/dev/null)
41
+ HOOK_CWD=$(echo "$input" | jq -r '
42
+ .workspacePaths[0] //
43
+ .workspace_roots[0] //
44
+ .cwd //
45
+ .toolCall.args.Cwd //
46
+ .toolCall.args.cwd //
47
+ empty
48
+ ' 2>/dev/null)
49
+ HOOK_PROMPT=""
50
+ HOOK_TOOL_NAME=$(echo "$input" | jq -r '.toolCall.name // .tool_name // empty' 2>/dev/null)
51
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.toolCall.args // .tool_input // {}' 2>/dev/null || echo '{}')
52
+ HOOK_TOOL_OUTPUT=$(echo "$input" | jq -c '.toolCall.result // .tool_response // {}' 2>/dev/null || echo '{}')
53
+ HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcriptPath // empty' 2>/dev/null)
54
+
55
+ elif [ "$has_toolName" = "true" ] && [ "$has_sessionId" != "true" ]; then
35
56
  # Copilot CLI: no session ID, uses toolName/toolArgs/toolResult
36
57
  HOOK_TOOL="copilot_cli"
37
58
 
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Session paper-trail snapshot (container-use style "auto-commit
3
+ # everything", no containers).
4
+ #
5
+ # Defines _rulemetric_paper_trail_snapshot() which snapshots the FULL working
6
+ # tree to a shadow git ref:
7
+ #
8
+ # refs/rulemetric/paper-trail/<session-id>
9
+ #
10
+ # It NEVER touches HEAD, the user's index, the working tree, or any visible
11
+ # branch — all writes go through a TEMP index file + write-tree + commit-tree +
12
+ # update-ref. Shadow refs don't appear in normal `git status`/`git log`.
13
+ #
14
+ # Must be sourced AFTER _normalize.sh (needs HOOK_SESSION_ID, HOOK_TOOL_NAME,
15
+ # HOOK_TOOL_INPUT) and _ensure-session.sh (sets TEMP_DIR). Falls back to a
16
+ # sane TEMP_DIR if unset so the function is usable standalone (e.g. in tests).
17
+ #
18
+ # Every failure is swallowed: the snapshot must NEVER break capture or fail the
19
+ # hook. The function always returns 0.
20
+
21
+ # Best-effort ISO-8601 UTC timestamp.
22
+ _rulemetric_paper_trail_ts() {
23
+ date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo ""
24
+ }
25
+
26
+ # Try to derive the edited file path from HOOK_TOOL_INPUT JSON. Tools use
27
+ # `file_path` (Edit/Write/MultiEdit) or `notebook_path` (NotebookEdit). Returns
28
+ # empty on any failure — purely cosmetic for the commit message.
29
+ _rulemetric_paper_trail_file() {
30
+ if ! command -v jq >/dev/null 2>&1; then
31
+ echo ""
32
+ return 0
33
+ fi
34
+ printf '%s' "${HOOK_TOOL_INPUT:-{\}}" \
35
+ | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null \
36
+ || echo ""
37
+ }
38
+
39
+ _rulemetric_paper_trail_snapshot() {
40
+ # Gate: default ON; RULEMETRIC_PAPER_TRAIL=0 disables.
41
+ if [ "${RULEMETRIC_PAPER_TRAIL:-1}" = "0" ]; then
42
+ return 0
43
+ fi
44
+
45
+ # Need a session id to name the ref.
46
+ if [ -z "${HOOK_SESSION_ID:-}" ]; then
47
+ return 0
48
+ fi
49
+
50
+ # Must be inside a git work tree. Clean skip otherwise.
51
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
52
+ _rulemetric_log "paper-trail" "skip" "not_a_git_repo"
53
+ return 0
54
+ fi
55
+
56
+ # Ensure we have a temp dir for the isolated index file.
57
+ local temp_dir="${TEMP_DIR:-${TMPDIR:-/tmp}/rulemetric}"
58
+ mkdir -p "$temp_dir" 2>/dev/null || {
59
+ _rulemetric_log "paper-trail" "error" "mkdir_temp_dir_failed"
60
+ return 0
61
+ }
62
+
63
+ local ref="refs/rulemetric/paper-trail/$HOOK_SESSION_ID"
64
+
65
+ # Per-call temp index. Claude Code runs tool calls in PARALLEL within one
66
+ # session (same HOOK_SESSION_ID), so a shared index path would be clobbered
67
+ # by concurrent invocations. A unique path per call ($$ pid + $RANDOM) keeps
68
+ # each write-tree isolated.
69
+ local tmp_index="$temp_dir/$HOOK_SESSION_ID.$$.${RANDOM}.ptindex"
70
+
71
+ # Build the tree from the working tree using ONLY the temp index. The
72
+ # working-tree READ does not need the lock (git read-tree/add/write-tree only
73
+ # touch our private GIT_INDEX_FILE, never the user's index). Wrapped in a
74
+ # subshell so a failure under `set -e` (the caller runs with -euo pipefail)
75
+ # can't abort the hook. `|| true` on the seed read-tree so an empty/headless
76
+ # repo still works.
77
+ local tree=""
78
+ tree=$(
79
+ set +e
80
+ GIT_INDEX_FILE="$tmp_index" git read-tree HEAD 2>/dev/null \
81
+ || GIT_INDEX_FILE="$tmp_index" git read-tree --empty 2>/dev/null
82
+ GIT_INDEX_FILE="$tmp_index" git add -A 2>/dev/null
83
+ GIT_INDEX_FILE="$tmp_index" git write-tree 2>/dev/null
84
+ ) || true
85
+
86
+ # Clean up the per-call temp index regardless of outcome.
87
+ rm -f "$tmp_index" 2>/dev/null || true
88
+
89
+ if [ -z "$tree" ]; then
90
+ _rulemetric_log "paper-trail" "error" "write_tree_failed"
91
+ return 0
92
+ fi
93
+
94
+ # --- Critical section: read-parent → de-dup → commit-tree → update-ref ------
95
+ # Parallel tool calls share HOOK_SESSION_ID and therefore the same trail ref.
96
+ # Without serialization, two snapshots read the same parent tip, both commit
97
+ # off it, and the second update-ref clobbers the first (last-writer-wins →
98
+ # silent loss of trail commits). Serialize with the same atomic mkdir lock
99
+ # pattern used by post-tool-use.sh (the .seqlock pattern) so parent
100
+ # computation and ref update are atomic.
101
+ #
102
+ # Bounded wait: give up after ~5s of contention so a stale lock (e.g. a hook
103
+ # that was SIGKILLed mid-section) can never hang the hook. On give-up we skip
104
+ # and log — the snapshot is best-effort.
105
+ local lock_dir="$temp_dir/$HOOK_SESSION_ID.ptlock"
106
+ local waited=0
107
+ local got_lock=0
108
+ while true; do
109
+ if mkdir "$lock_dir" 2>/dev/null; then
110
+ got_lock=1
111
+ break
112
+ fi
113
+ if [ "$waited" -ge 500 ]; then
114
+ break
115
+ fi
116
+ waited=$((waited + 1))
117
+ sleep 0.01
118
+ done
119
+
120
+ if [ "$got_lock" != "1" ]; then
121
+ _rulemetric_log "paper-trail" "skip" "lock_timeout"
122
+ return 0
123
+ fi
124
+
125
+ # Everything from here to the matching rmdir MUST release the lock on every
126
+ # path. We run the whole critical section in a subshell with `set +e` and an
127
+ # EXIT trap that removes the lock dir, so even an unexpected failure releases
128
+ # it. The subshell returns the outcome; the function still always returns 0.
129
+ (
130
+ set +e
131
+ # Release the lock no matter how this subshell exits (success, failure,
132
+ # early return, or signal-ish abort).
133
+ trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT
134
+
135
+ # Parent = current trail tip if present, else HEAD if the repo has commits.
136
+ # `ref_old` is the CURRENT value of the trail ref (empty if it doesn't yet
137
+ # exist) — that, not `parent`, is the compare-and-swap expected-old value.
138
+ local ref_old=""
139
+ ref_old=$(git rev-parse --verify -q "$ref" 2>/dev/null || true)
140
+ local parent="$ref_old"
141
+ if [ -z "$parent" ]; then
142
+ parent=$(git rev-parse --verify -q HEAD 2>/dev/null || true)
143
+ fi
144
+
145
+ # De-dup: if the parent's tree equals the new tree, write nothing.
146
+ if [ -n "$parent" ]; then
147
+ local parent_tree=""
148
+ parent_tree=$(git rev-parse -q "$parent^{tree}" 2>/dev/null || true)
149
+ if [ -n "$parent_tree" ] && [ "$parent_tree" = "$tree" ]; then
150
+ _rulemetric_log "paper-trail" "skip" "no_change"
151
+ exit 0
152
+ fi
153
+ fi
154
+
155
+ # Build the commit message.
156
+ local file
157
+ file=$(_rulemetric_paper_trail_file)
158
+ local tool="${HOOK_TOOL_NAME:-unknown}"
159
+ local run="${RULEMETRIC_RUN_ID:-none}"
160
+ local ts
161
+ ts=$(_rulemetric_paper_trail_ts)
162
+ local msg
163
+ if [ -n "$file" ]; then
164
+ msg="paper-trail: $tool $file (session ${HOOK_SESSION_ID} run ${run}) $ts"
165
+ else
166
+ msg="paper-trail: $tool (session ${HOOK_SESSION_ID} run ${run}) $ts"
167
+ fi
168
+
169
+ # Create the commit. Use a fixed rulemetric identity so the commit never
170
+ # depends on (or pollutes) the user's git config.
171
+ local commit=""
172
+ if [ -n "$parent" ]; then
173
+ commit=$(
174
+ printf '%s' "$msg" | GIT_AUTHOR_NAME=rulemetric GIT_COMMITTER_NAME=rulemetric \
175
+ GIT_AUTHOR_EMAIL=rulemetric@localhost GIT_COMMITTER_EMAIL=rulemetric@localhost \
176
+ git commit-tree "$tree" -p "$parent" 2>/dev/null
177
+ )
178
+ else
179
+ commit=$(
180
+ printf '%s' "$msg" | GIT_AUTHOR_NAME=rulemetric GIT_COMMITTER_NAME=rulemetric \
181
+ GIT_AUTHOR_EMAIL=rulemetric@localhost GIT_COMMITTER_EMAIL=rulemetric@localhost \
182
+ git commit-tree "$tree" 2>/dev/null
183
+ )
184
+ fi
185
+
186
+ if [ -z "$commit" ]; then
187
+ _rulemetric_log "paper-trail" "error" "commit_tree_failed"
188
+ exit 0
189
+ fi
190
+
191
+ # Update only the shadow ref. Compare-and-swap on the ref's expected old
192
+ # value (empty string ⇒ "ref must not yet exist") — belt-and-suspenders
193
+ # alongside the lock so a concurrent writer can never be silently
194
+ # clobbered. Never touches HEAD or any visible branch.
195
+ if git update-ref "$ref" "$commit" "$ref_old" 2>/dev/null; then
196
+ _rulemetric_log "paper-trail" "success" "$ref"
197
+ else
198
+ _rulemetric_log "paper-trail" "error" "update_ref_failed"
199
+ fi
200
+ exit 0
201
+ ) || true
202
+
203
+ return 0
204
+ }
package/scripts/jq ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ # LAST-RESORT FALLBACK for hosts without real jq — NOT a full jq implementation.
3
+ # Supports only the narrow filter subset the bundled hook scripts use (see the
4
+ # hardcoded fast-path patterns below). Any other expression may silently return
5
+ # wrong output. If you're extending a hook script with a new jq filter, test it
6
+ # against this shim or require real jq. PATH order matters: real jq wins when
7
+ # installed; this file is only found via the scripts dir.
8
+ import json
9
+ import re
10
+ import sys
11
+
12
+
13
+ class Empty:
14
+ pass
15
+
16
+
17
+ EMPTY = Empty()
18
+
19
+
20
+ def parse_args(argv):
21
+ flags = set()
22
+ vars_ = {}
23
+ filter_ = None
24
+ files = []
25
+ i = 0
26
+ while i < len(argv):
27
+ arg = argv[i]
28
+ if arg == "--arg":
29
+ vars_[argv[i + 1]] = argv[i + 2]
30
+ i += 3
31
+ elif arg == "--argjson":
32
+ raw = argv[i + 2]
33
+ try:
34
+ vars_[argv[i + 1]] = json.loads(raw)
35
+ except Exception:
36
+ vars_[argv[i + 1]] = None
37
+ i += 3
38
+ elif arg.startswith("-") and arg != "-":
39
+ for ch in arg[1:]:
40
+ flags.add(ch)
41
+ i += 1
42
+ else:
43
+ if filter_ is None:
44
+ filter_ = arg
45
+ else:
46
+ files.append(arg)
47
+ i += 1
48
+ return flags, vars_, filter_ or ".", files
49
+
50
+
51
+ def path_get(obj, expr):
52
+ if not expr.startswith("."):
53
+ return EMPTY
54
+ cur = obj
55
+ rest = expr[1:]
56
+ if rest == "":
57
+ return cur
58
+
59
+ tokens = []
60
+ i = 0
61
+ while i < len(rest):
62
+ if rest[i] == ".":
63
+ i += 1
64
+ continue
65
+ if rest[i] == "[":
66
+ end = rest.find("]", i)
67
+ if end == -1:
68
+ return None
69
+ try:
70
+ tokens.append(int(rest[i + 1:end]))
71
+ except Exception:
72
+ return None
73
+ i = end + 1
74
+ continue
75
+ m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", rest[i:])
76
+ if not m:
77
+ return None
78
+ tokens.append(m.group(0))
79
+ i += len(m.group(0))
80
+
81
+ for token in tokens:
82
+ if isinstance(token, int):
83
+ if not isinstance(cur, list) or token >= len(cur):
84
+ return None
85
+ cur = cur[token]
86
+ else:
87
+ if not isinstance(cur, dict) or token not in cur:
88
+ return None
89
+ cur = cur[token]
90
+ return cur
91
+
92
+
93
+ def eval_simple(expr, data, vars_):
94
+ expr = expr.strip()
95
+ if expr == "empty":
96
+ return EMPTY
97
+ if expr == "null":
98
+ return None
99
+ if expr == "{}":
100
+ return {}
101
+ if expr == ".":
102
+ return data
103
+ if expr.startswith("$"):
104
+ return vars_.get(expr[1:], "")
105
+ if len(expr) >= 2 and expr[0] == '"' and expr[-1] == '"':
106
+ return json.loads(expr)
107
+
108
+ m = re.fullmatch(r'has\("([^"]+)"\)', expr)
109
+ if m:
110
+ return isinstance(data, dict) and m.group(1) in data
111
+
112
+ m = re.fullmatch(r"(.+)\\|\\s*length", expr)
113
+ if m:
114
+ value = eval_filter(m.group(1), data, vars_)
115
+ if value is EMPTY or value is None:
116
+ return 0
117
+ return len(value)
118
+
119
+ if expr == '[.suggestions[] | select(.content != null)]':
120
+ suggestions = path_get(data, ".suggestions")
121
+ if not isinstance(suggestions, list):
122
+ return []
123
+ return [item for item in suggestions if isinstance(item, dict) and item.get("content") is not None]
124
+
125
+ if expr.startswith("{") and "with_entries" in expr:
126
+ obj_src = expr.split("|", 1)[0].strip()
127
+ return eval_object_literal(obj_src, data, vars_, drop_null=True)
128
+
129
+ if expr.startswith("{") and expr.endswith("}"):
130
+ return eval_object_literal(expr, data, vars_, drop_null=False)
131
+
132
+ return path_get(data, expr)
133
+
134
+
135
+ def split_fallbacks(expr):
136
+ return [part.strip() for part in expr.split("//")]
137
+
138
+
139
+ def eval_filter(expr, data, vars_):
140
+ expr = " ".join(expr.strip().split())
141
+
142
+ if "//" in expr:
143
+ for part in split_fallbacks(expr):
144
+ value = eval_simple(part, data, vars_)
145
+ if value is EMPTY:
146
+ continue
147
+ if value is not None:
148
+ return value
149
+ return EMPTY
150
+
151
+ return eval_simple(expr, data, vars_)
152
+
153
+
154
+ def eval_object_literal(expr, data, vars_, drop_null=False):
155
+ inner = expr.strip()[1:-1].strip()
156
+ if not inner:
157
+ return {}
158
+ out = {}
159
+ parts = []
160
+ depth = 0
161
+ start = 0
162
+ for i, ch in enumerate(inner):
163
+ if ch in "{[":
164
+ depth += 1
165
+ elif ch in "}]":
166
+ depth -= 1
167
+ elif ch == "," and depth == 0:
168
+ parts.append(inner[start:i])
169
+ start = i + 1
170
+ parts.append(inner[start:])
171
+
172
+ for part in parts:
173
+ if ":" not in part:
174
+ continue
175
+ key, raw_expr = part.split(":", 1)
176
+ key = key.strip().strip('"')
177
+ value = eval_filter(raw_expr.strip(), data, vars_)
178
+ if drop_null and (value is None or value is EMPTY):
179
+ continue
180
+ out[key] = None if value is EMPTY else value
181
+ return out
182
+
183
+
184
+ def build_known_n_filter(filter_, vars_):
185
+ compact = " ".join(filter_.strip().split())
186
+
187
+ if compact == "$v":
188
+ return vars_.get("v", "")
189
+
190
+ if "script: $script" in compact and "outcome: $outcome" in compact:
191
+ return {
192
+ "ts": vars_.get("ts", ""),
193
+ "script": vars_.get("script", ""),
194
+ "outcome": vars_.get("outcome", ""),
195
+ "reason": vars_.get("reason", ""),
196
+ "session": vars_.get("session", ""),
197
+ }
198
+
199
+ if "gitRemote" in compact and "isWorktree" in compact:
200
+ out = {}
201
+ if vars_.get("gitRemote"):
202
+ out["gitRemote"] = vars_["gitRemote"]
203
+ if vars_.get("gitRootCommit"):
204
+ out["gitRootCommit"] = vars_["gitRootCommit"]
205
+ if vars_.get("launchProjectPath"):
206
+ out["launchProjectPath"] = vars_["launchProjectPath"]
207
+ out["isWorktree"] = bool(vars_.get("isWorktree"))
208
+ out["headless"] = bool(vars_.get("isHeadless"))
209
+ return out
210
+
211
+ if "externalSessionId" in compact and "projectPath" in compact:
212
+ return {
213
+ "tool": vars_.get("tool", ""),
214
+ "externalSessionId": vars_.get("externalSessionId", ""),
215
+ "projectPath": vars_.get("projectPath", ""),
216
+ "gitBranch": vars_.get("gitBranch", ""),
217
+ "metadata": vars_.get("metadata", {}),
218
+ "orgId": vars_.get("orgId"),
219
+ }
220
+
221
+ if 'eventType: "tool_call"' in compact:
222
+ return {
223
+ "sequence": vars_.get("seq"),
224
+ "eventType": "tool_call",
225
+ "toolName": vars_.get("toolName", ""),
226
+ "toolInput": vars_.get("toolInput", {}),
227
+ "toolOutput": vars_.get("toolOutput", {}),
228
+ "timestamp": vars_.get("timestamp", ""),
229
+ }
230
+
231
+ if "input_tokens" in compact and "cache_creation_input_tokens" in compact:
232
+ out = {}
233
+ mapping = [
234
+ ("in", "input_tokens"),
235
+ ("out", "output_tokens"),
236
+ ("cr", "cache_read_input_tokens"),
237
+ ("cw", "cache_creation_input_tokens"),
238
+ ]
239
+ for var, key in mapping:
240
+ raw = vars_.get(var, "")
241
+ if raw != "":
242
+ try:
243
+ out[key] = int(raw)
244
+ except Exception:
245
+ try:
246
+ out[key] = float(raw)
247
+ except Exception:
248
+ pass
249
+ return out
250
+
251
+ if "generationId" in compact and "usage" in compact:
252
+ out = {}
253
+ if vars_.get("generationId"):
254
+ out["generationId"] = vars_["generationId"]
255
+ usage = vars_.get("usage", {})
256
+ if isinstance(usage, dict) and len(usage) > 0:
257
+ out["usage"] = usage
258
+ return out
259
+
260
+ if 'eventType: "assistant_response"' in compact:
261
+ return {
262
+ "sequence": vars_.get("seq"),
263
+ "eventType": "assistant_response",
264
+ "content": vars_.get("text", ""),
265
+ "timestamp": vars_.get("timestamp", ""),
266
+ "metadata": vars_.get("metadata", {}),
267
+ }
268
+
269
+ return eval_filter(filter_, {}, vars_)
270
+
271
+
272
+ def emit(value, raw=False, compact=False):
273
+ if value is EMPTY:
274
+ return
275
+ if raw and isinstance(value, str):
276
+ sys.stdout.write(value + "\n")
277
+ elif raw and value is None:
278
+ sys.stdout.write("\n")
279
+ elif raw and isinstance(value, bool):
280
+ sys.stdout.write(("true" if value else "false") + "\n")
281
+ else:
282
+ sys.stdout.write(json.dumps(value, separators=(",", ":") if compact else None) + "\n")
283
+
284
+
285
+ def main():
286
+ flags, vars_, filter_, files = parse_args(sys.argv[1:])
287
+ if files:
288
+ with open(files[0], "r", encoding="utf-8") as handle:
289
+ stdin = handle.read()
290
+ else:
291
+ stdin = sys.stdin.read()
292
+
293
+ if "R" in flags and "s" in flags and filter_.strip() == ".":
294
+ emit(stdin, raw=False, compact="c" in flags)
295
+ return
296
+
297
+ if "n" in flags:
298
+ emit(build_known_n_filter(filter_, vars_), raw="r" in flags, compact="c" in flags)
299
+ return
300
+
301
+ try:
302
+ data = json.loads(stdin) if stdin.strip() else None
303
+ except Exception:
304
+ sys.exit(4)
305
+
306
+ emit(eval_filter(filter_, data, vars_), raw="r" in flags, compact="c" in flags)
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Safety test for the session paper-trail snapshot.
3
+ #
4
+ # Run with: bash packages/hooks/scripts/paper-trail.test.sh
5
+ # Exits non-zero on any failed assertion.
6
+ #
7
+ # The most important assertion is the SAFETY INVARIANT: after a snapshot, the
8
+ # user's current branch, HEAD, `git status --porcelain`, and the staged index
9
+ # must be BYTE-IDENTICAL to before the snapshot. The snapshot must only ever
10
+ # touch refs/rulemetric/paper-trail/<id>.
11
+
12
+ set -uo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+
16
+ # Colors (fall back to plain if not a tty).
17
+ if [ -t 1 ]; then
18
+ C_OK="\033[32m"; C_FAIL="\033[31m"; C_RST="\033[0m"
19
+ else
20
+ C_OK=""; C_FAIL=""; C_RST=""
21
+ fi
22
+
23
+ FAILURES=0
24
+ PASSES=0
25
+
26
+ ok() {
27
+ printf "${C_OK}OK${C_RST} %s\n" "$1"
28
+ PASSES=$((PASSES + 1))
29
+ }
30
+ fail() {
31
+ printf "${C_FAIL}FAIL${C_RST} %s\n" "$1"
32
+ FAILURES=$((FAILURES + 1))
33
+ }
34
+
35
+ assert_eq() {
36
+ # assert_eq <description> <expected> <actual>
37
+ if [ "$2" = "$3" ]; then
38
+ ok "$1"
39
+ else
40
+ fail "$1 (expected [$2], got [$3])"
41
+ fi
42
+ }
43
+
44
+ # --- Stub _rulemetric_log so _paper-trail.sh can call it standalone ----------
45
+ _rulemetric_log() { :; }
46
+
47
+ # --- Build a throwaway git repo ----------------------------------------------
48
+ WORK_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/pt-test.XXXXXX")
49
+ REPO="$WORK_ROOT/repo"
50
+ mkdir -p "$REPO"
51
+
52
+ cleanup() {
53
+ rm -rf "$WORK_ROOT" 2>/dev/null || true
54
+ }
55
+ trap cleanup EXIT
56
+
57
+ cd "$REPO" || { echo "cd to repo failed"; exit 1; }
58
+ git init -q
59
+ git checkout -q -b work-branch 2>/dev/null || git switch -q -c work-branch 2>/dev/null || true
60
+ git config user.name "Test User"
61
+ git config user.email "test@example.com"
62
+ echo "initial" > a.txt
63
+ git add a.txt
64
+ git commit -q -m "initial commit"
65
+
66
+ # Source the function under test.
67
+ # shellcheck source=/dev/null
68
+ source "$SCRIPT_DIR/_paper-trail.sh"
69
+
70
+ # Test scaffolding env.
71
+ export HOOK_SESSION_ID="11111111-2222-3333-4444-555555555555"
72
+ export TEMP_DIR="$WORK_ROOT/tmp"
73
+ mkdir -p "$TEMP_DIR"
74
+ export HOOK_TOOL_NAME="Write"
75
+ export HOOK_TOOL_INPUT='{"file_path":"b.txt"}'
76
+
77
+ REF="refs/rulemetric/paper-trail/$HOOK_SESSION_ID"
78
+
79
+ # Count paper-trail commits only — commits reachable from the trail ref but
80
+ # NOT from HEAD. The first snapshot parents off HEAD, so the trail's full
81
+ # ancestry includes the user's real commits; those must be excluded to count
82
+ # the paper-trail entries themselves.
83
+ count_commits() {
84
+ if ! git rev-parse --verify -q "$REF" >/dev/null 2>&1; then
85
+ echo "0"
86
+ return 0
87
+ fi
88
+ git rev-list --count "$REF" --not HEAD 2>/dev/null || echo "0"
89
+ }
90
+
91
+ # --- Capture pre-snapshot safety state ---------------------------------------
92
+ make_change_1() { echo "change one" > b.txt; }
93
+ make_change_1
94
+
95
+ PRE_BRANCH=$(git rev-parse --abbrev-ref HEAD)
96
+ PRE_HEAD=$(git rev-parse HEAD)
97
+ PRE_STATUS=$(git status --porcelain)
98
+ PRE_INDEX=$(git diff --cached --name-only)
99
+
100
+ # --- Snapshot #1 -------------------------------------------------------------
101
+ _rulemetric_paper_trail_snapshot
102
+
103
+ POST_BRANCH=$(git rev-parse --abbrev-ref HEAD)
104
+ POST_HEAD=$(git rev-parse HEAD)
105
+ POST_STATUS=$(git status --porcelain)
106
+ POST_INDEX=$(git diff --cached --name-only)
107
+
108
+ echo "--- Safety invariant (branch / HEAD / status / staged index unchanged) ---"
109
+ assert_eq "branch name unchanged" "$PRE_BRANCH" "$POST_BRANCH"
110
+ assert_eq "HEAD commit unchanged" "$PRE_HEAD" "$POST_HEAD"
111
+ assert_eq "git status --porcelain unchanged" "$PRE_STATUS" "$POST_STATUS"
112
+ assert_eq "staged index unchanged" "$PRE_INDEX" "$POST_INDEX"
113
+
114
+ echo "--- Trail ref created ---"
115
+ # Ref exists?
116
+ if git rev-parse --verify -q "$REF" >/dev/null 2>&1; then
117
+ ok "trail ref exists"
118
+ else
119
+ fail "trail ref exists"
120
+ fi
121
+ assert_eq "trail has exactly 1 commit" "1" "$(count_commits)"
122
+
123
+ # Tree of the trail tip contains the change (b.txt with new content)?
124
+ TRAIL_B=$(git show "$REF:b.txt" 2>/dev/null || echo "")
125
+ assert_eq "trail tip tree contains the change" "change one" "$TRAIL_B"
126
+
127
+ # --- Snapshot #2 (with a new change) -----------------------------------------
128
+ echo "--- Second change → 2 commits ---"
129
+ echo "change two" > b.txt
130
+ _rulemetric_paper_trail_snapshot
131
+ assert_eq "trail has 2 commits after second change" "2" "$(count_commits)"
132
+
133
+ # --- Snapshot #3 (no change → de-dup) ----------------------------------------
134
+ echo "--- No change → de-dup (still 2 commits) ---"
135
+ _rulemetric_paper_trail_snapshot
136
+ assert_eq "trail still has 2 commits (de-dup)" "2" "$(count_commits)"
137
+
138
+ # --- Gate: RULEMETRIC_PAPER_TRAIL=0 is a no-op --------------------------------
139
+ echo "--- Gate RULEMETRIC_PAPER_TRAIL=0 ---"
140
+ echo "change three" > b.txt
141
+ RULEMETRIC_PAPER_TRAIL=0 _rulemetric_paper_trail_snapshot
142
+ assert_eq "gate=0 makes it a no-op (still 2 commits)" "2" "$(count_commits)"
143
+
144
+ # --- Non-git dir → clean skip ------------------------------------------------
145
+ echo "--- Non-git dir clean skip ---"
146
+ NONGIT="$WORK_ROOT/nongit"
147
+ mkdir -p "$NONGIT"
148
+ (
149
+ cd "$NONGIT" || exit 1
150
+ echo "loose" > x.txt
151
+ # Ensure it really is not inside a git tree.
152
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
153
+ echo "PRECHECK_FAIL: nongit dir is inside a work tree"
154
+ exit 2
155
+ fi
156
+ _rulemetric_paper_trail_snapshot
157
+ rc=$?
158
+ exit $rc
159
+ )
160
+ NONGIT_RC=$?
161
+ assert_eq "non-git dir returns 0 (clean skip)" "0" "$NONGIT_RC"
162
+
163
+ # --- Concurrency: N parallel snapshots → N distinct commits ------------------
164
+ # Claude Code runs tool calls in PARALLEL within one session (same
165
+ # HOOK_SESSION_ID). Each parallel snapshot must be serialized into its own
166
+ # distinct trail commit by the per-session lock — no clobbering / lost commits.
167
+ #
168
+ # Design constraints this test must satisfy to be a real regression guard:
169
+ # 1. The N snapshots must run as genuinely concurrent processes contending
170
+ # for the paper-trail's OWN lock (NOT a test-level lock around the whole
171
+ # mutate→snapshot window — that would mask the bug, since fully serialized
172
+ # dispatch trivially avoids both the index clobber and the update-ref
173
+ # race). Verified: a whole-window test-level lock makes even the pre-fix
174
+ # buggy code pass, so it is useless as a guard.
175
+ # 2. Each snapshot must observe a DISTINCT full working tree, or de-dup
176
+ # legitimately collapses them and the count is meaningless. paper-trail
177
+ # snapshots the WHOLE tree, so if all jobs append before any snapshot runs
178
+ # write-tree, every snapshot sees the same final tree → 1 commit.
179
+ #
180
+ # We satisfy both by giving each job its OWN file (f_$i.txt — no write contention
181
+ # on a shared file) and a small per-index stagger before it appends+snapshots.
182
+ # The stagger (STEP ms, >> a snapshot's runtime) spreads the N file-creations
183
+ # across real time so each snapshot, when it wins the paper-trail lock, observes
184
+ # a distinct set of f_*.txt files. The jobs are still N concurrent processes —
185
+ # the stagger only offsets their start, it does not serialize the lock-protected
186
+ # section, which is what the fix must protect. Each job appends a per-job nonce
187
+ # too, so a job that somehow runs twice still produces a unique tree.
188
+ echo "--- Concurrency: N parallel snapshots → N distinct commits ---"
189
+
190
+ # Use a fresh session id so this trail starts clean and the count is unambiguous.
191
+ export HOOK_SESSION_ID="99999999-8888-7777-6666-555555555555"
192
+ CONC_REF="refs/rulemetric/paper-trail/$HOOK_SESSION_ID"
193
+
194
+ # Per-index start stagger. paper-trail snapshots the WHOLE tree and de-dups
195
+ # identical trees, so to get N *distinct* (non-de-duped) trees each snapshot
196
+ # must observe a distinct set of f_*.txt files. That requires the file
197
+ # creations to be ordered w.r.t. the snapshots: hence a stagger (STEP_MS)
198
+ # comfortably larger than one snapshot's git-plumbing runtime, so snapshot i
199
+ # reliably observes files 1..i and produces a tree distinct from all others.
200
+ # The jobs are still N concurrent background processes piling onto the
201
+ # paper-trail lock — the lock + per-call index + CAS update-ref is what keeps
202
+ # every one of the N distinct commits instead of clobbering down to fewer.
203
+ #
204
+ # A smaller stagger forces more overlap (and reliably breaks the PRE-FIX code,
205
+ # observed dropping to 1-2 commits), but then de-dup legitimately collapses
206
+ # trees that have converged to the full file set, so it is not a clean
207
+ # count==N assertion. 40ms keeps the trees distinct and the assertion exact.
208
+ STEP_MS=40
209
+ N=12
210
+ pids=()
211
+ for i in $(seq 1 "$N"); do
212
+ # Precompute the per-index delay in seconds (e.g. 0.040, 0.080, ...) without
213
+ # nested quoting inside the subshell. Integer ms → "S.mmm" via printf.
214
+ delay_ms=$(( (i - 1) * STEP_MS ))
215
+ delay=$(printf '%d.%03d' "$(( delay_ms / 1000 ))" "$(( delay_ms % 1000 ))")
216
+ (
217
+ # Stagger start so file-creation order is spread across real time.
218
+ sleep "$delay"
219
+ # Distinct, growing tree: this job creates its own file. By staggering,
220
+ # each snapshot observes a strictly-larger set of f_*.txt files.
221
+ echo "concurrent-$i" > "f_$i.txt"
222
+ _rulemetric_paper_trail_snapshot
223
+ ) &
224
+ pids+=("$!")
225
+ done
226
+
227
+ # Wait for all parallel jobs to finish.
228
+ for pid in "${pids[@]}"; do
229
+ wait "$pid"
230
+ done
231
+
232
+ conc_count() {
233
+ if ! git rev-parse --verify -q "$CONC_REF" >/dev/null 2>&1; then
234
+ echo "0"
235
+ return 0
236
+ fi
237
+ git rev-list --count "$CONC_REF" --not HEAD 2>/dev/null || echo "0"
238
+ }
239
+ CONC_ACTUAL="$(conc_count)"
240
+ assert_eq "all $N parallel snapshots produced $N distinct commits" "$N" "$CONC_ACTUAL"
241
+
242
+ # The lock must be released after the burst — no stale lock dir left behind.
243
+ if [ -d "$TEMP_DIR/$HOOK_SESSION_ID.ptlock" ]; then
244
+ fail "lock dir was left behind after concurrent burst"
245
+ else
246
+ ok "lock dir released after concurrent burst"
247
+ fi
248
+
249
+ # Restore the original session id for any later assertions.
250
+ export HOOK_SESSION_ID="11111111-2222-3333-4444-555555555555"
251
+
252
+ # Clean up the per-job files so the final safety re-check sees a stable tree.
253
+ rm -f f_*.txt 2>/dev/null || true
254
+
255
+ # --- Final safety re-check (after all snapshots) -----------------------------
256
+ # Reset working file to match the committed state for a stable status compare,
257
+ # matching pre-state captured above (b.txt was unstaged, content "change one").
258
+ echo "--- Final safety re-check ---"
259
+ echo "change one" > b.txt
260
+ FINAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
261
+ FINAL_HEAD=$(git rev-parse HEAD)
262
+ FINAL_STATUS=$(git status --porcelain)
263
+ FINAL_INDEX=$(git diff --cached --name-only)
264
+ assert_eq "branch still unchanged at end" "$PRE_BRANCH" "$FINAL_BRANCH"
265
+ assert_eq "HEAD still unchanged at end" "$PRE_HEAD" "$FINAL_HEAD"
266
+ assert_eq "status still unchanged at end" "$PRE_STATUS" "$FINAL_STATUS"
267
+ assert_eq "staged index still unchanged at end" "$PRE_INDEX" "$FINAL_INDEX"
268
+
269
+ # --- Summary -----------------------------------------------------------------
270
+ echo ""
271
+ echo "Passed: $PASSES Failed: $FAILURES"
272
+ if [ "$FAILURES" -gt 0 ]; then
273
+ exit 1
274
+ fi
275
+ exit 0
@@ -13,6 +13,18 @@ source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
13
13
  INPUT=$(cat)
14
14
  source "$SCRIPT_DIR/_normalize.sh"
15
15
 
16
+ # Session paper-trail: snapshot the full working tree to a shadow git ref for
17
+ # file-mutating tools. Runs BEFORE the capture/auth guards below — the local
18
+ # audit trail only needs HOOK_SESSION_ID + a git repo, NOT RuleMetric API auth,
19
+ # so a user gets the trail even before (or without) capture being configured.
20
+ # Swallows all failures and can never change the hook's exit behavior.
21
+ case "$HOOK_TOOL_NAME" in
22
+ Edit|Write|MultiEdit|NotebookEdit)
23
+ source "$SCRIPT_DIR/_paper-trail.sh" 2>/dev/null || true
24
+ _rulemetric_paper_trail_snapshot || true
25
+ ;;
26
+ esac
27
+
16
28
  if [ -z "$HOOK_SESSION_ID" ] || [ -z "${RULEMETRIC_API_URL:-}" ]; then
17
29
  _rulemetric_log "post-tool-use" "skip" "no_config"
18
30
  exit 0
@@ -122,6 +122,14 @@ else
122
122
  -d '{}' > /dev/null 2>&1) &
123
123
  fi
124
124
 
125
+ # ── Tail transcript for 429 rate-limit events ──
126
+ # Forward-only watcher: reads new lines since the cached byte offset and
127
+ # posts any synthetic 429 messages to the research pipeline. Hook-safe
128
+ # (rulemetric command catches its own errors).
129
+ if [ -n "$HOOK_TRANSCRIPT_PATH" ] && [ -f "$HOOK_TRANSCRIPT_PATH" ]; then
130
+ (rulemetric research tail-transcript "$HOOK_TRANSCRIPT_PATH" > /dev/null 2>&1 || true) &
131
+ fi
132
+
125
133
  # ── Auto-generate recommendations (daily debounce) ──
126
134
  # Queue a recommendation job if one hasn't been generated today.
127
135
  # The worker picks it up and runs 7 Opus prompts (~$5-15 per run).
@@ -176,7 +176,8 @@ _rulemetric_suggest_instructions() {
176
176
  "${api_url}/api/instruction-suggestions/${sid}/events" \
177
177
  -H "Authorization: Bearer ${auth_token}" \
178
178
  -H "Content-Type: application/json" \
179
- -d "{\"eventType\":\"${event_type}\",\"tool\":\"${tool}\",\"projectPath\":\"${project_path}\"}" \
179
+ -d "$(jq -n --arg eventType "$event_type" --arg tool "$tool" --arg projectPath "$project_path" \
180
+ '{eventType: $eventType, tool: $tool, projectPath: $projectPath}')" \
180
181
  >/dev/null 2>&1 &
181
182
  disown 2>/dev/null || true
182
183
  i=$((i + 1))