@onlooker-community/ecosystem 0.28.1 → 0.29.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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -3
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +2 -0
  6. package/README.md +114 -13
  7. package/docs/plugin-catalog.md +125 -0
  8. package/package.json +3 -3
  9. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  10. package/plugins/compass/CHANGELOG.md +7 -0
  11. package/plugins/compass/README.md +1 -3
  12. package/plugins/compass/config.json +1 -2
  13. package/plugins/compass/docs/design.md +1 -2
  14. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
  15. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +8 -1
  16. package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
  17. package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
  18. package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
  19. package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
  20. package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
  21. package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
  22. package/plugins/inspector/.claude-plugin/plugin.json +14 -0
  23. package/plugins/inspector/CHANGELOG.md +8 -0
  24. package/plugins/inspector/README.md +155 -0
  25. package/plugins/inspector/config.json +25 -0
  26. package/plugins/inspector/docs/design.md +286 -0
  27. package/plugins/inspector/hooks/hooks.json +33 -0
  28. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
  29. package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
  30. package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
  31. package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
  32. package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
  33. package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
  34. package/release-please-config.json +17 -1
  35. package/test/bats/archivist-project-key.bats +79 -0
  36. package/test/bats/archivist-storage.bats +79 -0
  37. package/test/bats/compact-tracker.bats +125 -0
  38. package/test/bats/compass-config.bats +65 -0
  39. package/test/bats/compass-gate.bats +129 -0
  40. package/test/bats/compass-sanitizer.bats +69 -0
  41. package/test/bats/compass-symbolic-skip.bats +88 -0
  42. package/test/bats/compass-transcript.bats +80 -0
  43. package/test/bats/inspector-config.bats +118 -0
  44. package/test/bats/inspector-events.bats +156 -0
  45. package/test/bats/inspector-post-write-hook.bats +164 -0
  46. package/test/bats/inspector-project-key.bats +68 -0
  47. package/test/bats/inspector-ulid.bats +34 -0
  48. package/test/bats/librarian-session-end.bats +8 -1
  49. package/test/bats/onlooker-schema.bats +111 -0
  50. package/test/bats/prompt-rules.bats +98 -0
  51. package/test/bats/session-tracker.bats +260 -0
  52. package/test/bats/skill-usage-tracker.bats +63 -0
  53. package/test/bats/task-tracker.bats +102 -0
  54. package/test/bats/turn-tracker.bats +180 -0
  55. package/test/bats/validate-path.bats +125 -0
  56. package/test/bats/worktree-tracker.bats +167 -0
@@ -1,20 +1,22 @@
1
1
  #!/usr/bin/env bash
2
- # N=5 parallel Haiku evaluator for Compass.
2
+ # N=5 parallel `claude -p` evaluator for Compass.
3
3
  #
4
- # Launches N independent evaluator calls, aggregates scores, and returns
5
- # a decision (pass/fail) with confidence and stddev.
4
+ # Launches N independent evaluator calls via `claude -p --max-turns 1`,
5
+ # aggregates scores, and returns a decision (pass / fail / error) with
6
+ # confidence and stddev.
6
7
  #
7
8
  # Exposes:
8
9
  # compass_evaluate <tool_name> <file_path> <operation> \
9
10
  # <prior_turn> <context_excerpt> <session_id>
10
11
  #
11
- # Exits 0 if confidence >= threshold AND stddev <= stddev_threshold.
12
- # Exits 1 if confidence < threshold OR stddev > stddev_threshold (block).
13
- # Exits 2 on evaluator error (respects error_policy).
14
- #
15
12
  # Writes a JSON result object to stdout:
16
13
  # {"decision":"pass|fail|error","confidence":<f>,"stddev":<f>,
17
14
  # "primary_concern":"<str>","rationale":"<str>","sample_count":<n>}
15
+ #
16
+ # Exit codes:
17
+ # 0 pass (confidence >= threshold AND stddev <= stddev_threshold)
18
+ # 1 fail (block)
19
+ # 2 error (respects error_policy)
18
20
 
