@rulemetric/hooks 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@rulemetric/hooks",
3
+ "version": "0.1.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "exports": {
9
+ "./scripts-dir": "./scripts-dir.js"
10
+ },
11
+ "files": [
12
+ "scripts",
13
+ "scripts-dir.js"
14
+ ],
15
+ "devDependencies": {
16
+ "vitest": "^3.2.4"
17
+ },
18
+ "scripts": {
19
+ "test": "vitest run"
20
+ }
21
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Shared config loader for hook scripts
3
+ # Sources credentials from multiple locations (first match wins):
4
+ # 1. Already-set environment variables (e.g. from shell profile)
5
+ # 2. ~/.config/rulemetric/env (global user config, created by `rulemetric login`)
6
+ # 3. .env.local in project root (development)
7
+
8
+ _rulemetric_load_config() {
9
+ # If already configured, nothing to do
10
+ if [ -n "${RULEMETRIC_API_URL:-}" ] && { [ -n "${RULEMETRIC_API_KEY:-}" ] || [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; }; then
11
+ return 0
12
+ fi
13
+
14
+ local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/rulemetric"
15
+
16
+ # Try global env file (shell-sourceable, written by `rulemetric auth login`)
17
+ if [ -f "$config_dir/env" ]; then
18
+ set -a
19
+ # shellcheck source=/dev/null
20
+ source "$config_dir/env"
21
+ set +a
22
+ if [ -n "${RULEMETRIC_API_URL:-}" ] && { [ -n "${RULEMETRIC_API_KEY:-}" ] || [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; }; then
23
+ return 0
24
+ fi
25
+ fi
26
+
27
+ # Try auth.json (written by `rulemetric auth login`)
28
+ if [ -f "$config_dir/auth.json" ] && command -v jq >/dev/null 2>&1; then
29
+ local token
30
+ token=$(jq -r '.accessToken // empty' "$config_dir/auth.json" 2>/dev/null)
31
+ if [ -n "$token" ]; then
32
+ export RULEMETRIC_ACCESS_TOKEN="$token"
33
+ # Default API URL if not set
34
+ export RULEMETRIC_API_URL="${RULEMETRIC_API_URL:-https://api.rulemetric.com}"
35
+ return 0
36
+ fi
37
+ fi
38
+
39
+ # Try project-local .env.local (development)
40
+ if [ -f .env.local ]; then
41
+ while IFS= read -r line || [ -n "$line" ]; do
42
+ case "$line" in
43
+ RULEMETRIC_*=*)
44
+ key="${line%%=*}"
45
+ value="${line#*=}"
46
+ # Only export valid variable names
47
+ case "$key" in
48
+ *[!A-Za-z0-9_]*) continue ;;
49
+ esac
50
+ export "$key=$value"
51
+ ;;
52
+ esac
53
+ done < .env.local
54
+ return 0
55
+ fi
56
+
57
+ return 0
58
+ }
59
+
60
+ _rulemetric_load_config
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Lazy session recovery
3
+ # If session-start failed (API was down), this creates the session on demand.
4
+ # Must be sourced AFTER _normalize.sh and _config.sh.
5
+ #
6
+ # Requires: HOOK_SESSION_ID, HOOK_TOOL, HOOK_CWD, RULEMETRIC_API_URL, AUTH_HEADER
7
+ # Sets: RW_SESSION_ID (non-empty on success)
8
+
9
+ _rulemetric_ensure_session() {
10
+ TEMP_DIR="${TMPDIR:-/tmp}/rulemetric"
11
+ RW_SESSION_ID=""
12
+
13
+ # Check for existing temp file first
14
+ if [ -f "$TEMP_DIR/$HOOK_SESSION_ID" ]; then
15
+ RW_SESSION_ID=$(cat "$TEMP_DIR/$HOOK_SESSION_ID")
16
+ if [ -n "$RW_SESSION_ID" ]; then
17
+ return 0
18
+ fi
19
+ fi
20
+
21
+ # No session — try to create one (lazy recovery)
22
+ # Use mkdir-based locking to prevent parallel tool calls from creating duplicate sessions
23
+ mkdir -p "$TEMP_DIR" || return 1
24
+ local lock_dir="$TEMP_DIR/$HOOK_SESSION_ID.sesslock"
25
+
26
+ # Acquire lock (mkdir is atomic on all POSIX systems)
27
+ local tries=0
28
+ while ! mkdir "$lock_dir" 2>/dev/null; do
29
+ sleep 0.01
30
+ tries=$((tries + 1))
31
+ if [ "$tries" -ge 500 ]; then
32
+ # Stale lock — force remove and retry
33
+ rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null
34
+ mkdir "$lock_dir" 2>/dev/null || return 1
35
+ break
36
+ fi
37
+ done
38
+
39
+ # Re-check after acquiring lock (another process may have created it)
40
+ if [ -f "$TEMP_DIR/$HOOK_SESSION_ID" ]; then
41
+ RW_SESSION_ID=$(cat "$TEMP_DIR/$HOOK_SESSION_ID")
42
+ if [ -n "$RW_SESSION_ID" ]; then
43
+ rmdir "$lock_dir" 2>/dev/null
44
+ return 0
45
+ fi
46
+ fi
47
+
48
+ local cwd="${HOOK_CWD:-$(pwd)}"
49
+ # Normalize to git root so subdirectories map to the same project
50
+ local git_root
51
+ git_root=$(cd "$cwd" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || echo "")
52
+ if [ -n "$git_root" ]; then
53
+ cwd="$git_root"
54
+ fi
55
+ local git_branch
56
+ git_branch=$(cd "$cwd" 2>/dev/null && git branch --show-current 2>/dev/null || echo "")
57
+ local git_remote
58
+ git_remote=$(cd "$cwd" 2>/dev/null && git remote get-url origin 2>/dev/null || echo "")
59
+
60
+ local metadata="{}"
61
+ if [ -n "$git_remote" ]; then
62
+ metadata=$(printf '{"gitRemote":%s}' "$(printf '%s' "$git_remote" | jq -Rs .)")
63
+ fi
64
+
65
+ local payload
66
+ payload=$(jq -n \
67
+ --arg tool "$HOOK_TOOL" \
68
+ --arg externalSessionId "$HOOK_SESSION_ID" \
69
+ --arg projectPath "$cwd" \
70
+ --arg gitBranch "$git_branch" \
71
+ --argjson metadata "$metadata" \
72
+ '{tool: $tool, externalSessionId: $externalSessionId, projectPath: $projectPath, gitBranch: $gitBranch, metadata: $metadata}')
73
+
74
+ local response
75
+ response=$(curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions" \
76
+ -H "$AUTH_HEADER" \
77
+ -H "Content-Type: application/json" \
78
+ -d "$payload" \
79
+ 2>/dev/null)
80
+
81
+ if [ "$?" -ne 0 ]; then
82
+ rmdir "$lock_dir" 2>/dev/null
83
+ return 1
84
+ fi
85
+
86
+ RW_SESSION_ID=$(echo "$response" | jq -r '.id // empty')
87
+ if [ -z "$RW_SESSION_ID" ]; then
88
+ rmdir "$lock_dir" 2>/dev/null
89
+ return 1
90
+ fi
91
+
92
+ echo "$RW_SESSION_ID" > "$TEMP_DIR/$HOOK_SESSION_ID"
93
+ echo "0" > "$TEMP_DIR/$HOOK_SESSION_ID.seq"
94
+
95
+ _rulemetric_log "${1:-ensure-session}" "recovered" "lazy_create"
96
+ rmdir "$lock_dir" 2>/dev/null
97
+
98
+ # Re-read the session ID written by the locked subshell
99
+ if [ -f "$TEMP_DIR/$HOOK_SESSION_ID" ]; then
100
+ RW_SESSION_ID=$(cat "$TEMP_DIR/$HOOK_SESSION_ID")
101
+ fi
102
+ [ -n "$RW_SESSION_ID" ]
103
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Structured hook logging
3
+ # Appends JSON lines to $TMPDIR/rulemetric/hook.log
4
+ # Never fails — all writes guarded with || true
5
+
6
+ _rulemetric_log() {
7
+ local script="${1:-unknown}"
8
+ local outcome="${2:-unknown}"
9
+ local reason="${3:-}"
10
+ local timestamp
11
+ timestamp="$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || echo "")"
12
+
13
+ local log_dir="${TMPDIR:-/tmp}/rulemetric"
14
+ mkdir -p "$log_dir" 2>/dev/null || true
15
+
16
+ if command -v jq >/dev/null 2>&1; then
17
+ jq -cn --arg ts "$timestamp" --arg script "$script" --arg outcome "$outcome" \
18
+ --arg reason "$reason" --arg session "${HOOK_SESSION_ID:-}" \
19
+ '{ts: $ts, script: $script, outcome: $outcome, reason: $reason, session: $session}' \
20
+ >> "$log_dir/hook.log" 2>/dev/null || true
21
+ else
22
+ # Fallback: escape quotes and backslashes to prevent JSON injection
23
+ local esc_script esc_outcome esc_reason esc_session
24
+ esc_script="${script//\\/\\\\}"; esc_script="${esc_script//\"/\\\"}"
25
+ esc_outcome="${outcome//\\/\\\\}"; esc_outcome="${esc_outcome//\"/\\\"}"
26
+ esc_reason="${reason//\\/\\\\}"; esc_reason="${esc_reason//\"/\\\"}"
27
+ esc_session="${HOOK_SESSION_ID:-}"; esc_session="${esc_session//\\/\\\\}"; esc_session="${esc_session//\"/\\\"}"
28
+ printf '{"ts":"%s","script":"%s","outcome":"%s","reason":"%s","session":"%s"}\n' \
29
+ "$timestamp" "$esc_script" "$esc_outcome" "$esc_reason" "$esc_session" \
30
+ >> "$log_dir/hook.log" 2>/dev/null || true
31
+ fi
32
+ }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Normalize hook input across AI coding tools
3
+ # Detects Claude Code, VS Code Copilot, Copilot CLI, and Cursor input formats,
4
+ # then exports canonical HOOK_* variables for use by other scripts.
5
+ #
6
+ # Usage: source "$SCRIPT_DIR/_normalize.sh" (after reading INPUT from stdin)
7
+ #
8
+ # Exports:
9
+ # HOOK_TOOL - "claude_code", "vscode_copilot", "copilot_cli", or "cursor"
10
+ # HOOK_SESSION_ID - Session identifier
11
+ # HOOK_CWD - Working directory
12
+ # HOOK_PROMPT - User prompt text
13
+ # HOOK_TOOL_NAME - Tool/command name
14
+ # HOOK_TOOL_INPUT - Tool input as JSON
15
+ # HOOK_TOOL_OUTPUT - Tool output as JSON
16
+ # HOOK_TRANSCRIPT_PATH - Path to transcript file (empty if unavailable)
17
+
18
+ _rulemetric_normalize() {
19
+ local input="$1"
20
+
21
+ # Detect format by checking for distinguishing fields
22
+ # Copilot CLI: has "toolName" (camelCase, no sessionId)
23
+ # VS Code Copilot: has "sessionId" (camelCase)
24
+ # Cursor: has "conversation_id" AND "hook_event_name"
25
+ # Claude Code: has "session_id" (snake_case) — fallback default
26
+
27
+ local has_session_id has_sessionId has_toolName has_conversation_id has_hook_event_name
28
+ has_session_id=$(echo "$input" | jq -r 'has("session_id")' 2>/dev/null)
29
+ has_sessionId=$(echo "$input" | jq -r 'has("sessionId")' 2>/dev/null)
30
+ has_toolName=$(echo "$input" | jq -r 'has("toolName")' 2>/dev/null)
31
+ has_conversation_id=$(echo "$input" | jq -r 'has("conversation_id")' 2>/dev/null)
32
+ has_hook_event_name=$(echo "$input" | jq -r 'has("hook_event_name")' 2>/dev/null)
33
+
34
+ if [ "$has_toolName" = "true" ] && [ "$has_sessionId" != "true" ]; then
35
+ # Copilot CLI: no session ID, uses toolName/toolArgs/toolResult
36
+ HOOK_TOOL="copilot_cli"
37
+
38
+ # Generate deterministic session ID from cwd hash (stable per-project)
39
+ local cwd
40
+ cwd=$(echo "$input" | jq -r '.cwd // empty')
41
+ if [ -n "$cwd" ]; then
42
+ HOOK_SESSION_ID="copilot-cli-$(printf '%s' "$cwd" | shasum -a 256 | cut -c1-16)"
43
+ else
44
+ HOOK_SESSION_ID="copilot-cli-$(printf '%s' "unknown" | shasum -a 256 | cut -c1-16)"
45
+ fi
46
+
47
+ HOOK_CWD="$cwd"
48
+ HOOK_PROMPT=$(echo "$input" | jq -r '.prompt // empty')
49
+ HOOK_TOOL_NAME=$(echo "$input" | jq -r '.toolName // empty')
50
+
51
+ # toolArgs is a JSON string — parse it to an object
52
+ local tool_args_raw
53
+ tool_args_raw=$(echo "$input" | jq -r '.toolArgs // empty')
54
+ if [ -n "$tool_args_raw" ]; then
55
+ # Try parsing as JSON; if it fails, wrap as string
56
+ HOOK_TOOL_INPUT=$(printf '%s' "$tool_args_raw" | jq -c '.' 2>/dev/null || printf '{"raw":%s}' "$(printf '%s' "$tool_args_raw" | jq -Rs .)")
57
+ else
58
+ HOOK_TOOL_INPUT="{}"
59
+ fi
60
+
61
+ # toolResult.textResultForLlm → wrap as {"result": "..."}
62
+ local tool_result
63
+ tool_result=$(echo "$input" | jq -r '.toolResult.textResultForLlm // empty')
64
+ if [ -n "$tool_result" ]; then
65
+ HOOK_TOOL_OUTPUT=$(printf '{"result":%s}' "$(printf '%s' "$tool_result" | jq -Rs .)")
66
+ else
67
+ HOOK_TOOL_OUTPUT="{}"
68
+ fi
69
+
70
+ HOOK_TRANSCRIPT_PATH=""
71
+
72
+ elif [ "$has_sessionId" = "true" ]; then
73
+ # VS Code Copilot (agent mode): sessionId (camelCase), tool_name, tool_input, tool_response
74
+ HOOK_TOOL="vscode_copilot"
75
+ HOOK_SESSION_ID=$(echo "$input" | jq -r '.sessionId // empty')
76
+ HOOK_CWD=$(echo "$input" | jq -r '.cwd // empty')
77
+ HOOK_PROMPT=$(echo "$input" | jq -r '.prompt // empty')
78
+ HOOK_TOOL_NAME=$(echo "$input" | jq -r '.tool_name // empty')
79
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.tool_input // {}')
80
+ HOOK_TOOL_OUTPUT=$(echo "$input" | jq -c '.tool_response // {}')
81
+ HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // empty')
82
+
83
+ elif [ "$has_conversation_id" = "true" ] && [ "$has_hook_event_name" = "true" ]; then
84
+ # Cursor: conversation_id + hook_event_name
85
+ HOOK_TOOL="cursor"
86
+
87
+ # Generate deterministic session ID from conversation_id hash
88
+ local conversation_id
89
+ conversation_id=$(echo "$input" | jq -r '.conversation_id // empty')
90
+ if [ -n "$conversation_id" ]; then
91
+ HOOK_SESSION_ID="cursor-$(printf '%s' "$conversation_id" | shasum -a 256 | cut -c1-16)"
92
+ else
93
+ HOOK_SESSION_ID="cursor-$(printf '%s' "unknown" | shasum -a 256 | cut -c1-16)"
94
+ fi
95
+
96
+ HOOK_CWD=$(echo "$input" | jq -r '.workspace_roots[0] // .cwd // empty')
97
+ HOOK_PROMPT=$(echo "$input" | jq -r '.prompt // empty')
98
+ HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // empty')
99
+
100
+ # Map hook_event_name to tool fields
101
+ local event_name
102
+ event_name=$(echo "$input" | jq -r '.hook_event_name // empty')
103
+
104
+ case "$event_name" in
105
+ beforeShellExecution)
106
+ HOOK_TOOL_NAME="bash"
107
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '{command: .command, cwd: .cwd} | with_entries(select(.value != null))' 2>/dev/null || echo '{}')
108
+ HOOK_TOOL_OUTPUT="{}"
109
+ ;;
110
+ afterFileEdit)
111
+ HOOK_TOOL_NAME="edit"
112
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '{file_path: .file_path, edits: .edits} | with_entries(select(.value != null))' 2>/dev/null || echo '{}')
113
+ HOOK_TOOL_OUTPUT="{}"
114
+ ;;
115
+ beforeReadFile)
116
+ HOOK_TOOL_NAME="read"
117
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '{file_path: .file_path} | with_entries(select(.value != null))' 2>/dev/null || echo '{}')
118
+ HOOK_TOOL_OUTPUT="{}"
119
+ ;;
120
+ beforeMCPExecution)
121
+ HOOK_TOOL_NAME=$(echo "$input" | jq -r '.tool_name // empty')
122
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.parameters // {}')
123
+ HOOK_TOOL_OUTPUT="{}"
124
+ ;;
125
+ *)
126
+ # Default: use generic fields if present (stop, beforeSubmitPrompt, etc.)
127
+ HOOK_TOOL_NAME=$(echo "$input" | jq -r '.tool_name // empty')
128
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.tool_input // {}')
129
+ HOOK_TOOL_OUTPUT=$(echo "$input" | jq -c '.tool_response // {}')
130
+ ;;
131
+ esac
132
+
133
+ else
134
+ # Claude Code (default): session_id (snake_case)
135
+ HOOK_TOOL="claude_code"
136
+ HOOK_SESSION_ID=$(echo "$input" | jq -r '.session_id // empty')
137
+ HOOK_CWD=$(echo "$input" | jq -r '.cwd // empty')
138
+ HOOK_PROMPT=$(echo "$input" | jq -r '.prompt // empty')
139
+ HOOK_TOOL_NAME=$(echo "$input" | jq -r '.tool_name // empty')
140
+ HOOK_TOOL_INPUT=$(echo "$input" | jq -c '.tool_input // {}')
141
+ HOOK_TOOL_OUTPUT=$(echo "$input" | jq -c '.tool_response // {}')
142
+ HOOK_TRANSCRIPT_PATH=$(echo "$input" | jq -r '.transcript_path // empty')
143
+ fi
144
+
145
+ # Truncate large payloads (Read tool output can be massive)
146
+ local max_len=4000
147
+ if [ "${#HOOK_TOOL_OUTPUT}" -gt "$max_len" ]; then
148
+ HOOK_TOOL_OUTPUT=$(printf '%s' "$HOOK_TOOL_OUTPUT" | head -c "$max_len")
149
+ # Re-wrap as valid JSON string if we truncated
150
+ HOOK_TOOL_OUTPUT="{\"truncated\":true,\"preview\":$(printf '%s' "$HOOK_TOOL_OUTPUT" | jq -Rs . 2>/dev/null || echo '""')}"
151
+ fi
152
+ if [ "${#HOOK_TOOL_INPUT}" -gt "$max_len" ]; then
153
+ HOOK_TOOL_INPUT=$(printf '%s' "$HOOK_TOOL_INPUT" | head -c "$max_len")
154
+ HOOK_TOOL_INPUT="{\"truncated\":true,\"preview\":$(printf '%s' "$HOOK_TOOL_INPUT" | jq -Rs . 2>/dev/null || echo '""')}"
155
+ fi
156
+
157
+ # Validate HOOK_SESSION_ID format (prevent path traversal)
158
+ # Accepts: standard UUIDs (case-insensitive), copilot-cli-<hex>, and cursor-<hex> synthetic IDs
159
+ if [ -n "$HOOK_SESSION_ID" ]; then
160
+ if ! echo "$HOOK_SESSION_ID" | grep -qiE '^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|copilot-cli-[0-9a-f]{1,64}|cursor-[0-9a-f]{1,64})$'; then
161
+ HOOK_SESSION_ID=""
162
+ fi
163
+ fi
164
+
165
+ export HOOK_TOOL HOOK_SESSION_ID HOOK_CWD HOOK_PROMPT
166
+ export HOOK_TOOL_NAME HOOK_TOOL_INPUT HOOK_TOOL_OUTPUT HOOK_TRANSCRIPT_PATH
167
+ }
168
+
169
+ # INPUT must be set before sourcing this file
170
+ _rulemetric_normalize "$INPUT"
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Capture tool use events in real-time
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ source "$SCRIPT_DIR/_log.sh" 2>/dev/null || _rulemetric_log() { :; }
5
+
6
+ set -euo pipefail
7
+ trap 'echo "rulemetric post-tool-use failed at line $LINENO: $BASH_COMMAND" >&2; _rulemetric_log "post-tool-use" "error" "line $LINENO: $BASH_COMMAND"; exit 1' ERR
8
+
9
+ # Load config (env vars, ~/.config/rulemetric/env, or .env.local)
10
+ source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
11
+
12
+ # Read hook input from stdin and normalize across tools
13
+ INPUT=$(cat)
14
+ source "$SCRIPT_DIR/_normalize.sh"
15
+
16
+ if [ -z "$HOOK_SESSION_ID" ] || [ -z "${RULEMETRIC_API_URL:-}" ]; then
17
+ _rulemetric_log "post-tool-use" "skip" "no_config"
18
+ exit 0
19
+ fi
20
+
21
+ # Auth
22
+ AUTH_HEADER=""
23
+ if [ -n "${RULEMETRIC_API_KEY:-}" ]; then
24
+ AUTH_HEADER="Authorization: Bearer $RULEMETRIC_API_KEY"
25
+ elif [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; then
26
+ AUTH_HEADER="Authorization: Bearer $RULEMETRIC_ACCESS_TOKEN"
27
+ else
28
+ _rulemetric_log "post-tool-use" "skip" "no_auth"
29
+ exit 0
30
+ fi
31
+
32
+ # Get or lazily create RuleMetric session
33
+ source "$SCRIPT_DIR/_ensure-session.sh"
34
+ if ! _rulemetric_ensure_session "post-tool-use"; then
35
+ _rulemetric_log "post-tool-use" "skip" "no_rw_session"
36
+ exit 0
37
+ fi
38
+
39
+ # Increment sequence counter (atomic mkdir lock, works on macOS + Linux)
40
+ SEQ_FILE="$TEMP_DIR/$HOOK_SESSION_ID.seq"
41
+ LOCK_DIR="$TEMP_DIR/$HOOK_SESSION_ID.seqlock"
42
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do sleep 0.01; done
43
+ SEQ=1
44
+ if [ -f "$SEQ_FILE" ]; then
45
+ SEQ=$(( $(cat "$SEQ_FILE") + 1 ))
46
+ fi
47
+ echo "$SEQ" > "$SEQ_FILE"
48
+ rmdir "$LOCK_DIR"
49
+
50
+ # Build payload — toolInput/toolOutput as JSON objects
51
+ PAYLOAD=$(jq -n \
52
+ --argjson seq "$SEQ" \
53
+ --arg toolName "$HOOK_TOOL_NAME" \
54
+ --argjson toolInput "$HOOK_TOOL_INPUT" \
55
+ --argjson toolOutput "$HOOK_TOOL_OUTPUT" \
56
+ --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
57
+ '{sequence: $seq, eventType: "tool_call", toolName: $toolName, toolInput: $toolInput, toolOutput: $toolOutput, timestamp: $timestamp}')
58
+
59
+ _rulemetric_log "post-tool-use" "success"
60
+
61
+ # Fire-and-forget (capture HTTP errors for debugging)
62
+ (CURL_OUT=$(curl -s -w "\n%{http_code}" -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
63
+ -H "$AUTH_HEADER" \
64
+ -H "Content-Type: application/json" \
65
+ -d "$PAYLOAD" 2>&1)
66
+ HTTP_CODE=$(echo "$CURL_OUT" | tail -1)
67
+ if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
68
+ BODY=$(echo "$CURL_OUT" | sed '$d')
69
+ _rulemetric_log "post-tool-use" "error" "http_${HTTP_CODE}_seq_${SEQ}: ${BODY}"
70
+ fi) &
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Finalize session with full transcript import
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ source "$SCRIPT_DIR/_log.sh" 2>/dev/null || _rulemetric_log() { :; }
5
+
6
+ set -euo pipefail
7
+ trap 'echo "rulemetric session-end failed at line $LINENO: $BASH_COMMAND" >&2; _rulemetric_log "session-end" "error" "line $LINENO: $BASH_COMMAND"; exit 1' ERR
8
+
9
+ # Load config (env vars, ~/.config/rulemetric/env, or .env.local)
10
+ source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
11
+
12
+ # Read hook input from stdin and normalize across tools
13
+ INPUT=$(cat)
14
+ source "$SCRIPT_DIR/_normalize.sh"
15
+
16
+ if [ -z "$HOOK_SESSION_ID" ] || [ -z "${RULEMETRIC_API_URL:-}" ]; then
17
+ _rulemetric_log "session-end" "skip" "no_config"
18
+ exit 0
19
+ fi
20
+
21
+ # Auth
22
+ AUTH_HEADER=""
23
+ if [ -n "${RULEMETRIC_API_KEY:-}" ]; then
24
+ AUTH_HEADER="Authorization: Bearer $RULEMETRIC_API_KEY"
25
+ elif [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; then
26
+ AUTH_HEADER="Authorization: Bearer $RULEMETRIC_ACCESS_TOKEN"
27
+ else
28
+ _rulemetric_log "session-end" "skip" "no_auth"
29
+ exit 0
30
+ fi
31
+
32
+ # Get or lazily create RuleMetric session
33
+ source "$SCRIPT_DIR/_ensure-session.sh"
34
+ if ! _rulemetric_ensure_session "session-end"; then
35
+ _rulemetric_log "session-end" "skip" "no_rw_session"
36
+ exit 0
37
+ fi
38
+
39
+ # Try to reimport with full transcript, fall back to close
40
+ if [ -n "$HOOK_TRANSCRIPT_PATH" ] && [ -f "$HOOK_TRANSCRIPT_PATH" ]; then
41
+ CONTENT=$(cat "$HOOK_TRANSCRIPT_PATH")
42
+ curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/reimport" \
43
+ -H "$AUTH_HEADER" \
44
+ -H "Content-Type: application/json" \
45
+ -d "{\"content\":$(echo "$CONTENT" | jq -Rs .),\"tool\":\"$HOOK_TOOL\"}" \
46
+ > /dev/null 2>&1 || {
47
+ _rulemetric_log "session-end" "fallback" "reimport_failed"
48
+ curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
49
+ -H "$AUTH_HEADER" \
50
+ > /dev/null 2>&1 || \
51
+ _rulemetric_log "session-end" "error" "close_failed"
52
+ }
53
+ else
54
+ # No transcript available — just close the session
55
+ curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/close" \
56
+ -H "$AUTH_HEADER" \
57
+ > /dev/null 2>&1 || \
58
+ _rulemetric_log "session-end" "error" "close_failed"
59
+ fi
60
+
61
+ _rulemetric_log "session-end" "success"
62
+
63
+ # ── Auto-link instructions from context snapshots ──
64
+ # Fire-and-forget: link any captured context to instruction records
65
+ (curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions/link-instructions" \
66
+ -H "$AUTH_HEADER" \
67
+ -H "Content-Type: application/json" \
68
+ -d '{}' > /dev/null 2>&1) &
69
+
70
+ # ── Auto-enrich from local session-meta ──
71
+ # For Claude Code: read local session-meta file
72
+ # For other tools: derive enrichment from stored session events via API
73
+ if [ "$HOOK_TOOL" = "claude_code" ]; then
74
+ SESSION_META_FILE="$HOME/.claude/usage-data/session-meta/$HOOK_SESSION_ID.json"
75
+ if [ -f "$SESSION_META_FILE" ]; then
76
+ # Build enrichment payload from local session-meta
77
+ ENRICH_PAYLOAD=$(SESSION_META_FILE="$SESSION_META_FILE" python3 -c "
78
+ import json, os, sys
79
+ try:
80
+ meta_file = os.environ['SESSION_META_FILE']
81
+ meta = json.load(open(meta_file, 'rb'), strict=False)
82
+ payload = {
83
+ 'metadata': {
84
+ 'local_session_meta': {
85
+ 'duration_minutes': meta.get('duration_minutes', 0),
86
+ 'user_message_count': meta.get('user_message_count', 0),
87
+ 'assistant_message_count': meta.get('assistant_message_count', 0),
88
+ 'tool_counts': meta.get('tool_counts', {}),
89
+ 'languages': meta.get('languages', {}),
90
+ 'git_commits': meta.get('git_commits', 0),
91
+ 'git_pushes': meta.get('git_pushes', 0),
92
+ 'user_interruptions': meta.get('user_interruptions', 0),
93
+ 'tool_errors': meta.get('tool_errors', 0),
94
+ 'lines_added': meta.get('lines_added', 0),
95
+ 'lines_removed': meta.get('lines_removed', 0),
96
+ 'files_modified': meta.get('files_modified', 0),
97
+ }
98
+ }
99
+ }
100
+ if meta.get('input_tokens', 0) > 0 or meta.get('output_tokens', 0) > 0:
101
+ payload['tokenUsage'] = {
102
+ 'input_tokens': meta.get('input_tokens', 0),
103
+ 'output_tokens': meta.get('output_tokens', 0),
104
+ }
105
+ print(json.dumps(payload))
106
+ except:
107
+ pass
108
+ " 2>/dev/null)
109
+
110
+ if [ -n "$ENRICH_PAYLOAD" ]; then
111
+ (curl -sf -X PATCH "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/enrich" \
112
+ -H "$AUTH_HEADER" \
113
+ -H "Content-Type: application/json" \
114
+ -d "$ENRICH_PAYLOAD" > /dev/null 2>&1) &
115
+ fi
116
+ fi
117
+ else
118
+ # Non-Claude tools: derive enrichment from stored session events
119
+ (curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/derive-enrichment" \
120
+ -H "$AUTH_HEADER" \
121
+ -H "Content-Type: application/json" \
122
+ -d '{}' > /dev/null 2>&1) &
123
+ fi
124
+
125
+ # ── Auto-generate recommendations (daily debounce) ──
126
+ # Queue a recommendation job if one hasn't been generated today.
127
+ # The worker picks it up and runs 7 Opus prompts (~$5-15 per run).
128
+ (curl -sf -X POST "${RULEMETRIC_API_URL}/api/insights-jobs" \
129
+ -H "$AUTH_HEADER" \
130
+ -H "Content-Type: application/json" \
131
+ -d "{\"dedup\":\"daily\",\"projectPath\":\"${HOOK_CWD}\"}" > /dev/null 2>&1) &
132
+
133
+ # Clean up temp files
134
+ rm -f "$TEMP_DIR/$HOOK_SESSION_ID" 2>/dev/null || true
135
+ rm -f "$TEMP_DIR/$HOOK_SESSION_ID.seq" 2>/dev/null || true
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Session start hook
3
+ # Ensures gateway + proxy are running. Session creation is handled by the proxy.
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ source "$SCRIPT_DIR/_log.sh" 2>/dev/null || _rulemetric_log() { :; }
6
+
7
+ set -euo pipefail
8
+ trap 'echo "rulemetric session-start failed at line $LINENO: $BASH_COMMAND" >&2; _rulemetric_log "session-start" "error" "line $LINENO: $BASH_COMMAND"; exit 1' ERR
9
+
10
+ # Load config (env vars, ~/.config/rulemetric/env, or .env.local)
11
+ source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
12
+
13
+ # Read hook input from stdin and normalize across tools
14
+ INPUT=$(cat)
15
+ source "$SCRIPT_DIR/_normalize.sh"
16
+
17
+ if [ -z "$HOOK_SESSION_ID" ]; then
18
+ _rulemetric_log "session-start" "skip" "no_session_id"
19
+ exit 0
20
+ fi
21
+
22
+ _rulemetric_log "session-start" "success"
23
+
24
+ # ── Gateway auto-recovery ──
25
+ # Ensure the gateway proxy is running so Claude Code can always connect.
26
+ # The gateway routes direct when mitmproxy is off, so this is non-breaking.
27
+ GATEWAY_PID_FILE="$HOME/.config/rulemetric/gateway.pid"
28
+
29
+ if [ -f "$GATEWAY_PID_FILE" ]; then
30
+ GATEWAY_PID=$(cat "$GATEWAY_PID_FILE" 2>/dev/null)
31
+ if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
32
+ # Gateway crashed — respawn it
33
+ rulemetric gateway ensure 2>/dev/null || true
34
+ fi
35
+ else
36
+ # No PID file — try to start gateway
37
+ rulemetric gateway ensure 2>/dev/null || true
38
+ fi
39
+
40
+ # ── Proxy auto-recovery ──
41
+ # Ensure the capture proxy is running so events + context are captured.
42
+ # Same pattern as gateway recovery above.
43
+ PROXY_PID_FILE="$HOME/.config/rulemetric/proxy.pid"
44
+
45
+ proxy_alive() {
46
+ if [ ! -f "$PROXY_PID_FILE" ]; then
47
+ return 1
48
+ fi
49
+ local pid
50
+ pid=$(cat "$PROXY_PID_FILE" 2>/dev/null) || return 1
51
+ kill -0 "$pid" 2>/dev/null
52
+ }
53
+
54
+ if ! proxy_alive; then
55
+ # Proxy is not running — try to restart it
56
+ rm -f "$PROXY_PID_FILE" 2>/dev/null
57
+ if command -v rulemetric >/dev/null 2>&1; then
58
+ rulemetric proxy start --port 8788 --skip-checks 2>/dev/null &
59
+ disown 2>/dev/null || true
60
+ fi
61
+ fi
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ # RuleMetric: Capture user prompts in real-time
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ source "$SCRIPT_DIR/_log.sh" 2>/dev/null || _rulemetric_log() { :; }
5
+
6
+ set -euo pipefail
7
+ trap 'echo "rulemetric user-prompt failed at line $LINENO: $BASH_COMMAND" >&2; _rulemetric_log "user-prompt" "error" "line $LINENO: $BASH_COMMAND"; exit 1' ERR
8
+
9
+ # Load config (env vars, ~/.config/rulemetric/env, or .env.local)
10
+ source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
11
+
12
+ # Read hook input from stdin and normalize across tools
13
+ INPUT=$(cat)
14
+ source "$SCRIPT_DIR/_normalize.sh"
15
+
16
+ if [ -z "$HOOK_SESSION_ID" ] || [ -z "${RULEMETRIC_API_URL:-}" ]; then
17
+ _rulemetric_log "user-prompt" "skip" "no_config"
18
+ exit 0
19
+ fi
20
+
21
+ # Auth
22
+ AUTH_HEADER=""
23
+ if [ -n "${RULEMETRIC_API_KEY:-}" ]; then
24
+ AUTH_HEADER="Authorization: Bearer $RULEMETRIC_API_KEY"
25
+ elif [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; then
26
+ AUTH_HEADER="Authorization: Bearer $RULEMETRIC_ACCESS_TOKEN"
27
+ else
28
+ _rulemetric_log "user-prompt" "skip" "no_auth"
29
+ exit 0
30
+ fi
31
+
32
+ # Get or lazily create RuleMetric session
33
+ source "$SCRIPT_DIR/_ensure-session.sh"
34
+ if ! _rulemetric_ensure_session "user-prompt"; then
35
+ _rulemetric_log "user-prompt" "skip" "no_rw_session"
36
+ exit 0
37
+ fi
38
+
39
+ # Increment sequence counter (atomic mkdir lock, works on macOS + Linux)
40
+ SEQ_FILE="$TEMP_DIR/$HOOK_SESSION_ID.seq"
41
+ LOCK_DIR="$TEMP_DIR/$HOOK_SESSION_ID.seqlock"
42
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do sleep 0.01; done
43
+ SEQ=1
44
+ if [ -f "$SEQ_FILE" ]; then
45
+ SEQ=$(( $(cat "$SEQ_FILE") + 1 ))
46
+ fi
47
+ echo "$SEQ" > "$SEQ_FILE"
48
+ rmdir "$LOCK_DIR"
49
+
50
+ # Fire-and-forget: send user message event (capture HTTP errors for debugging)
51
+ (CURL_OUT=$(curl -s -w "\n%{http_code}" -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
52
+ -H "$AUTH_HEADER" \
53
+ -H "Content-Type: application/json" \
54
+ -d "{\"sequence\":$SEQ,\"eventType\":\"user_message\",\"content\":$(printf '%s' "$HOOK_PROMPT" | jq -Rs .),\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\"}" 2>&1)
55
+ HTTP_CODE=$(echo "$CURL_OUT" | tail -1)
56
+ if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
57
+ BODY=$(echo "$CURL_OUT" | sed '$d')
58
+ _rulemetric_log "user-prompt" "error" "http_${HTTP_CODE}_seq_${SEQ}: ${BODY}"
59
+ fi) &
60
+
61
+ _rulemetric_log "user-prompt" "success"
62
+
63
+ # Context capture is now handled by the proxy sniffer (`rulemetric proxy start`).
64
+ # File-based scanning via _context-scan.sh is available as a manual fallback.
package/scripts-dir.js ADDED
@@ -0,0 +1,3 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ export const scriptsDir = join(dirname(fileURLToPath(import.meta.url)), 'scripts');