@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulemetric/hooks",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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="${XDG_CONFIG_HOME:-$HOME/.config}/rulemetric"
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
- 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 .)")
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
- '{tool: $tool, externalSessionId: $externalSessionId, projectPath: $projectPath, gitBranch: $gitBranch, metadata: $metadata}')
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) &
@@ -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