19
21
  _COMPASS_EVAL_PROMPT_NO_PRIOR='You are evaluating whether a pending write operation has sufficient intent clarity.
20
22
 
@@ -78,87 +80,65 @@ path: FILE_PATH_PLACEHOLDER
78
80
  operation: OPERATION_PLACEHOLDER
79
81
  </tool_input>'
80
82
 
81
- # Run a single evaluator call. Writes JSON to a temp file at $output_file.
83
+ # Strip leading/trailing markdown fences a model occasionally emits.
84
+ _compass_strip_fences() {
85
+ printf '%s' "$1" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//'
86
+ }
87
+
88
+ # Run a single evaluator call via `claude -p`. Writes JSON to $output_file.
82
89
  # $1 — prompt text
83
90
  # $2 — model
84
- # $3 — temperature (as string, e.g. "0.3")
85
- # $4 — max_output_tokens
86
- # $5 — output file path
87
- # $6 — API key env var name (default: ANTHROPIC_API_KEY)
91
+ # $3 — timeout (seconds)
92
+ # $4 — output file path
88
93
  _compass_run_single_eval() {
89
94
  local prompt="$1"
90
95
  local model="$2"
91
- local temperature="$3"
92
- local max_tokens="$4"
93
- local output_file="$5"
94
- local api_key_var="${6:-ANTHROPIC_API_KEY}"
95
- local api_key="${!api_key_var:-}"
96
-
97
- [[ -z "$api_key" ]] && { printf '{"error":"no_api_key"}' > "$output_file"; return 1; }
98
-
99
- local request_body
100
- request_body=$(jq -n \
101
- --arg model "$model" \
102
- --argjson temp "$temperature" \
103
- --argjson max_tokens "$max_tokens" \
104
- --arg prompt "$prompt" \
105
- '{
106
- model: $model,
107
- max_tokens: $max_tokens,
108
- temperature: $temp,
109
- messages: [{"role": "user", "content": $prompt}]
110
- }' 2>/dev/null) || { printf '{"error":"request_build_failed"}' > "$output_file"; return 1; }
111
-
112
- local http_response http_code response_body
113
- http_response=$(curl -s -w '\n%{http_code}' \
114
- -X POST "https://api.anthropic.com/v1/messages" \
115
- -H "x-api-key: ${api_key}" \
116
- -H "anthropic-version: 2023-06-01" \
117
- -H "content-type: application/json" \
118
- -d "$request_body" \
119
- --max-time 15 \
120
- 2>/dev/null) || { printf '{"error":"curl_failed"}' > "$output_file"; return 1; }
121
-
122
- http_code=$(printf '%s' "$http_response" | tail -n1)
123
- response_body=$(printf '%s' "$http_response" | head -n -1)
124
-
125
- if [[ "$http_code" == "429" ]]; then
126
- sleep 2
127
- http_response=$(curl -s -w '\n%{http_code}' \
128
- -X POST "https://api.anthropic.com/v1/messages" \
129
- -H "x-api-key: ${api_key}" \
130
- -H "anthropic-version: 2023-06-01" \
131
- -H "content-type: application/json" \
132
- -d "$request_body" \
133
- --max-time 15 \
134
- 2>/dev/null) || { printf '{"error":"curl_failed_retry"}' > "$output_file"; return 1; }
135
- http_code=$(printf '%s' "$http_response" | tail -n1)
136
- response_body=$(printf '%s' "$http_response" | head -n -1)
137
- fi
96
+ local timeout_secs="$3"
97
+ local output_file="$4"
138
98
 
139
- if [[ "$http_code" != "200" ]]; then
140
- printf '{"error":"http_%s"}' "$http_code" > "$output_file"
99
+ if ! command -v claude >/dev/null 2>&1; then
100
+ printf '{"error":"claude_cli_missing"}' > "$output_file"
141
101
  return 1
142
102
  fi
143
103
 
144
- local content
145
- content=$(printf '%s' "$response_body" | jq -r '.content[0].text // empty' 2>/dev/null) || {
146
- printf '{"error":"parse_failed"}' > "$output_file"
104
+ local prompt_file
105
+ prompt_file=$(mktemp -t compass-prompt.XXXXXX 2>/dev/null) || prompt_file="/tmp/compass-prompt.$$.${RANDOM}"
106
+ printf '%s' "$prompt" > "$prompt_file"
107
+
108
+ local args=(-p --max-turns 1)
109
+ [[ -n "$model" ]] && args+=(--model "$model")
110
+
111
+ local response=""
112
+ if command -v timeout >/dev/null 2>&1; then
113
+ response=$(COMPASS_NESTED=1 timeout "$timeout_secs" claude "${args[@]}" <"$prompt_file" 2>/dev/null) || response=""
114
+ elif command -v gtimeout >/dev/null 2>&1; then
115
+ response=$(COMPASS_NESTED=1 gtimeout "$timeout_secs" claude "${args[@]}" <"$prompt_file" 2>/dev/null) || response=""
116
+ else
117
+ response=$(COMPASS_NESTED=1 claude "${args[@]}" <"$prompt_file" 2>/dev/null) || response=""
118
+ fi
119
+
120
+ rm -f "$prompt_file" 2>/dev/null || true
121
+
122
+ if [[ -z "$response" ]]; then
123
+ printf '{"error":"empty_response"}' > "$output_file"
147
124
  return 1
148
- }
125
+ fi
149
126
 
150
- # Validate the model returned parseable JSON with a score field.
127
+ local clean
128
+ clean=$(_compass_strip_fences "$response")
129
+
130
+ # Confirm the model returned a JSON object with a numeric score.
151
131
  local score
152
- score=$(printf '%s' "$content" | jq -r '.score // empty' 2>/dev/null) || score=""
132
+ score=$(printf '%s' "$clean" | jq -r '.score // empty' 2>/dev/null) || score=""
153
133
  if [[ -z "$score" ]]; then
154
134
  printf '{"error":"invalid_json_response"}' > "$output_file"
155
135
  return 1
156
136
  fi
157
137
 
158
- printf '%s' "$content" > "$output_file"
138
+ printf '%s' "$clean" > "$output_file"
159
139
  }
