@rulemetric/hooks 0.1.1 → 0.2.0
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/_config.sh +49 -2
- package/scripts/_ensure-session.sh +64 -9
- package/scripts/assistant-response.sh +126 -0
- package/scripts/session-start.sh +125 -0
package/package.json
CHANGED
package/scripts/_config.sh
CHANGED
|
@@ -4,14 +4,57 @@
|
|
|
4
4
|
# 1. Already-set environment variables (e.g. from shell profile)
|
|
5
5
|
# 2. ~/.config/rulemetric/env (global user config, created by `rulemetric login`)
|
|
6
6
|
# 3. .env.local in project root (development)
|
|
7
|
+
#
|
|
8
|
+
# Also loads the active org from $config_dir/active-org (Phase 8.1).
|
|
9
|
+
# Env var RULEMETRIC_ORG_ID always wins over the file. The variable is
|
|
10
|
+
# exported so child curl/jq invocations and downstream scripts can see it.
|
|
11
|
+
|
|
12
|
+
_rulemetric_config_dir() {
|
|
13
|
+
if [ -n "${RULEMETRIC_CONFIG_DIR:-}" ]; then
|
|
14
|
+
printf '%s' "$RULEMETRIC_CONFIG_DIR"
|
|
15
|
+
return
|
|
16
|
+
fi
|
|
17
|
+
printf '%s' "${XDG_CONFIG_HOME:-$HOME/.config}/rulemetric"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Resolve the active org ID into RULEMETRIC_ORG_ID. Env beats file. A missing
|
|
21
|
+
# or unparseable file is silently ignored — capture must never break.
|
|
22
|
+
_rulemetric_load_active_org() {
|
|
23
|
+
if [ -n "${RULEMETRIC_ORG_ID:-}" ]; then
|
|
24
|
+
export RULEMETRIC_ORG_ID
|
|
25
|
+
return 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
local config_dir
|
|
29
|
+
config_dir=$(_rulemetric_config_dir)
|
|
30
|
+
local active_file="$config_dir/active-org"
|
|
31
|
+
|
|
32
|
+
if [ ! -f "$active_file" ]; then
|
|
33
|
+
return 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
37
|
+
return 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
local org_id
|
|
41
|
+
# `|| true` so a parse error from jq doesn't abort under `set -e`.
|
|
42
|
+
org_id=$(jq -r '.orgId // empty' "$active_file" 2>/dev/null || true)
|
|
43
|
+
if [ -n "$org_id" ] && [ "$org_id" != "null" ]; then
|
|
44
|
+
export RULEMETRIC_ORG_ID="$org_id"
|
|
45
|
+
fi
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
7
48
|
|
|
8
49
|
_rulemetric_load_config() {
|
|
9
|
-
# If already configured, nothing to do
|
|
50
|
+
# If already configured, nothing to do for credentials.
|
|
10
51
|
if [ -n "${RULEMETRIC_API_URL:-}" ] && { [ -n "${RULEMETRIC_API_KEY:-}" ] || [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; }; then
|
|
52
|
+
_rulemetric_load_active_org
|
|
11
53
|
return 0
|
|
12
54
|
fi
|
|
13
55
|
|
|
14
|
-
local config_dir
|
|
56
|
+
local config_dir
|
|
57
|
+
config_dir=$(_rulemetric_config_dir)
|
|
15
58
|
|
|
16
59
|
# Try global env file (shell-sourceable, written by `rulemetric auth login`)
|
|
17
60
|
if [ -f "$config_dir/env" ]; then
|
|
@@ -20,6 +63,7 @@ _rulemetric_load_config() {
|
|
|
20
63
|
source "$config_dir/env"
|
|
21
64
|
set +a
|
|
22
65
|
if [ -n "${RULEMETRIC_API_URL:-}" ] && { [ -n "${RULEMETRIC_API_KEY:-}" ] || [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; }; then
|
|
66
|
+
_rulemetric_load_active_org
|
|
23
67
|
return 0
|
|
24
68
|
fi
|
|
25
69
|
fi
|
|
@@ -32,6 +76,7 @@ _rulemetric_load_config() {
|
|
|
32
76
|
export RULEMETRIC_ACCESS_TOKEN="$token"
|
|
33
77
|
# Default API URL if not set
|
|
34
78
|
export RULEMETRIC_API_URL="${RULEMETRIC_API_URL:-https://api.rulemetric.com}"
|
|
79
|
+
_rulemetric_load_active_org
|
|
35
80
|
return 0
|
|
36
81
|
fi
|
|
37
82
|
fi
|
|
@@ -51,9 +96,11 @@ _rulemetric_load_config() {
|
|
|
51
96
|
;;
|
|
52
97
|
esac
|
|
53
98
|
done < .env.local
|
|
99
|
+
_rulemetric_load_active_org
|
|
54
100
|
return 0
|
|
55
101
|
fi
|
|
56
102
|
|
|
103
|
+
_rulemetric_load_active_org
|
|
57
104
|
return 0
|
|
58
105
|
}
|
|
59
106
|
|
|
@@ -52,14 +52,68 @@ _rulemetric_ensure_session() {
|
|
|
52
52
|
if [ -n "$git_root" ]; then
|
|
53
53
|
cwd="$git_root"
|
|
54
54
|
fi
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
55
|
+
|
|
56
|
+
# GUARD: a top-level user home dir (e.g. `/Users/foo`, `/home/bar`) is
|
|
57
|
+
# never a real project root, even if some env quirk lets git resolve a
|
|
58
|
+
# repo from there (stale GIT_DIR, walked-up worktree, etc.). Suppress
|
|
59
|
+
# all git context capture so we don't pollute the session row with a
|
|
60
|
+
# remote that doesn't actually belong to this path.
|
|
61
|
+
local is_home_dir="false"
|
|
62
|
+
if [[ "$cwd" =~ ^/(Users|home)/[^/]+/?$ ]]; then
|
|
63
|
+
is_home_dir="true"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
local git_branch=""
|
|
67
|
+
local git_remote=""
|
|
68
|
+
local git_root_commit=""
|
|
69
|
+
local is_worktree="false"
|
|
70
|
+
|
|
71
|
+
if [ "$is_home_dir" = "false" ]; then
|
|
72
|
+
git_branch=$(cd "$cwd" 2>/dev/null && git branch --show-current 2>/dev/null || echo "")
|
|
73
|
+
git_remote=$(cd "$cwd" 2>/dev/null && git remote get-url origin 2>/dev/null || echo "")
|
|
74
|
+
# Root commit = SHA of the very first commit. Stable forever (unless
|
|
75
|
+
# someone rewrites root). Survives URL renames; secondary identity for
|
|
76
|
+
# project matching. Empty string if repo has no commits / not a repo.
|
|
77
|
+
git_root_commit=$(cd "$cwd" 2>/dev/null && git rev-list --max-parents=0 HEAD 2>/dev/null | tail -1 || echo "")
|
|
78
|
+
# Worktree detection: in a worktree, `git rev-parse --git-dir` differs
|
|
79
|
+
# from `--git-common-dir`. The capture metadata gets a boolean so the
|
|
80
|
+
# API can tag the project_checkouts row as is_worktree=true.
|
|
81
|
+
local git_dir
|
|
82
|
+
git_dir=$(cd "$cwd" 2>/dev/null && git rev-parse --git-dir 2>/dev/null || echo "")
|
|
83
|
+
local git_common_dir
|
|
84
|
+
git_common_dir=$(cd "$cwd" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null || echo "")
|
|
85
|
+
if [ -n "$git_dir" ] && [ -n "$git_common_dir" ] && [ "$git_dir" != "$git_common_dir" ]; then
|
|
86
|
+
is_worktree="true"
|
|
87
|
+
fi
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# Explicit launch attribution (Phase 12). When a launcher (the web
|
|
91
|
+
# dashboard's "Launch Session", a CLI wrapper, or the user themselves)
|
|
92
|
+
# exports RULEMETRIC_LAUNCH_PROJECT_PATH, capture it as a stronger
|
|
93
|
+
# attribution signal than cwd. Use case: a skill chdir's into a target
|
|
94
|
+
# repo before invoking claude — without this hint, cwd at hook time
|
|
95
|
+
# describes where the work happens, not which project actually launched
|
|
96
|
+
# the session.
|
|
97
|
+
local launch_project_path="${RULEMETRIC_LAUNCH_PROJECT_PATH:-}"
|
|
98
|
+
|
|
99
|
+
local metadata
|
|
100
|
+
metadata=$(jq -n \
|
|
101
|
+
--arg gitRemote "$git_remote" \
|
|
102
|
+
--arg gitRootCommit "$git_root_commit" \
|
|
103
|
+
--arg launchProjectPath "$launch_project_path" \
|
|
104
|
+
--argjson isWorktree "$is_worktree" \
|
|
105
|
+
'{} + (if $gitRemote != "" then {gitRemote: $gitRemote} else {} end)
|
|
106
|
+
+ (if $gitRootCommit != "" then {gitRootCommit: $gitRootCommit} else {} end)
|
|
107
|
+
+ (if $launchProjectPath != "" then {launchProjectPath: $launchProjectPath} else {} end)
|
|
108
|
+
+ {isWorktree: $isWorktree}')
|
|
109
|
+
|
|
110
|
+
# Optional org attribution (Phase 8.1). RULEMETRIC_ORG_ID is exported by
|
|
111
|
+
# _config.sh — env wins over the local active-org file. If unset/empty, we
|
|
112
|
+
# encode `null` so the API can validate it cleanly. The API silently nulls
|
|
113
|
+
# the value if the user isn't a member, so capture never breaks.
|
|
114
|
+
local org_id_arg='null'
|
|
115
|
+
if [ -n "${RULEMETRIC_ORG_ID:-}" ]; then
|
|
116
|
+
org_id_arg=$(jq -n --arg v "$RULEMETRIC_ORG_ID" '$v')
|
|
63
117
|
fi
|
|
64
118
|
|
|
65
119
|
local payload
|
|
@@ -69,7 +123,8 @@ _rulemetric_ensure_session() {
|
|
|
69
123
|
--arg projectPath "$cwd" \
|
|
70
124
|
--arg gitBranch "$git_branch" \
|
|
71
125
|
--argjson metadata "$metadata" \
|
|
72
|
-
|
|
126
|
+
--argjson orgId "$org_id_arg" \
|
|
127
|
+
'{tool: $tool, externalSessionId: $externalSessionId, projectPath: $projectPath, gitBranch: $gitBranch, metadata: $metadata, orgId: $orgId}')
|
|
73
128
|
|
|
74
129
|
local response
|
|
75
130
|
response=$(curl -sf -X POST "${RULEMETRIC_API_URL}/api/sessions" \
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# RuleMetric: Capture assistant-response events from harness hooks.
|
|
3
|
+
#
|
|
4
|
+
# Receives the assistant's response text + token usage. Cursor's
|
|
5
|
+
# `afterAgentResponse` event exposes both directly; Claude Code emits the
|
|
6
|
+
# equivalent via SessionEnd telemetry, and Copilot's `afterAgentResponse`
|
|
7
|
+
# in agent mode follows the same shape. This script posts a single
|
|
8
|
+
# event of type `assistant_response` with the text as `content` and the
|
|
9
|
+
# token usage in `tokenUsage`.
|
|
10
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
+
source "$SCRIPT_DIR/_log.sh" 2>/dev/null || _rulemetric_log() { :; }
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
trap 'echo "rulemetric assistant-response failed at line $LINENO: $BASH_COMMAND" >&2; _rulemetric_log "assistant-response" "error" "line $LINENO: $BASH_COMMAND"; exit 1' ERR
|
|
15
|
+
|
|
16
|
+
# Load config (env vars, ~/.config/rulemetric/env, or .env.local)
|
|
17
|
+
source "$SCRIPT_DIR/_config.sh" 2>/dev/null || true
|
|
18
|
+
|
|
19
|
+
INPUT=$(cat)
|
|
20
|
+
source "$SCRIPT_DIR/_normalize.sh"
|
|
21
|
+
|
|
22
|
+
if [ -z "$HOOK_SESSION_ID" ] || [ -z "${RULEMETRIC_API_URL:-}" ]; then
|
|
23
|
+
_rulemetric_log "assistant-response" "skip" "no_config"
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Auth
|
|
28
|
+
AUTH_HEADER=""
|
|
29
|
+
if [ -n "${RULEMETRIC_API_KEY:-}" ]; then
|
|
30
|
+
AUTH_HEADER="Authorization: Bearer $RULEMETRIC_API_KEY"
|
|
31
|
+
elif [ -n "${RULEMETRIC_ACCESS_TOKEN:-}" ]; then
|
|
32
|
+
AUTH_HEADER="Authorization: Bearer $RULEMETRIC_ACCESS_TOKEN"
|
|
33
|
+
else
|
|
34
|
+
_rulemetric_log "assistant-response" "skip" "no_auth"
|
|
35
|
+
exit 0
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Get or lazily create RuleMetric session (same as post-tool-use)
|
|
39
|
+
source "$SCRIPT_DIR/_ensure-session.sh"
|
|
40
|
+
if ! _rulemetric_ensure_session "assistant-response"; then
|
|
41
|
+
_rulemetric_log "assistant-response" "skip" "no_rw_session"
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Extract assistant response fields. Cursor's afterAgentResponse uses `text`
|
|
46
|
+
# + flat token counters. Claude Code's session-end / Copilot's response
|
|
47
|
+
# hooks may use different field names, but for now this script is wired
|
|
48
|
+
# only for Cursor — the harness-specific extraction is intentionally
|
|
49
|
+
# explicit rather than via _normalize.sh, since the shapes diverge.
|
|
50
|
+
TEXT=$(echo "$INPUT" | jq -r '.text // .content // empty')
|
|
51
|
+
if [ -z "$TEXT" ]; then
|
|
52
|
+
_rulemetric_log "assistant-response" "skip" "no_text"
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
GENERATION_ID=$(echo "$INPUT" | jq -r '.generation_id // empty')
|
|
57
|
+
INPUT_TOKENS=$(echo "$INPUT" | jq -r '.input_tokens // empty')
|
|
58
|
+
OUTPUT_TOKENS=$(echo "$INPUT" | jq -r '.output_tokens // empty')
|
|
59
|
+
CACHE_READ=$(echo "$INPUT" | jq -r '.cache_read_tokens // empty')
|
|
60
|
+
CACHE_WRITE=$(echo "$INPUT" | jq -r '.cache_write_tokens // empty')
|
|
61
|
+
|
|
62
|
+
# Build token_usage object (only include fields the harness actually sent)
|
|
63
|
+
TOKEN_USAGE=$(jq -n \
|
|
64
|
+
--arg in "$INPUT_TOKENS" \
|
|
65
|
+
--arg out "$OUTPUT_TOKENS" \
|
|
66
|
+
--arg cr "$CACHE_READ" \
|
|
67
|
+
--arg cw "$CACHE_WRITE" \
|
|
68
|
+
'{} +
|
|
69
|
+
(if $in != "" then {input_tokens: ($in | tonumber)} else {} end) +
|
|
70
|
+
(if $out != "" then {output_tokens: ($out | tonumber)} else {} end) +
|
|
71
|
+
(if $cr != "" then {cache_read_input_tokens: ($cr | tonumber)} else {} end) +
|
|
72
|
+
(if $cw != "" then {cache_creation_input_tokens: ($cw | tonumber)} else {} end)')
|
|
73
|
+
|
|
74
|
+
# Increment per-session sequence (same locking pattern as post-tool-use)
|
|
75
|
+
SEQ_FILE="$TEMP_DIR/$HOOK_SESSION_ID.seq"
|
|
76
|
+
LOCK_DIR="$TEMP_DIR/$HOOK_SESSION_ID.seqlock"
|
|
77
|
+
while ! mkdir "$LOCK_DIR" 2>/dev/null; do sleep 0.01; done
|
|
78
|
+
SEQ=1
|
|
79
|
+
if [ -f "$SEQ_FILE" ]; then
|
|
80
|
+
SEQ=$(( $(cat "$SEQ_FILE") + 1 ))
|
|
81
|
+
fi
|
|
82
|
+
echo "$SEQ" > "$SEQ_FILE"
|
|
83
|
+
rmdir "$LOCK_DIR"
|
|
84
|
+
|
|
85
|
+
# Truncate text payload defensively (8 KB cap matches what the addon caps
|
|
86
|
+
# its captured snapshots at).
|
|
87
|
+
MAX_TEXT=8192
|
|
88
|
+
if [ "${#TEXT}" -gt "$MAX_TEXT" ]; then
|
|
89
|
+
TEXT=$(printf '%s' "$TEXT" | head -c "$MAX_TEXT")
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
# Build metadata in bash so the jq filter stays trivially parseable.
|
|
93
|
+
# Convention matches existing Claude Code assistant_response events
|
|
94
|
+
# (token usage lives at metadata.usage, not at the top level).
|
|
95
|
+
META=$(jq -nc \
|
|
96
|
+
--arg generationId "$GENERATION_ID" \
|
|
97
|
+
--argjson usage "$TOKEN_USAGE" \
|
|
98
|
+
'{}
|
|
99
|
+
+ (if $generationId != "" then {generationId: $generationId} else {} end)
|
|
100
|
+
+ (if ($usage | length) > 0 then {usage: $usage} else {} end)')
|
|
101
|
+
|
|
102
|
+
PAYLOAD=$(jq -n \
|
|
103
|
+
--argjson seq "$SEQ" \
|
|
104
|
+
--arg text "$TEXT" \
|
|
105
|
+
--argjson metadata "$META" \
|
|
106
|
+
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
|
107
|
+
'{
|
|
108
|
+
sequence: $seq,
|
|
109
|
+
eventType: "assistant_response",
|
|
110
|
+
content: $text,
|
|
111
|
+
timestamp: $timestamp,
|
|
112
|
+
metadata: $metadata
|
|
113
|
+
}')
|
|
114
|
+
|
|
115
|
+
_rulemetric_log "assistant-response" "success"
|
|
116
|
+
|
|
117
|
+
# Fire-and-forget (same error-logging pattern as post-tool-use)
|
|
118
|
+
(CURL_OUT=$(curl -s -w "\n%{http_code}" -X POST "${RULEMETRIC_API_URL}/api/sessions/${RW_SESSION_ID}/events" \
|
|
119
|
+
-H "$AUTH_HEADER" \
|
|
120
|
+
-H "Content-Type: application/json" \
|
|
121
|
+
-d "$PAYLOAD" 2>&1)
|
|
122
|
+
HTTP_CODE=$(echo "$CURL_OUT" | tail -1)
|
|
123
|
+
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
|
|
124
|
+
BODY=$(echo "$CURL_OUT" | sed '$d')
|
|
125
|
+
_rulemetric_log "assistant-response" "error" "http_${HTTP_CODE}_seq_${SEQ}: ${BODY}"
|
|
126
|
+
fi) &
|
package/scripts/session-start.sh
CHANGED
|
@@ -21,6 +21,42 @@ fi
|
|
|
21
21
|
|
|
22
22
|
_rulemetric_log "session-start" "success"
|
|
23
23
|
|
|
24
|
+
# ── Auth health check ──
|
|
25
|
+
# Verify the API key / access token actually works before the session starts.
|
|
26
|
+
# If auth is broken, the proxy will silently drop every captured snapshot.
|
|
27
|
+
_rulemetric_check_auth() {
|
|
28
|
+
local api_url="${RULEMETRIC_API_URL:-}"
|
|
29
|
+
local auth_token="${RULEMETRIC_API_KEY:-${RULEMETRIC_ACCESS_TOKEN:-}}"
|
|
30
|
+
|
|
31
|
+
# No credentials configured — warn
|
|
32
|
+
if [ -z "$api_url" ] || [ -z "$auth_token" ]; then
|
|
33
|
+
echo "[rulemetric] ⚠ Session tracking not configured — no API credentials found." >&2
|
|
34
|
+
echo "[rulemetric] Run: rulemetric auth login" >&2
|
|
35
|
+
_rulemetric_log "session-start" "warn" "no_auth_credentials"
|
|
36
|
+
return 0
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Quick auth check — GET /api/sessions?limit=1 with a 3s timeout
|
|
40
|
+
local http_code
|
|
41
|
+
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 \
|
|
42
|
+
"$api_url/api/sessions?limit=1" \
|
|
43
|
+
-H "Authorization: Bearer $auth_token" 2>/dev/null) || http_code="000"
|
|
44
|
+
|
|
45
|
+
if [ "$http_code" = "401" ] || [ "$http_code" = "403" ]; then
|
|
46
|
+
echo "[rulemetric] ⚠ Session tracking disconnected — API key is invalid or expired." >&2
|
|
47
|
+
echo "[rulemetric] Run: rulemetric auth login" >&2
|
|
48
|
+
_rulemetric_log "session-start" "warn" "auth_failed_$http_code"
|
|
49
|
+
elif [ "$http_code" = "000" ]; then
|
|
50
|
+
echo "[rulemetric] ⚠ Session tracking disconnected — API unreachable at $api_url" >&2
|
|
51
|
+
echo "[rulemetric] Is the API running? Check: curl $api_url/health" >&2
|
|
52
|
+
_rulemetric_log "session-start" "warn" "api_unreachable"
|
|
53
|
+
elif [ "$http_code" != "200" ]; then
|
|
54
|
+
_rulemetric_log "session-start" "warn" "auth_check_http_$http_code"
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_rulemetric_check_auth
|
|
59
|
+
|
|
24
60
|
# ── Gateway auto-recovery ──
|
|
25
61
|
# Ensure the gateway proxy is running so Claude Code can always connect.
|
|
26
62
|
# The gateway routes direct when mitmproxy is off, so this is non-breaking.
|
|
@@ -59,3 +95,92 @@ if ! proxy_alive; then
|
|
|
59
95
|
disown 2>/dev/null || true
|
|
60
96
|
fi
|
|
61
97
|
fi
|
|
98
|
+
|
|
99
|
+
# ── Instruction suggestions (non-blocking) ──
|
|
100
|
+
# Fetch the top precomputed suggestions for this project and surface them to
|
|
101
|
+
# the user. All failures are silent — this block MUST NOT prevent session
|
|
102
|
+
# start under any circumstances. Hard timeout: 4s on the read, 3s on the
|
|
103
|
+
# event-logging writes (which are backgrounded + disowned).
|
|
104
|
+
_rulemetric_suggest_instructions() {
|
|
105
|
+
local api_url="${RULEMETRIC_API_URL:-}"
|
|
106
|
+
local auth_token="${RULEMETRIC_API_KEY:-${RULEMETRIC_ACCESS_TOKEN:-}}"
|
|
107
|
+
local project_path
|
|
108
|
+
project_path="$(realpath -m "$(pwd)" 2>/dev/null || pwd)"
|
|
109
|
+
|
|
110
|
+
[ -n "$api_url" ] && [ -n "$auth_token" ] || return 0
|
|
111
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
112
|
+
|
|
113
|
+
local encoded_path
|
|
114
|
+
encoded_path="$(python3 -c \
|
|
115
|
+
"import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" \
|
|
116
|
+
"$project_path" 2>/dev/null)" || encoded_path="$project_path"
|
|
117
|
+
|
|
118
|
+
local response
|
|
119
|
+
# Phase 2b: request includeContent=true so high-confidence (score >= 0.7)
|
|
120
|
+
# suggestions arrive with the actual rule body. The body is rendered to
|
|
121
|
+
# stdout, which Claude Code surfaces to the model as session context.
|
|
122
|
+
# Low-confidence suggestions still render as name+reason only.
|
|
123
|
+
response="$(curl -s --max-time 4 \
|
|
124
|
+
"${api_url}/api/instruction-suggestions?projectPath=${encoded_path}&limit=3&includeContent=true" \
|
|
125
|
+
-H "Authorization: Bearer ${auth_token}" 2>/dev/null)" || return 0
|
|
126
|
+
|
|
127
|
+
local count
|
|
128
|
+
count="$(printf '%s' "$response" | jq -r '.suggestions | length' 2>/dev/null)" || return 0
|
|
129
|
+
[ "$count" != "0" ] && [ "$count" != "null" ] && [ -n "$count" ] || return 0
|
|
130
|
+
|
|
131
|
+
# Count high-confidence rows (those with non-null content) for the header
|
|
132
|
+
local high_count
|
|
133
|
+
high_count="$(printf '%s' "$response" | jq -r '[.suggestions[] | select(.content != null)] | length' 2>/dev/null)" || high_count="0"
|
|
134
|
+
|
|
135
|
+
printf '\n---\n'
|
|
136
|
+
if [ "$high_count" != "0" ] && [ -n "$high_count" ]; then
|
|
137
|
+
printf '[rulemetric] %s high-confidence instruction(s) included below as session context.\n' "$high_count"
|
|
138
|
+
printf 'Accept any of them permanently with: rulemetric suggestions accept <id>\n'
|
|
139
|
+
else
|
|
140
|
+
printf '[rulemetric] %s instruction(s) may help with this project:\n' "$count"
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
local i=0
|
|
144
|
+
while [ "$i" -lt "$count" ]; do
|
|
145
|
+
local name reason score scope content
|
|
146
|
+
name="$(printf '%s' "$response" | jq -r ".suggestions[$i].name" 2>/dev/null)" || break
|
|
147
|
+
reason="$(printf '%s' "$response" | jq -r ".suggestions[$i].reason" 2>/dev/null)" || break
|
|
148
|
+
score="$(printf '%s' "$response" | jq -r ".suggestions[$i].score" 2>/dev/null)" || break
|
|
149
|
+
scope="$(printf '%s' "$response" | jq -r ".suggestions[$i].instructionScope" 2>/dev/null)" || break
|
|
150
|
+
content="$(printf '%s' "$response" | jq -r ".suggestions[$i].content // empty" 2>/dev/null)" || content=""
|
|
151
|
+
|
|
152
|
+
if [ -n "$content" ]; then
|
|
153
|
+
printf '\n## Skill: %s (%s, score: %s)\n%s\n\n%s\n' "$name" "$scope" "$score" "$reason" "$content"
|
|
154
|
+
else
|
|
155
|
+
printf ' * [%s] %s (score: %s)\n %s\n' "$scope" "$name" "$score" "$reason"
|
|
156
|
+
fi
|
|
157
|
+
i=$((i + 1))
|
|
158
|
+
done
|
|
159
|
+
|
|
160
|
+
printf '\n---\n'
|
|
161
|
+
|
|
162
|
+
# Log surfaced events fire-and-forget. Event type differs based on whether
|
|
163
|
+
# the full content was included so we can later measure outcome differences.
|
|
164
|
+
i=0
|
|
165
|
+
local tool="${HOOK_TOOL:-unknown}"
|
|
166
|
+
while [ "$i" -lt "$count" ]; do
|
|
167
|
+
local sid has_content event_type
|
|
168
|
+
sid="$(printf '%s' "$response" | jq -r ".suggestions[$i].id" 2>/dev/null)" || break
|
|
169
|
+
has_content="$(printf '%s' "$response" | jq -r ".suggestions[$i].content // \"null\"" 2>/dev/null)" || has_content="null"
|
|
170
|
+
if [ "$has_content" != "null" ] && [ -n "$has_content" ]; then
|
|
171
|
+
event_type="surfaced_with_content"
|
|
172
|
+
else
|
|
173
|
+
event_type="surfaced"
|
|
174
|
+
fi
|
|
175
|
+
curl -s --max-time 3 -X POST \
|
|
176
|
+
"${api_url}/api/instruction-suggestions/${sid}/events" \
|
|
177
|
+
-H "Authorization: Bearer ${auth_token}" \
|
|
178
|
+
-H "Content-Type: application/json" \
|
|
179
|
+
-d "{\"eventType\":\"${event_type}\",\"tool\":\"${tool}\",\"projectPath\":\"${project_path}\"}" \
|
|
180
|
+
>/dev/null 2>&1 &
|
|
181
|
+
disown 2>/dev/null || true
|
|
182
|
+
i=$((i + 1))
|
|
183
|
+
done
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_rulemetric_suggest_instructions || true
|