@onlooker-community/ecosystem 0.0.2 → 0.2.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.
@@ -0,0 +1,577 @@
1
+ #!/usr/bin/env bash
2
+ # Shared path validation utilities for Onlooker scripts
3
+ #
4
+ # Source this file to get consistent path handling and error reporting.
5
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/validate-path.sh"
6
+ #
7
+ # All validation functions return 0 (success) or 1 (failure), never exit.
8
+ # All ensure functions create resources if needed and return 0 (success) or 1 (failure).
9
+
10
+ # ==============================================================================
11
+ # Path Constants (exported for use by scripts that source this file)
12
+ # ==============================================================================
13
+
14
+ export CLAUDE_HOME="${CLAUDE_HOME:-$HOME/.claude}"
15
+ export ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}"
16
+ export ONLOOKER_SESSION_TRACKERS_DIR="$ONLOOKER_DIR/session-trackers"
17
+ export ONLOOKER_SESSION_HISTORY_DIR="$ONLOOKER_DIR/session-history"
18
+ export ONLOOKER_SESSION_SUMMARIES_DIR="$ONLOOKER_DIR/session-summaries"
19
+ export ONLOOKER_COMPACT_TRACKERS_DIR="$ONLOOKER_DIR/compact-trackers"
20
+ export ONLOOKER_METRICS_DIR="$ONLOOKER_DIR/metrics"
21
+ export ONLOOKER_EVENTS_LOG="$ONLOOKER_DIR/logs/onlooker-events.jsonl"
22
+ export ONLOOKER_HOOK_HEALTH_LOG="$ONLOOKER_DIR/logs/hook-health.jsonl"
23
+ _VALIDATE_PATH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ export ONLOOKER_EMIT="$_VALIDATE_PATH_DIR/onlooker-emit.sh"
25
+ unset _VALIDATE_PATH_DIR
26
+
27
+ # ==============================================================================
28
+ # Plugin Identity
29
+ # Derive the calling plugin's name from its config.json .plugin_name field.
30
+ # Exported as ONLOOKER_PLUGIN_NAME so onlooker-emit.sh can stamp every event.
31
+ # If the sourcing plugin has no config.json or no plugin_name key, falls back
32
+ # to the directory name of CLAUDE_PLUGIN_ROOT.
33
+ # ==============================================================================
34
+ if [[ -z "${ONLOOKER_PLUGIN_NAME:-}" ]]; then
35
+ _config_file="${CLAUDE_PLUGIN_ROOT:-}/config.json"
36
+ if [[ -f "$_config_file" ]]; then
37
+ ONLOOKER_PLUGIN_NAME=$(jq -r '.plugin_name // empty' "$_config_file" 2>/dev/null) || ONLOOKER_PLUGIN_NAME=""
38
+ fi
39
+ if [[ -z "${ONLOOKER_PLUGIN_NAME:-}" ]]; then
40
+ ONLOOKER_PLUGIN_NAME=$(basename "${CLAUDE_PLUGIN_ROOT:-unknown}")
41
+ fi
42
+ unset _config_file
43
+ fi
44
+ export ONLOOKER_PLUGIN_NAME
45
+
46
+ # ==============================================================================
47
+ # Hook Health Monitoring
48
+ # Track hook success/failure rates to identify flaky hooks.
49
+ # ==============================================================================
50
+
51
+ # These functions provide observability into hook execution health.
52
+ # Usage:
53
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/validate-path.sh"
54
+ # hook_register "my-hook" "My Hook" "My hook description" # Call at start of hook
55
+ # # ... hook logic ...
56
+ # hook_success # Call on successful completion (or let trap handle failure)
57
+
58
+ # Current hook content (set by hook_register)
59
+ _HOOK_NAME=""
60
+ _HOOK_START_TIME=""
61
+
62
+ # Extended hook context (set by hook_set_context; not cleared on re-source so
63
+ # callers can set _HOOK_SESSION_ID before invoking onlooker-emit.sh).
64
+
65
+ # Detect hook event from script path
66
+ # Looks for known event directory names in the call stack
67
+ _detect_hook_event() {
68
+ local script_path="${BASH_SOURCE[2]:-${BASH_SOURCE[1]:-}}"
69
+
70
+ # Known Claude Code hook events
71
+ local events="PreToolUse|PostToolUse|PostToolUseFailure|PermissionRequest|PermissionDenied|SessionStart|SessionEnd|Notification|SubagentStart|PreCompact|PostCompact|SubagentStop|ConfigChange|CwdChanged|FileChanged|StopFailure|InstructionsLoaded|Elicitation|ElicitationResult|UserPromptSubmit|Stop|TeammateIdle|TaskCreated|TaskCompleted|WorktreeCreate|WorktreeRemove"
72
+
73
+ if [[ "$script_path" =~ /($events)/ ]]; then
74
+ echo "${BASH_REMATCH[1]}"
75
+ else
76
+ echo ""
77
+ fi
78
+ }
79
+
80
+ # Set extended context from hook input JSON
81
+ # Call this after reading stdin to capture session/tool context
82
+ # Usage: hook_set_context "$INPUT"
83
+ # OR: hook_set_context "$INPUT" "PostToolUse" # explicit event override
84
+ hook_set_context() {
85
+ local input="${1:-}"
86
+ local event_override="${2:-}"
87
+
88
+ [[ -z "$input" ]] && return 0
89
+
90
+ # Extract context from JSON input
91
+ _HOOK_SESSION_ID=$(echo "$input" | jq -r '.session_id // ""' 2>/dev/null) || _HOOK_SESSION_ID=""
92
+ _HOOK_TOOL_NAME=$(echo "$input" | jq -r '.tool_name // ""' 2>/dev/null) || _HOOK_TOOL_NAME=""
93
+
94
+ # Use explicit event or auto-detect from script path
95
+ if [[ -n "$event_override" ]]; then
96
+ _HOOK_EVENT="$event_override"
97
+ else
98
+ _HOOK_EVENT=$(_detect_hook_event)
99
+ fi
100
+
101
+ # Export for onlooker-emit.sh envelope enrichment
102
+ export ONLOOKER_HOOK_TYPE="${_HOOK_EVENT}"
103
+ export ONLOOKER_TOOL_NAME="${_HOOK_TOOL_NAME}"
104
+ }
105
+
106
+
107
+ # Register hook execution start
108
+ # Usage: hook_register "hook-name"
109
+ hook_register() {
110
+ _HOOK_NAME="${1:-unknown}"
111
+ # Get time in milliseconds (macOS compatible)
112
+ if [[ "$(uname)" == "Darwin" ]]; then
113
+ _HOOK_START_TIME=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s)
114
+ else
115
+ _HOOK_START_TIME=$(date +%s%3N 2>/dev/null || date +%s)
116
+ fi
117
+
118
+ # Set up trap to catch failures
119
+ trap '_hook_on_exit $?' EXIT
120
+ }
121
+
122
+ # Log hook success (call explicitly or let trap determine)
123
+ hook_success() {
124
+ _hook_log "success" ""
125
+ trap - EXIT # Clear trap since we're handling it
126
+ }
127
+
128
+ # Log hook failure with optional error message
129
+ # Usage: hook_failure "error message"
130
+ hook_failure() {
131
+ local error_msg="${1:-}"
132
+ _hook_log "failure" "$error_msg"
133
+ trap - EXIT
134
+ }
135
+
136
+ # Internal: called by EXIT trap
137
+ _hook_on_exit() {
138
+ local exit_code="$1"
139
+ if [[ $exit_code -eq 0 ]]; then
140
+ _hook_log "success" ""
141
+ else
142
+ _hook_log "failure" "exit_code=$exit_code"
143
+ fi
144
+ trap - EXIT
145
+ }
146
+
147
+ # Internal: write to health log
148
+ _hook_log() {
149
+ local hook_status="$1"
150
+ local error_msg="$2"
151
+
152
+ [[ -z "$_HOOK_NAME" ]] && return 0
153
+
154
+ local end_time duration_ms timestamp
155
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
156
+
157
+ # Get end time in milliseconds (macOS compatible)
158
+ if [[ "$(uname)" == "Darwin" ]]; then
159
+ end_time=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s)
160
+ else
161
+ end_time=$(date +%s%3N 2>/dev/null || date +%s)
162
+ fi
163
+
164
+ # Calculate duration (handle both ms and s timestamps)
165
+ if [[ ${#_HOOK_START_TIME} -gt 10 && ${#end_time} -gt 10 ]]; then
166
+ duration_ms=$((end_time - _HOOK_START_TIME))
167
+ else
168
+ # Fallback to seconds-based calculation
169
+ duration_ms=0
170
+ fi
171
+
172
+ ensure_file_exists "$ONLOOKER_HOOK_HEALTH_LOG" || return 0
173
+
174
+ jq -cn \
175
+ --arg ts "$timestamp" \
176
+ --arg hook "$_HOOK_NAME" \
177
+ --arg hook_status "$hook_status" \
178
+ --arg error "$error_msg" \
179
+ --argjson duration "$duration_ms" \
180
+ --arg session_id "$_HOOK_SESSION_ID" \
181
+ --arg hook_event "$_HOOK_EVENT" \
182
+ --arg tool_name "$_HOOK_TOOL_NAME" \
183
+ '{
184
+ timestamp: $ts,
185
+ hook: $hook,
186
+ status: $hook_status,
187
+ duration_ms: $duration,
188
+ error: (if $error == "" then null else $error end),
189
+ session_id: (if $session_id == "" then null else $session_id end),
190
+ hook_event: (if $hook_event == "" then null else $hook_event end),
191
+ tool_name: (if $tool_name == "" then null else $tool_name end)
192
+ }' \
193
+ >> "$ONLOOKER_HOOK_HEALTH_LOG" 2>/dev/null
194
+
195
+ # Reset context
196
+ _HOOK_NAME=""
197
+ _HOOK_START_TIME=""
198
+ _HOOK_SESSION_ID=""
199
+ _HOOK_EVENT=""
200
+ _HOOK_TOOL_NAME=""
201
+ }
202
+
203
+ # Get hook health summary for last N hours
204
+ # Usage: health=$(hook_health_summary 24)
205
+ # Returns JSON with success/failure counts per hook
206
+ hook_health_summary() {
207
+ local hours="${1:-24}"
208
+ local cutoff_time
209
+
210
+ if ! validate_file_readable "$ONLOOKER_HOOK_HEALTH_LOG"; then
211
+ echo '{}'
212
+ return 0
213
+ fi
214
+
215
+ # Calculate cutoff timestamp
216
+ if [[ "$(uname)" == "Darwin" ]]; then
217
+ cutoff_time=$(date -u -v-"${hours}"H +"%Y-%m-%dT%H:%M:%SZ")
218
+ else
219
+ cutoff_time=$(date -u -d "$hours hours ago" +"%Y-%m-%dT%H:%M:%SZ")
220
+ fi
221
+
222
+ jq -s --arg cutoff "$cutoff_time" '
223
+ map(select(.timestamp >= $cutoff))
224
+ | group_by(.hook)
225
+ | map({
226
+ hook: .[0].hook,
227
+ total: length,
228
+ success: map(select(.status == "success")) | length,
229
+ failure: map(select(.status == "failure")) | length,
230
+ avg_duration_ms: (map(.duration_ms) | add / length | floor),
231
+ last_error: (map(select(.error != null)) | last | .error // null)
232
+ })
233
+ | sort_by(-.failure)
234
+ ' "$ONLOOKER_HOOK_HEALTH_LOG" 2>/dev/null || echo '[]'
235
+ }
236
+
237
+ # ==============================================================================
238
+ # Hook Composition Bus
239
+ # ==============================================================================
240
+
241
+ # Lightweight mechanism for hooks within the same event invocation to share
242
+ # structured JSON findings. Each tool call gets a unique bus directory;
243
+ # hooks write named JSON files that later hooks can read.
244
+ #
245
+ # IMPORTANT: Hooks within the same `hooks` array run in PARALLEL.
246
+ # For reliable producer->consumer flow, place them in separate matcher entries in
247
+ # hooks.json (matcher entries run sequentially).
248
+ #
249
+ # Usage (producer):
250
+ # hook_bus_init "$INPUT"
251
+ # hook_bus_put "secret-scanner" '{"found": true, "patterns": ["AWS key"]}'
252
+ #
253
+ # Usage (consumer):
254
+ # hook_bus_init "$INPUT"
255
+ # if hook_bus_has "secret-scanner"; then
256
+ # result=$(hook_bus_get "secret-scanner")
257
+ # fi
258
+
259
+ # Current bus directory (set by hook_bus_init)
260
+ _HOOK_BUS_DIR=""
261
+
262
+ # Portable short hash (macOS md5 vs Linux md5sum)
263
+ _short_hash() {
264
+ local input="$1"
265
+ if command -v md5sum &>/dev/null; then
266
+ printf '%s' "$input" | md5sum 2>/dev/null | cut -c1-8
267
+ elif command -v md5 &>/dev/null; then
268
+ printf '%s' "$input" | md5 2>/dev/null | cut -c1-8
269
+ else
270
+ # Fallback: use cksum (always available)
271
+ printf '%s' "$input" | cksum | cut -d' ' -f1
272
+ fi
273
+ }
274
+
275
+ # Initialize the hook bus for this invocation
276
+ # Derives a unique directory from session + tool + input content
277
+ # Usage: hook_bus_init "$INPUT"
278
+ hook_bus_init() {
279
+ local input_json="${1:-}"
280
+
281
+ local session_id="${_HOOK_SESSION_ID:-unknown}"
282
+ local tool_name="${_HOOK_TOOL_NAME:-unknown}"
283
+
284
+ # Hash the tool_input portion for uniqueness within session+tool
285
+ local input_hash
286
+ local tool_input
287
+ tool_input=$(printf '%s' "$input_json" | jq -r '.tool_input // ""' 2>/dev/null) || tool_input=""
288
+ input_hash=$(_short_hash "${tool_input}")
289
+
290
+ _HOOK_BUS_DIR="/tmp/.onlooker-hook-bus-${session_id}-${tool_name}-${input_hash}"
291
+ ensure_dir_exists "$_HOOK_BUS_DIR" || {
292
+ _HOOK_BUS_DIR="" # Signal bus unavailable so hook_bus_put noops
293
+ return 1
294
+ }
295
+ }
296
+
297
+ # Write a named finding to the bus
298
+ # Usage: hook_bus_put "secret-scanner" '{"found": true}'
299
+ hook_bus_put() {
300
+ local name="$1"
301
+ local json_payload="$2"
302
+ [[ -z "$_HOOK_BUS_DIR" || ! -d "$_HOOK_BUS_DIR" ]] && return 1
303
+ printf '%s\n' "$json_payload" > "${_HOOK_BUS_DIR}/${name}.json" 2>/dev/null
304
+ }
305
+
306
+ # Read a named finding from the bus
307
+ # Returns JSON payload, or empty string if not found
308
+ # Usage: result=$(hook_bus_get "secret-scanner")
309
+ hook_bus_get() {
310
+ local name="$1"
311
+ local path="${_HOOK_BUS_DIR}/${name}.json"
312
+ if [[ -f "$path" ]]; then
313
+ cat "$path" 2>/dev/null
314
+ fi
315
+ }
316
+
317
+ # Check if a named finding exists on the bus
318
+ # Usage: if hook_bus_has "secret-scanner"; then ...
319
+ hook_bus_has() {
320
+ local name="$1"
321
+ [[ -n "$_HOOK_BUS_DIR" && -f "${_HOOK_BUS_DIR}/${name}.json" ]]
322
+ }
323
+
324
+ # List all finding names on the bus
325
+ # Returns newline-separated names (without .json extension)
326
+ hook_bus_list() {
327
+ [[ -z "$_HOOK_BUS_DIR" || ! -d "$_HOOK_BUS_DIR" ]] && return 0
328
+ local f
329
+ for f in "$_HOOK_BUS_DIR"/*.json; do
330
+ [[ -f "$f" ]] || continue
331
+ basename "$f" .json
332
+ done
333
+ }
334
+
335
+ # Clean up expired bus directories (older than 5 minutes)
336
+ # Call from SessionEnd or periodically
337
+ hook_bus_cleanup() {
338
+ # Resolve /tmp symlink (macOS: /tmp -> /private/tmp) so find works
339
+ local tmp_dir
340
+ tmp_dir="$(cd /tmp && pwd -P)"
341
+ find "$tmp_dir" -maxdepth 1 -name ".onlooker-hook-bus-*" -type d -mmin +5 -exec rm -rf {} + 2>/dev/null || true
342
+ }
343
+
344
+ # ==============================================================================
345
+ # Validation Functions (return 0/1, never exit)
346
+ # ==============================================================================
347
+
348
+ # Check if file exists
349
+ # Usage: validate_file_exists "/path/to/file" && echo "exists"
350
+ validate_file_exists() {
351
+ local path="$1"
352
+ [[ -n "$path" && -f "$path" ]]
353
+ }
354
+
355
+ # Check if file exists and is readable
356
+ # Usage: validate_file_readable "/path/to/file" && cat "$file"
357
+ validate_file_readable() {
358
+ local path="$1"
359
+ [[ -n "$path" && -f "$path" && -r "$path" ]]
360
+ }
361
+
362
+ # Check if parent directory is writable (for creating/appending to file)
363
+ # Usage: validate_file_writable "/path/to/new/file" && echo "data" >> "$file"
364
+ validate_file_writable() {
365
+ local path="$1"
366
+ [[ -z "$path" ]] && return 1
367
+ local parent
368
+ parent=$(dirname "$path")
369
+ [[ -d "$parent" && -w "$parent" ]]
370
+ }
371
+
372
+ # Check if directory exists
373
+ # Usage: validate_dir_exists "/path/to/dir" && ls "$dir"
374
+ validate_dir_exists() {
375
+ local path="$1"
376
+ [[ -n "$path" && -d "$path" ]]
377
+ }
378
+
379
+ # ==============================================================================
380
+ # Ensure Functions (create if needed, return 0/1)
381
+ # ==============================================================================
382
+
383
+ # Create directory if it doesn't exist (mkdir -p wrapper)
384
+ # Usage: ensure_dir_exists "/path/to/dir" && echo "ready"
385
+ ensure_dir_exists() {
386
+ local path="$1"
387
+ [[ -z "$path" ]] && return 1
388
+ [[ -d "$path" ]] && return 0
389
+ mkdir -p "$path" 2>/dev/null
390
+ }
391
+
392
+ # Create file if it doesn't exist (creates parent dirs too)
393
+ # Usage: ensure_file_exists "/path/to/file" && echo "data" >> "$file"
394
+ ensure_file_exists() {
395
+ local path="$1"
396
+ [[ -z "$path" ]] && return 1
397
+ [[ -f "$path" ]] && return 0
398
+ local parent
399
+ parent=$(dirname "$path")
400
+ ensure_dir_exists "$parent" || return 1
401
+ touch "$path" 2>/dev/null
402
+ }
403
+
404
+ # ==============================================================================
405
+ # Convenience Functions (shortcuts for common operations)
406
+ # ==============================================================================
407
+
408
+ # Safely read last N lines from file (returns empty if file missing)
409
+ # Usage: recent=$(safe_tail "/path/to/file" 5)
410
+ safe_tail() {
411
+ local path="$1"
412
+ local lines="${2:-10}"
413
+ if validate_file_readable "$path"; then
414
+ tail -n "$lines" "$path" 2>/dev/null
415
+ fi
416
+ }
417
+
418
+
419
+ # Safely append to file (creates file if needed)
420
+ # Usage: echo "data" | safe_append "/path/to/file"
421
+ # Or: safe_append "/path/to/file" "data to append"
422
+ safe_append() {
423
+ local path="$1"
424
+ local data="$2"
425
+ ensure_file_exists "$path" || return 1
426
+ if [[ -n "$data" ]]; then
427
+ printf '%s\n' "$data" >> "$path" 2>/dev/null
428
+ else
429
+ cat >> "$path" 2>/dev/null
430
+ fi
431
+ }
432
+
433
+ # ==============================================================================
434
+ # Turn State Tracking
435
+ # Track turn-level state for each session (lineage, turn number, etc.)
436
+ # ==============================================================================
437
+
438
+ # Read/write turn state from the per-session tracker file. The tracker stores
439
+ # JSON with turn_number and turn_tool_seq alongside existing fields (start_time,
440
+ # tool_calls, etc.). These values are exported as env vars so onlooker-emit.sh
441
+ # can include them in every event envelope.
442
+
443
+ # Read turn state from session tracker and export as env vars.
444
+ # Sets ONLOOKER_TURN_NUMBER and ONLOOKER_TURN_TOOL_SEQ.
445
+ # Usage: turn_state_export "$SESSION_ID"
446
+ turn_state_export() {
447
+ local session_id="${1:-}"
448
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 0
449
+
450
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
451
+ if [[ -f "$tracker_file" ]] && jq -e '.turn_number' "$tracker_file" >/dev/null 2>&1; then
452
+ export ONLOOKER_TURN_NUMBER=$(jq -r '.turn_number // 0' "$tracker_file" 2>/dev/null)
453
+ export ONLOOKER_TURN_TOOL_SEQ=$(jq -r '.turn_tool_seq // 0' "$tracker_file" 2>/dev/null)
454
+ else
455
+ export ONLOOKER_TURN_NUMBER=""
456
+ export ONLOOKER_TURN_TOOL_SEQ=""
457
+ fi
458
+ }
459
+
460
+ # Ensure session tracker exists with turn_number and turn_tool_seq fields.
461
+ # Usage: turn_state_ensure_session "$SESSION_ID"
462
+ turn_state_ensure_session() {
463
+ local session_id="${1:-}"
464
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 0
465
+
466
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
467
+ ensure_dir_exists "$ONLOOKER_SESSION_TRACKERS_DIR" || return 1
468
+
469
+ if [[ ! -f "$tracker_file" ]]; then
470
+ echo '{"turn_number":1,"turn_tool_seq":0}' >"$tracker_file"
471
+ return 0
472
+ fi
473
+
474
+ if ! jq -e '.turn_number' "$tracker_file" >/dev/null 2>&1; then
475
+ local temp_file
476
+ temp_file=$(mktemp)
477
+ if jq '.turn_number = (.turn_number // 1) | .turn_tool_seq = (.turn_tool_seq // 0)' \
478
+ "$tracker_file" >"$temp_file" 2>/dev/null; then
479
+ mv "$temp_file" "$tracker_file"
480
+ else
481
+ rm -f "$temp_file"
482
+ echo '{"turn_number":1,"turn_tool_seq":0}' >"$tracker_file"
483
+ fi
484
+ fi
485
+ }
486
+
487
+ # Increment turn_number and reset turn_tool_seq in session tracker.
488
+ # Usage: turn_state_next_turn "$SESSION_ID"
489
+ turn_state_next_turn() {
490
+ local session_id="${1:-}"
491
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 0
492
+
493
+ turn_state_ensure_session "$session_id" || return 1
494
+
495
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
496
+ [[ ! -f "$tracker_file" ]] && return 0
497
+
498
+ local temp_file
499
+ temp_file=$(mktemp)
500
+ if jq '.turn_number = ((.turn_number // 0) + 1) | .turn_tool_seq = 0' \
501
+ "$tracker_file" > "$temp_file" 2>/dev/null; then
502
+ mv "$temp_file" "$tracker_file"
503
+ else
504
+ rm -f "$temp_file"
505
+ fi
506
+ }
507
+
508
+ # Increment turn_tool_seq in session tracker.
509
+ # Usage: turn_state_next_tool "$SESSION_ID"
510
+ turn_state_next_tool() {
511
+ local session_id="${1:-}"
512
+ [[ -z "$session_id" || "$session_id" == "null" ]] && return 0
513
+
514
+ turn_state_ensure_session "$session_id" || return 1
515
+
516
+ local tracker_file="$ONLOOKER_SESSION_TRACKERS_DIR/$session_id"
517
+ [[ ! -f "$tracker_file" ]] && return 0
518
+
519
+ local temp_file
520
+ temp_file=$(mktemp)
521
+ if jq '.turn_tool_seq = ((.turn_tool_seq // 0) + 1)' \
522
+ "$tracker_file" > "$temp_file" 2>/dev/null; then
523
+ mv "$temp_file" "$tracker_file"
524
+ else
525
+ rm -f "$temp_file"
526
+ fi
527
+ }
528
+
529
+ # Safely emit dev-os event (validates emit script exists)
530
+ # Automatically exports turn state if not already set, so every emission
531
+ # gets hook_type/turn/tool_call_seq in the envelope without callers needing
532
+ # to call turn_state_export() explicitly.
533
+ # Usage: echo "$INPUT" | safe_emit "event_type" '{"key":"value"}'
534
+ safe_emit() {
535
+ local event_type="$1"
536
+ local payload="$2"
537
+
538
+ # Auto-export turn state if not already set
539
+ if [[ -z "${ONLOOKER_TURN_NUMBER:-}" && -n "${_HOOK_SESSION_ID:-}" ]]; then
540
+ turn_state_export "$_HOOK_SESSION_ID"
541
+ fi
542
+
543
+ if validate_file_exists "$ONLOOKER_EMIT"; then
544
+ "$ONLOOKER_EMIT" "$event_type" "$payload"
545
+ else
546
+ # Fallback: write directly to events log with envelope enrichment.
547
+ # Uses env vars set by hook_set_context() — NOT stdin (already consumed).
548
+ ensure_file_exists "$ONLOOKER_EVENTS_LOG" || return 1
549
+ local timestamp session_id plugin_name hook_type tool_name turn tool_seq
550
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
551
+ session_id="${_HOOK_SESSION_ID:-}"
552
+ if [[ -z "$session_id" ]]; then
553
+ session_id=$(echo "$payload" | jq -r '.session_id // "unknown"' 2>/dev/null) || session_id="unknown"
554
+ fi
555
+ plugin_name="${ONLOOKER_PLUGIN_NAME:-unknown}"
556
+ hook_type="${ONLOOKER_HOOK_TYPE:-}"
557
+ tool_name="${ONLOOKER_TOOL_NAME:-}"
558
+ turn="${ONLOOKER_TURN_NUMBER:-}"
559
+ tool_seq="${ONLOOKER_TURN_TOOL_SEQ:-}"
560
+ jq -cn \
561
+ --arg ts "$timestamp" \
562
+ --arg sid "$session_id" \
563
+ --arg plugin "$plugin_name" \
564
+ --arg type "$event_type" \
565
+ --arg hook_type "$hook_type" \
566
+ --arg tool_name "$tool_name" \
567
+ --arg turn "$turn" \
568
+ --arg tool_seq "$tool_seq" \
569
+ --argjson payload "$payload" \
570
+ '{timestamp: $ts, session_id: $sid, plugin: $plugin, event_type: $type, payload: $payload}
571
+ + (if $hook_type != "" then {hook_type: $hook_type} else {} end)
572
+ + (if $tool_name != "" then {tool_name: $tool_name} else {} end)
573
+ + (if $turn != "" then {turn: ($turn | tonumber)} else {} end)
574
+ + (if $tool_seq != "" then {tool_call_seq: ($tool_seq | tonumber)} else {} end)
575
+ ' >> "$ONLOOKER_EVENTS_LOG"
576
+ fi
577
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ load_validate_path
7
+ export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
8
+ rm -f "${ONLOOKER_DIR}/agent-spawn-trackers.json" "${ONLOOKER_DIR}/agent-spawn-trackers.json.lock"
9
+ }
10
+
11
+ @test "agent-spawn-tracker approves non-Agent tool calls" {
12
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/non-agent-tool.json"
13
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/agent-spawn-tracker.sh' 2>/dev/null"
14
+ [ "$status" -eq 0 ]
15
+ echo "$output" | jq -e '.decision == "approve" and (.reason | test("Not an Agent"))' >/dev/null
16
+ }
17
+
18
+ @test "agent-spawn-tracker approves Agent tool calls" {
19
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/agent-tool.json"
20
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/agent-spawn-tracker.sh' 2>/dev/null"
21
+ [ "$status" -eq 0 ]
22
+ echo "$output" | jq -e '.decision == "approve"' >/dev/null
23
+ }
24
+
25
+ @test "agent-spawn-tracker records session in state file" {
26
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/agent-tool.json"
27
+ cat "$fixture" | "${REPO_ROOT}/scripts/hooks/agent-spawn-tracker.sh" >/dev/null 2>&1
28
+ jq -e '.sessions["test-session-001"] | type == "object"' "${ONLOOKER_DIR}/agent-spawn-trackers.json" >/dev/null
29
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup_file() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ }
7
+
8
+ @test "config.json is valid JSON with plugin_name" {
9
+ run jq -e '.plugin_name | length > 0' "${REPO_ROOT}/config.json"
10
+ [ "$status" -eq 0 ]
11
+ }
12
+
13
+ @test "hooks.json wildcard matcher references tool-sequence-tracker" {
14
+ run jq -e '.hooks.PreToolUse[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
15
+ [ "$status" -eq 0 ]
16
+
17
+ local hook_cmd
18
+ hook_cmd=$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
19
+ [[ "$hook_cmd" == *tool-sequence-tracker.sh ]]
20
+
21
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
22
+ script_path="${script_path//\"/}"
23
+ run test -x "$script_path"
24
+ [ "$status" -eq 0 ]
25
+ }
26
+
27
+ @test "hooks.json Agent matcher references agent-spawn-tracker" {
28
+ run jq -e '.hooks.PreToolUse[1].matcher == "Agent"' "${REPO_ROOT}/hooks/hooks.json"
29
+ [ "$status" -eq 0 ]
30
+
31
+ local hook_cmd
32
+ hook_cmd=$(jq -r '.hooks.PreToolUse[1].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
33
+ [[ "$hook_cmd" == *agent-spawn-tracker.sh ]]
34
+
35
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
36
+ script_path="${script_path//\"/}"
37
+ run test -x "$script_path"
38
+ [ "$status" -eq 0 ]
39
+ }
40
+
41
+ @test "hooks.json PostToolUse references tool-history-tracker" {
42
+ run jq -e '.hooks.PostToolUse[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
43
+ [ "$status" -eq 0 ]
44
+
45
+ local hook_cmd
46
+ hook_cmd=$(jq -r '.hooks.PostToolUse[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
47
+ [[ "$hook_cmd" == *tool-history-tracker.sh ]]
48
+
49
+ local script_path="${hook_cmd//\$CLAUDE_PLUGIN_ROOT/$REPO_ROOT}"
50
+ script_path="${script_path//\"/}"
51
+ run test -x "$script_path"
52
+ [ "$status" -eq 0 ]
53
+ }
54
+
55
+ @test "hooks.json PostToolUseFailure references tool-history-tracker" {
56
+ run jq -e '.hooks.PostToolUseFailure[0].matcher == "*"' "${REPO_ROOT}/hooks/hooks.json"
57
+ [ "$status" -eq 0 ]
58
+
59
+ local hook_cmd
60
+ hook_cmd=$(jq -r '.hooks.PostToolUseFailure[0].hooks[0].command' "${REPO_ROOT}/hooks/hooks.json")
61
+ [[ "$hook_cmd" == *tool-history-tracker.sh ]]
62
+ }
63
+
64
+ @test "plugin.json is valid JSON" {
65
+ run jq -e '.name and .version' "${REPO_ROOT}/.claude-plugin/plugin.json"
66
+ [ "$status" -eq 0 ]
67
+ }