160
140
 
161
- # Build the evaluator prompt.
141
+ # Build the evaluator prompt by interpolating the data slots.
162
142
  _compass_build_prompt() {
163
143
  local prior_turn="$1"
164
144
  local context_excerpt="$2"
@@ -182,7 +162,7 @@ _compass_build_prompt() {
182
162
  printf '%s' "$template"
183
163
  }
184
164
 
185
- # Compute mean of space-separated floats.
165
+ # Mean of space-separated floats.
186
166
  _compass_mean() {
187
167
  local scores=("$@")
188
168
  local n="${#scores[@]}"
@@ -195,7 +175,7 @@ _compass_mean() {
195
175
  awk "BEGIN {printf \"%.4f\", $sum / $n}" 2>/dev/null || printf '0'
196
176
  }
197
177
 
198
- # Compute population stddev of space-separated floats.
178
+ # Population stddev of space-separated floats.
199
179
  _compass_stddev() {
200
180
  local scores=("$@")
201
181
  local n="${#scores[@]}"
@@ -213,7 +193,7 @@ _compass_stddev() {
213
193
  # Main evaluator entry point.
214
194
  # $1 — tool_name
215
195
  # $2 — file_path
216
- # $3 — operation (write|edit|multi_edit|bash)
196
+ # $3 — operation (write|edit|multi_edit|bash_write)
217
197
  # $4 — prior_turn (may be empty)
218
198
  # $5 — context_excerpt
219
199
  # $6 — session_id
@@ -225,17 +205,11 @@ compass_evaluate() {
225
205
  local context_excerpt="$5"
226
206
  local session_id="${6:-unknown}"
227
207
 
228
- local model
208
+ local model n_samples timeout_secs min_valid
229
209
  model=$(compass_config_get '.compass.evaluator.model')
230
210
  model="${model:-claude-haiku-4-5-20251001}"
231
-
232
- local n_samples temperature max_tokens timeout_secs min_valid
233
211
  n_samples=$(compass_config_get '.compass.evaluator.n')
234
212
  n_samples="${n_samples:-5}"
235
- temperature=$(compass_config_get '.compass.evaluator.temperature')
236
- temperature="${temperature:-0.3}"
237
- max_tokens=$(compass_config_get '.compass.evaluator.max_output_tokens')
238
- max_tokens="${max_tokens:-128}"
239
213
  timeout_secs=$(compass_config_get '.compass.evaluator.sample_timeout_seconds')
240
214
  timeout_secs="${timeout_secs:-8}"
241
215
  min_valid=$(compass_config_get '.compass.evaluator.min_valid_samples')
@@ -250,34 +224,22 @@ compass_evaluate() {
250
224
  local prompt
251
225
  prompt=$(_compass_build_prompt "$prior_turn" "$context_excerpt" "$tool_name" "$file_path" "$operation")
252
226
 
253
- # Launch N parallel eval calls.
254
227
  local tmp_dir
255
- tmp_dir=$(mktemp -d -t compass-eval.XXXXXX 2>/dev/null) || tmp_dir="/tmp/compass-eval.$$"
228
+ tmp_dir=$(mktemp -d -t compass-eval.XXXXXX 2>/dev/null) || tmp_dir="/tmp/compass-eval.$$.${RANDOM}"
256
229
  mkdir -p "$tmp_dir"
257
230
 
258
231
  local pids=()
259
232
  local i
260
233
  for (( i=0; i<n_samples; i++ )); do
261
234
  local out_file="${tmp_dir}/sample_${i}.json"
262
- (
263
- _compass_run_single_eval \
264
- "$prompt" "$model" "$temperature" "$max_tokens" "$out_file"
265
- ) &
235
+ _compass_run_single_eval \
236
+ "$prompt" "$model" "$timeout_secs" "$out_file" &
266
237
  pids+=($!)
267
238
  done
268
239
 
269
- # Collect with timeout watchdog.
270
- local deadline=$(( $(date +%s) + timeout_secs ))
271
240
  local pid
272
241
  for pid in "${pids[@]}"; do
273
- local now
274
- now=$(date +%s)
275
- local remaining=$(( deadline - now ))
276
- if [[ "$remaining" -gt 0 ]]; then
277
- wait "$pid" 2>/dev/null || true
278
- else
279
- kill "$pid" 2>/dev/null || true
280
- fi
242
+ wait "$pid" 2>/dev/null || true
281
243
  done
282
244
 
283
245
  # Aggregate valid scores.
@@ -307,9 +269,7 @@ compass_evaluate() {
307
269
  error_policy="${error_policy:-closed}"
308
270
 
309
271
  local decision="error"
310
- if [[ "$error_policy" == "open" ]]; then
311
- decision="pass"
312
- fi
272
+ [[ "$error_policy" == "open" ]] && decision="pass"
313
273
 
314
274
  jq -n \
315
275
  --arg decision "$decision" \
@@ -92,21 +92,18 @@ _compass_matches_skip_glob() {
92
92
  local globs_json="$2"
93
93
  [[ -z "$file_path" || -z "$globs_json" ]] && return 1
94
94
 
95
- # Convert JSON array to bash array and check each pattern.
96
- local globs
97
- mapfile -t globs < <(printf '%s' "$globs_json" | jq -r '.[]' 2>/dev/null) || return 1
98
-
99
- local glob
100
- for glob in "${globs[@]}"; do
101
- # Use bash glob matching (extglob not needed for ** — use case).
102
- # Convert ** to a catch-all for simple prefix/suffix matching.
103
- local pattern="${glob//\*\*/DOUBLE_STAR}"
95
+ # Bash 3.2 (macOS) lacks mapfile, so stream via read-while.
96
+ local glob pattern
97
+ while IFS= read -r glob; do
98
+ [[ -z "$glob" ]] && continue
99
+ # Translate ** → .* and * → [^/]* for simple prefix/suffix matching.
100
+ pattern="${glob//\*\*/DOUBLE_STAR}"
104
101
  pattern="${pattern//\*/[^/]*}"
105
102
  pattern="${pattern//DOUBLE_STAR/.*}"
106
103
  if [[ "$file_path" =~ $pattern ]]; then
107
104
  return 0
108
105
  fi
109
- done
106
+ done < <(printf '%s' "$globs_json" | jq -r '.[]' 2>/dev/null)
110
107
  return 1
111
108
  }
112
109
 
@@ -241,12 +238,13 @@ Choose a path:
241
238
 
242
239
  # -----------------------------------------------------------------------
243
240
  # Main gate function.
244
- # $1 — tool_name (Write | Edit | MultiEdit | Bash)
245
- # $2 — file_path (may be empty for Bash)
246
- # $3 — operation (write | edit | multi_edit | bash_write)
247
- # $4 — context (context excerpt or bash command string)
241
+ # $1 — tool_name (Write | Edit | MultiEdit | Bash)
242
+ # $2 — file_path (may be empty for Bash)
243
+ # $3 — operation (write | edit | multi_edit | bash_write)
244
+ # $4 — context (context excerpt or bash command string)
248
245
  # $5 — session_id
249
246
  # $6 — cwd
247
+ # $7 — transcript_path (from hook JSON; may be empty)
250
248
  # -----------------------------------------------------------------------
251
249
  compass_run_gate() {
252
250
  local tool_name="$1"
@@ -255,6 +253,7 @@ compass_run_gate() {
255
253
  local context="$4"
256
254
  local session_id="${5:-unknown}"
257
255
  local cwd="${6:-}"
256
+ local transcript_path="${7:-}"
258
257
 
259
258
  local _allow_exit=0
260
259
  local _block_exit=0
@@ -348,14 +347,12 @@ compass_run_gate() {
348
347
  _compass_increment_turn_count "$session_id" 2>/dev/null || true
349
348
 
350
349
  # ---- Read prior assistant turn -----------------------------------
351
- local prior_turn_chars_max transcript_max_age
350
+ local prior_turn_chars_max
352
351
  prior_turn_chars_max=$(compass_config_get '.compass.transcript.prior_turn_chars_max')
353
352
  prior_turn_chars_max="${prior_turn_chars_max:-800}"
354
- transcript_max_age=$(compass_config_get '.compass.transcript.transcript_max_age_seconds')
355
- transcript_max_age="${transcript_max_age:-300}"
356
353
 
357
354
  local prior_turn=""
358
- prior_turn=$(compass_read_prior_turn "$session_id" "$prior_turn_chars_max" "$transcript_max_age") \
355
+ prior_turn=$(compass_read_prior_turn "$transcript_path" "$prior_turn_chars_max") \
359
356
  || prior_turn=""
360
357
 
361
358
  # ---- Symbolic skip layer -----------------------------------------
@@ -42,13 +42,13 @@ _compass_strip_control_chars() {
42
42
  }
43
43
 
44
44
  # Replace all occurrences of a literal string with [STRIPPED].
45
+ # Uses bash native parameter expansion to avoid the sed delimiter trap —
46
+ # needles like </prior_assistant_turn> contain '/' which breaks sed s///.
45
47
  _compass_strip_literal() {
46
48
  local input="$1"
47
49
  local needle="$2"
48
- # Use printf + sed; escape needle for BRE (basic regex)
49
- local escaped_needle
50
- escaped_needle=$(printf '%s' "$needle" | sed 's/[[\.*^$()+?{|]/\\&/g' 2>/dev/null) || escaped_needle="$needle"
51
- printf '%s' "$input" | sed "s/${escaped_needle}/[STRIPPED]/g" 2>/dev/null
50
+ [[ -z "$needle" ]] && { printf '%s' "$input"; return; }
51
+ printf '%s' "${input//"$needle"/[STRIPPED]}"
52
52
  }
53
53
 
54
54
  # Truncate a string to at most max_chars UTF-8 characters.
@@ -1,135 +1,102 @@
1
1
  #!/usr/bin/env bash
2
2
  # Prior assistant turn reader for Compass.
3
3
  #
4
- # Resolves the most recent assistant turn from the session transcript so
5
- # the evaluator can operate on the pair {prior_assistant_turn, context}
6
- # rather than context alone avoiding false positives on question-answer
7
- # turns (see ADR-001).
4
+ # Resolves the most recent assistant turn from the session transcript JSONL
5
+ # so the evaluator can operate on the pair {prior_assistant_turn, context}
6
+ # rather than context alone. Avoids false positives on question-answer
7
+ # turns. See ADR-001 (plugins/compass/docs/adr/001-evaluate-prompts-in-context.md).
8
8
  #
9
- # Resolution order:
10
- # 1. CLAUDE_TRANSCRIPT_PATH env var parse as JSONL, find most recent
11
- # entry with role:"assistant"
12
- # 2. Onlooker JSONL event log (~/.onlooker/logs/onlooker-events.jsonl)
13
- # filtered by session_id and event_type:"session.prompt", most recent
14
- # assistant-role entry
15
- # 3. Empty string — degrades gracefully; evaluator runs on context alone
9
+ # Source: the `transcript_path` field from the hook JSON payload. This is
10
+ # the same field tribunal-stop-gate.sh reads (`jq -r '.transcript_path // ""'`).
11
+ # When `transcript_path` is absent or unreadable, this function returns the
12
+ # empty string and the evaluator runs on the current context alone.
16
13
  #
17
14
  # Exposes:
18
- # compass_read_prior_turn <session_id> <max_chars> <max_age_seconds>
19
- # Echoes the prior assistant turn text (possibly empty).
20
-
21
- _compass_transcript_from_path() {
22
- local transcript_path="$1"
23
- local session_id="$2"
24
- local max_age_seconds="$3"
25
-
26
- [[ -f "$transcript_path" ]] || return 1
27
-
28
- local now
29
- now=$(date +%s 2>/dev/null) || now=0
30
-
31
- # Parse JSONL: find most recent entry with role=assistant within max_age_seconds.
32
- # Each line is a JSON object. We want the last one matching our criteria.
33
- local prior_turn=""
34
- local line
35
- while IFS= read -r line; do
36
- [[ -z "$line" ]] && continue
37
- local role ts content
38
- role=$(printf '%s' "$line" | jq -r '.role // empty' 2>/dev/null) || continue
39
- [[ "$role" == "assistant" ]] || continue
40
-
41
- # Age check — skip entries older than max_age_seconds.
42
- if [[ "$max_age_seconds" -gt 0 ]]; then
43
- ts=$(printf '%s' "$line" | jq -r '.timestamp // empty' 2>/dev/null) || ts=""
44
- if [[ -n "$ts" ]]; then
45
- local entry_time
46
- entry_time=$(date -d "$ts" +%s 2>/dev/null) \
47
- || entry_time=$(date -jf '%Y-%m-%dT%H:%M:%SZ' "$ts" +%s 2>/dev/null) \
48
- || entry_time=0
49
- local age=$(( now - entry_time ))
50
- [[ "$age" -gt "$max_age_seconds" ]] && continue
51
- fi
52
- fi
53
-
54
- content=$(printf '%s' "$line" | jq -r '.content // .text // empty' 2>/dev/null) || continue
55
- [[ -n "$content" ]] && prior_turn="$content"
56
- done < "$transcript_path"
57
-
58
- [[ -n "$prior_turn" ]] || return 1
59
- printf '%s' "$prior_turn"
15
+ # compass_read_prior_turn <transcript_path> <max_chars>
16
+ # Echoes the sanitized, truncated prior assistant turn, or empty string.
17
+
18
+ # Extract the text portion of a transcript line. The Claude Code session
19
+ # transcript stores assistant messages as `{"type":"assistant","message":{...}}`
20
+ # where `message.content` may be a string or an array of content blocks.
21
+ _compass_transcript_extract_text() {
22
+ local line="$1"
23
+ # Prefer message.content[*].text for the array-of-blocks shape; fall back
24
+ # to message.content when it is already a string. Final fallback: any
25
+ # top-level .content or .text field (covers legacy/alternate writers).
26
+ printf '%s' "$line" | jq -r '
27
+ if (.message? // null) != null then
28
+ if (.message.content | type) == "array" then
29
+ [.message.content[]? | select(type == "object") | (.text // "")]
30
+ | map(select(. != "")) | join("\n")
31
+ elif (.message.content | type) == "string" then
32
+ .message.content
33
+ else "" end
34
+ else
35
+ (.content // .text // "")
36
+ end
37
+ ' 2>/dev/null
60
38
  }
61
39
 
62
- _compass_transcript_from_event_log() {
63
- local session_id="$1"
64
- local max_age_seconds="$2"
65
- local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
66
-
67
- [[ -f "$log_path" ]] || return 1
68
- [[ -n "$session_id" && "$session_id" != "unknown" ]] || return 1
69
-
70
- local now
71
- now=$(date +%s 2>/dev/null) || now=0
72
-
73
- local prior_turn=""
74
- local line
75
- while IFS= read -r line; do
76
- [[ -z "$line" ]] && continue
77
-
78
- local sid etype role
79
- sid=$(printf '%s' "$line" | jq -r '.session_id // empty' 2>/dev/null) || continue
80
- [[ "$sid" == "$session_id" ]] || continue
81
-
82
- etype=$(printf '%s' "$line" | jq -r '.event_type // empty' 2>/dev/null) || continue
83
- [[ "$etype" == "session.prompt" ]] || continue
84
-
85
- role=$(printf '%s' "$line" | jq -r '.payload.role // empty' 2>/dev/null) || continue
86
- [[ "$role" == "assistant" ]] || continue
40
+ # Return the role for a transcript line, falling back to .message.role.
41
+ _compass_transcript_role() {
42
+ local line="$1"
43
+ printf '%s' "$line" \
44
+ | jq -r '(.role // .message.role // .type // empty)' 2>/dev/null
45
+ }
87
46
 
88
- if [[ "$max_age_seconds" -gt 0 ]]; then
89
- local ts entry_time
90
- ts=$(printf '%s' "$line" | jq -r '.timestamp // empty' 2>/dev/null) || ts=""
91
- if [[ -n "$ts" ]]; then
92
- entry_time=$(date -d "$ts" +%s 2>/dev/null) \
93
- || entry_time=$(date -jf '%Y-%m-%dT%H:%M:%SZ' "$ts" +%s 2>/dev/null) \
94
- || entry_time=0
95
- local age=$(( now - entry_time ))
96
- [[ "$age" -gt "$max_age_seconds" ]] && continue
97
- fi
98
- fi
47
+ # Walk a JSONL transcript file backwards to find the most recent assistant
48
+ # turn with non-empty text. Avoids loading the entire file into memory by
49
+ # streaming with `tac` when available; falls back to `tail -r` on BSD.
50
+ _compass_transcript_read_from_file() {
51
+ local path="$1"
52
+ [[ -f "$path" ]] || return 1
53
+
54
+ local reverser=""
55
+ if command -v tac >/dev/null 2>&1; then
56
+ reverser="tac"
57
+ elif command -v tail >/dev/null 2>&1 && tail -r </dev/null >/dev/null 2>&1; then
58
+ reverser="tail -r"
59
+ fi
99
60
 
100
- local content
101
- content=$(printf '%s' "$line" | jq -r '.payload.content // .payload.text // empty' 2>/dev/null) || continue
102
- [[ -n "$content" ]] && prior_turn="$content"
103
- done < "$log_path"
61
+ local line role content
62
+ if [[ -n "$reverser" ]]; then
63
+ while IFS= read -r line; do
64
+ [[ -z "$line" ]] && continue
65
+ role=$(_compass_transcript_role "$line") || continue
66
+ [[ "$role" == "assistant" ]] || continue
67
+ content=$(_compass_transcript_extract_text "$line") || continue
68
+ [[ -n "$content" ]] && { printf '%s' "$content"; return 0; }
69
+ done < <(eval "$reverser" "\"$path\"" 2>/dev/null)
70
+ else
71
+ # Final fallback: forward scan, keep the last match.
72
+ local found=""
73
+ while IFS= read -r line; do
74
+ [[ -z "$line" ]] && continue
75
+ role=$(_compass_transcript_role "$line") || continue
76
+ [[ "$role" == "assistant" ]] || continue
77
+ content=$(_compass_transcript_extract_text "$line") || continue
78
+ [[ -n "$content" ]] && found="$content"
79
+ done < "$path"
80
+ [[ -n "$found" ]] && { printf '%s' "$found"; return 0; }
81
+ fi
104
82
 
105
- [[ -n "$prior_turn" ]] || return 1
106
- printf '%s' "$prior_turn"
83
+ return 1
107
84
  }
108
85
 
109
86
  # Read the prior assistant turn.
110
- # $1 — session_id
87
+ # $1 — transcript_path (from hook JSON payload; may be empty)
111
88
  # $2 — max_chars (from config: transcript.prior_turn_chars_max)
112
- # $3 max_age_seconds (from config: transcript.transcript_max_age_seconds)
113
- # Echoes the sanitized, truncated prior assistant turn, or empty string.
89
+ # Echoes the sanitized, truncated prior assistant turn, or the empty string.
114
90
  compass_read_prior_turn() {
115
- local session_id="${1:-unknown}"
91
+ local transcript_path="${1:-}"
116
92
  local max_chars="${2:-800}"
117
- local max_age_seconds="${3:-300}"
118
93
 
119
- local raw=""
120
-
121
- # Source 1: CLAUDE_TRANSCRIPT_PATH
122
- if [[ -n "${CLAUDE_TRANSCRIPT_PATH:-}" ]]; then
123
- raw=$(_compass_transcript_from_path "$CLAUDE_TRANSCRIPT_PATH" "$session_id" "$max_age_seconds") || raw=""
124
- fi
125
-
126
- # Source 2: Onlooker event log
127
- if [[ -z "$raw" ]]; then
128
- raw=$(_compass_transcript_from_event_log "$session_id" "$max_age_seconds") || raw=""
129
- fi
94
+ [[ -z "$transcript_path" ]] && return 0
130
95
 
96
+ local raw=""
97
+ raw=$(_compass_transcript_read_from_file "$transcript_path") || raw=""
131
98
  [[ -z "$raw" ]] && return 0
132
99
 
133
- # Sanitize and truncate — compass-sanitizer.sh must be sourced by the caller.
100
+ # compass-sanitizer.sh is sourced by the caller (hook script).
134
101
  compass_sanitize "$raw" "$max_chars"
135
102
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "inspector",
3
+ "version": "0.2.0",
4
+ "description": "Per-edit lint and typecheck gate. Runs the project's configured checks on just the touched file after every Write / Edit / MultiEdit, so the agent sees its own type errors before claiming success. Cheaper than proctor (which runs project-wide verification at Stop); complements assayer (which catches the agent lying about claims). Builds on the Onlooker ecosystem plugin.",
5
+ "author": {
6
+ "name": "Onlooker Community",
7
+ "url": "https://onlooker.dev"
8
+ },
9
+ "homepage": "https://onlooker.dev",
10
+ "repository": "https://github.com/onlooker-community/ecosystem",
11
+ "license": "MIT",
12
+ "skills": [],
13
+ "agents": []
14
+ }
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/inspector-v0.1.0...inspector-v0.2.0) (2026-06-15)
4
+
5
+
6
+ ### Features
7
+
8
+ * **inspector:** ship the per-edit lint/typecheck plugin ([#88](https://github.com/onlooker-community/ecosystem/issues/88)) ([2018243](https://github.com/onlooker-community/ecosystem/commit/201824384abd6a4fc5f4395266924aa413a2ffd1))