@rulemetric/hooks 0.2.1 → 0.2.3
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 +12 -1
- package/scripts/_normalize.sh +22 -1
- package/scripts/_paper-trail.sh +204 -0
- package/scripts/jq +310 -0
- package/scripts/paper-trail.test.sh +275 -0
- package/scripts/post-tool-use.sh +12 -0
- package/scripts/session-end.sh +8 -0
- package/scripts/session-start.sh +2 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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" \
|
package/scripts/_normalize.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -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
|
package/scripts/session-end.sh
CHANGED
|
@@ -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).
|
package/scripts/session-start.sh
CHANGED
|
@@ -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 "
|
|
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))
|