@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -5
- package/.github/workflows/release.yml +13 -5
- package/.github/workflows/test.yml +26 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +40 -0
- package/README.md +14 -12
- package/config.json +4 -0
- package/hooks/hooks.json +46 -0
- package/mise.toml +11 -0
- package/package.json +10 -2
- package/scripts/common.sh +40 -0
- package/scripts/hooks/agent-spawn-tracker.sh +160 -0
- package/scripts/hooks/tool-history-tracker.sh +36 -0
- package/scripts/hooks/tool-sequence-tracker.sh +32 -0
- package/scripts/lib/onlooker-emit.sh +41 -0
- package/scripts/lib/onlooker-event.mjs +278 -0
- package/scripts/lib/onlooker-schema.sh +45 -0
- package/scripts/lib/tool-history.sh +35 -0
- package/scripts/lib/validate-path.sh +577 -0
- package/test/bats/agent-spawn-tracker.bats +29 -0
- package/test/bats/config.bats +67 -0
- package/test/bats/onlooker-emit.bats +26 -0
- package/test/bats/tool-history-tracker.bats +72 -0
- package/test/bats/tool-sequence-tracker.bats +53 -0
- package/test/bats/validate-path.bats +109 -0
- package/test/fixtures/hook-inputs/agent-tool.json +11 -0
- package/test/fixtures/hook-inputs/non-agent-tool.json +7 -0
- package/test/fixtures/hook-inputs/post-tool-use-failure-bash.json +12 -0
- package/test/fixtures/hook-inputs/post-tool-use-read.json +13 -0
- package/test/helpers/setup.bash +38 -0
- package/test/node/schema-events.test.mjs +67 -0
|
@@ -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
|
+
}
|