@onlooker-community/ecosystem 0.15.2 → 0.17.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.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +39 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +5 -2
  4. package/CHANGELOG.md +15 -0
  5. package/CLAUDE.md +88 -0
  6. package/package.json +3 -3
  7. package/plugins/compass/.claude-plugin/plugin.json +14 -0
  8. package/plugins/compass/CHANGELOG.md +8 -0
  9. package/plugins/compass/config.json +71 -0
  10. package/plugins/compass/docs/adr/001-evaluate-prompts-in-context.md +82 -0
  11. package/plugins/compass/docs/design.md +421 -0
  12. package/plugins/compass/hooks/hooks.json +82 -0
  13. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +95 -0
  14. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +86 -0
  15. package/plugins/compass/scripts/hooks/compass-record-write.sh +97 -0
  16. package/plugins/compass/scripts/hooks/compass-session-start.sh +77 -0
  17. package/plugins/compass/scripts/lib/compass-config.sh +72 -0
  18. package/plugins/compass/scripts/lib/compass-evaluator.sh +374 -0
  19. package/plugins/compass/scripts/lib/compass-events.sh +81 -0
  20. package/plugins/compass/scripts/lib/compass-gate.sh +465 -0
  21. package/plugins/compass/scripts/lib/compass-sanitizer.sh +82 -0
  22. package/plugins/compass/scripts/lib/compass-transcript.sh +135 -0
  23. package/plugins/governor/.claude-plugin/plugin.json +14 -0
  24. package/plugins/governor/CHANGELOG.md +22 -0
  25. package/plugins/governor/config.json +19 -0
  26. package/plugins/governor/hooks/hooks.json +48 -0
  27. package/plugins/governor/scripts/hooks/governor-post-tool-use.sh +147 -0
  28. package/plugins/governor/scripts/hooks/governor-pre-tool-use.sh +199 -0
  29. package/plugins/governor/scripts/hooks/governor-session-start.sh +109 -0
  30. package/plugins/governor/scripts/hooks/governor-stop.sh +108 -0
  31. package/plugins/governor/scripts/lib/governor-config.sh +79 -0
  32. package/plugins/governor/scripts/lib/governor-estimate.sh +116 -0
  33. package/plugins/governor/scripts/lib/governor-events.sh +81 -0
  34. package/plugins/governor/scripts/lib/governor-ledger.sh +172 -0
  35. package/plugins/scribe/.claude-plugin/plugin.json +12 -0
  36. package/plugins/scribe/CHANGELOG.md +8 -0
  37. package/plugins/scribe/config.json +20 -0
  38. package/plugins/scribe/hooks/hooks.json +37 -0
  39. package/plugins/scribe/scripts/hooks/scribe-capture.sh +76 -0
  40. package/plugins/scribe/scripts/hooks/scribe-session-start.sh +58 -0
  41. package/plugins/scribe/scripts/hooks/scribe-stop.sh +67 -0
  42. package/plugins/scribe/scripts/lib/scribe-config.sh +72 -0
  43. package/plugins/scribe/scripts/lib/scribe-distill.sh +239 -0
  44. package/plugins/scribe/scripts/lib/scribe-events.sh +80 -0
  45. package/plugins/scribe/scripts/lib/scribe-extract.sh +147 -0
  46. package/plugins/scribe/scripts/lib/scribe-project-key.sh +89 -0
  47. package/plugins/scribe/scripts/lib/scribe-ulid.sh +50 -0
  48. package/release-please-config.json +48 -0
  49. package/test/bats/governor-config.bats +106 -0
  50. package/test/bats/governor-estimate.bats +86 -0
  51. package/test/bats/governor-events.bats +238 -0
  52. package/test/bats/governor-ledger.bats +220 -0
  53. package/test/bats/scribe-extract.bats +102 -0
  54. package/test/bats/scribe-project-key.bats +75 -0
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env bash
2
+ # Shared evaluation pipeline for Compass.
3
+ #
4
+ # Called by compass-pre-tool-use.sh (Write/Edit/MultiEdit) and
5
+ # compass-bash-gate.sh (Bash write commands). Contains all gate logic:
6
+ # 1. Skip-glob filter
7
+ # 2. Dir+stem cooldown check
8
+ # 3. Turn budget check
9
+ # 4. Context minimum check
10
+ # 5. Skip sentinel check
11
+ # 6. Symbolic skip layer (reply-to-question pattern)
12
+ # 7. Input sanitization
13
+ # 8. Prior-turn transcript read
14
+ # 9. N=5 parallel evaluator
15
+ # 10. Intervention UX on block
16
+ #
17
+ # Exposes:
18
+ # compass_run_gate <tool_name> <file_path> <operation> \
19
+ # <context_or_command> <session_id> <cwd>
20
+ #
21
+ # Hook contract (Claude Code PreToolUse protocol):
22
+ # - Always exits 0.
23
+ # - To block: write {"decision":"block","reason":"..."} to stdout before returning.
24
+ # - To allow: write nothing to stdout.
25
+
26
+ # -----------------------------------------------------------------------
27
+ # Helper: update session state (turn_check_count, circuit_breaker).
28
+ # -----------------------------------------------------------------------
29
+
30
+ _compass_state_file() {
31
+ local session_id="$1"
32
+ local onlooker_dir="${ONLOOKER_DIR:-${HOME}/.onlooker}"
33
+ printf '%s' "${onlooker_dir}/compass/sessions/${session_id}.json"
34
+ }
35
+
36
+ _compass_state_get() {
37
+ local session_id="$1"
38
+ local jq_path="$2"
39
+ local state_file
40
+ state_file=$(_compass_state_file "$session_id")
41
+ [[ -f "$state_file" ]] || { printf ''; return 1; }
42
+ jq -r "${jq_path} // empty" "$state_file" 2>/dev/null
43
+ }
44
+
45
+ _compass_state_update() {
46
+ local session_id="$1"
47
+ local jq_expr="$2"
48
+ local state_file
49
+ state_file=$(_compass_state_file "$session_id")
50
+ [[ -f "$state_file" ]] || return 1
51
+ local updated
52
+ updated=$(jq "$jq_expr" "$state_file" 2>/dev/null) || return 1
53
+ [[ -n "$updated" ]] && printf '%s' "$updated" > "$state_file"
54
+ }
55
+
56
+ _compass_increment_turn_count() {
57
+ local session_id="$1"
58
+ _compass_state_update "$session_id" '.turn_check_count += 1'
59
+ }
60
+
61
+ _compass_record_failure() {
62
+ local session_id="$1"
63
+ _compass_state_update "$session_id" '
64
+ .circuit_breaker.consecutive_failures += 1
65
+ '
66
+ }
67
+
68
+ _compass_reset_failures() {
69
+ local session_id="$1"
70
+ _compass_state_update "$session_id" '
71
+ .circuit_breaker.consecutive_failures = 0
72
+ '
73
+ }
74
+
75
+ _compass_open_circuit() {
76
+ local session_id="$1"
77
+ local now
78
+ now=$(date +%s 2>/dev/null) || now=0
79
+ _compass_state_update "$session_id" \
80
+ --argjson now "$now" \
81
+ '.circuit_breaker.state = "open" | .circuit_breaker.opened_at = $now' \
82
+ 2>/dev/null || \
83
+ _compass_state_update "$session_id" \
84
+ ".circuit_breaker.state = \"open\" | .circuit_breaker.opened_at = ${now}"
85
+ }
86
+
87
+ # -----------------------------------------------------------------------
88
+ # Helper: glob matching for skip_globs.
89
+ # -----------------------------------------------------------------------
90
+ _compass_matches_skip_glob() {
91
+ local file_path="$1"
92
+ local globs_json="$2"
93
+ [[ -z "$file_path" || -z "$globs_json" ]] && return 1
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}"
104
+ pattern="${pattern//\*/[^/]*}"
105
+ pattern="${pattern//DOUBLE_STAR/.*}"
106
+ if [[ "$file_path" =~ $pattern ]]; then
107
+ return 0
108
+ fi
109
+ done
110
+ return 1
111
+ }
112
+
113
+ # -----------------------------------------------------------------------
114
+ # Helper: dir+stem identity (same as compass-record-write.sh).
115
+ # -----------------------------------------------------------------------
116
+ _compass_gate_dir_plus_stem() {
117
+ local path="$1"
118
+ [[ -z "$path" ]] && return 1
119
+ local dir base stem
120
+ dir=$(dirname "$path" 2>/dev/null) || dir="."
121
+ base=$(basename "$path" 2>/dev/null) || base="$path"
122
+ stem="${base%%.*}"
123
+ [[ -z "$stem" ]] && stem="$base"
124
+ printf '%s/%s' "$dir" "$stem"
125
+ }
126
+
127
+ # -----------------------------------------------------------------------
128
+ # Helper: cooldown check.
129
+ # -----------------------------------------------------------------------
130
+ _compass_in_cooldown() {
131
+ local file_path="$1"
132
+ local session_id="$2"
133
+ local cooldown_seconds="$3"
134
+
135
+ [[ -z "$file_path" ]] && return 1
136
+
137
+ local identity
138
+ identity=$(_compass_gate_dir_plus_stem "$file_path") || return 1
139
+
140
+ local state_file
141
+ state_file=$(_compass_state_file "$session_id")
142
+ [[ -f "$state_file" ]] || return 1
143
+
144
+ local now
145
+ now=$(date +%s 2>/dev/null) || now=0
146
+
147
+ local entry_ts
148
+ entry_ts=$(jq -r \
149
+ --arg identity "$identity" \
150
+ '.cooldown[] | select(.identity == $identity) | .ts' \
151
+ "$state_file" 2>/dev/null | tail -1) || entry_ts=""
152
+
153
+ [[ -z "$entry_ts" ]] && return 1
154
+
155
+ local age=$(( now - entry_ts ))
156
+ [[ "$age" -le "$cooldown_seconds" ]] && return 0
157
+ return 1
158
+ }
159
+
160
+ # -----------------------------------------------------------------------
161
+ # Helper: symbolic skip layer (reply-to-question pattern).
162
+ # -----------------------------------------------------------------------
163
+ _compass_symbolic_skip() {
164
+ local prior_turn="$1"
165
+ local context_excerpt="$2"
166
+
167
+ [[ -z "$prior_turn" ]] && return 1
168
+
169
+ # Condition 1: prior turn contains a numbered list and a question mark.
170
+ local has_numbered_list has_question
171
+ has_numbered_list=$(printf '%s' "$prior_turn" \
172
+ | grep -cE '^\s*[0-9]+[.)]\s+' 2>/dev/null) || has_numbered_list=0
173
+ has_question=$(printf '%s' "$prior_turn" | grep -c '?' 2>/dev/null) || has_question=0
174
+
175
+ [[ "$has_numbered_list" -eq 0 || "$has_question" -eq 0 ]] && return 1
176
+
177
+ # Condition 2: context excerpt looks like an option reference or affirmation.
178
+ local trimmed_context
179
+ trimmed_context=$(printf '%s' "$context_excerpt" | tr -s ' \t\n' ' ' | sed 's/^ *//;s/ *$//' 2>/dev/null) \
180
+ || trimmed_context="$context_excerpt"
181
+
182
+ if printf '%s' "$trimmed_context" | grep -qiE \
183
+ '^(yes|no|both|all|none|either|ok|okay|sure|the (first|second|third|fourth|fifth)|option [0-9]|[0-9]+[.)]?$)' \
184
+ 2>/dev/null; then
185
+ return 0
186
+ fi
187
+
188
+ return 1
189
+ }
190
+
191
+ # -----------------------------------------------------------------------
192
+ # Intervention output — block with structured message.
193
+ # -----------------------------------------------------------------------
194
+ _compass_intervention() {
195
+ local tool_name="$1"
196
+ local file_path="$2"
197
+ local confidence="$3"
198
+ local stddev="$4"
199
+ local primary_concern="$5"
200
+ local rationale="$6"
201
+ local session_id="$7"
202
+ local confidence_threshold="$8"
203
+ local stddev_threshold="$9"
204
+
205
+ local block_reason=""
206
+ local passed_conf passed_std
207
+ passed_conf=$(awk "BEGIN {exit !(${confidence:-0} >= ${confidence_threshold:-0.65})}" 2>/dev/null && echo true || echo false)
208
+ passed_std=$(awk "BEGIN {exit !(${stddev:-1} <= ${stddev_threshold:-0.20})}" 2>/dev/null && echo true || echo false)
209
+
210
+ if [[ "$passed_conf" != "true" && "$passed_std" != "true" ]]; then
211
+ block_reason="low confidence and high evaluator disagreement"
212
+ elif [[ "$passed_conf" != "true" ]]; then
213
+ block_reason="low confidence (${confidence} < ${confidence_threshold})"
214
+ else
215
+ block_reason="high evaluator disagreement (stddev ${stddev} > ${stddev_threshold})"
216
+ fi
217
+
218
+ local message
219
+ message=$(printf \
220
+ 'Compass blocked this write — %s.
221
+
222
+ File: %s
223
+ Tool: %s
224
+ Confidence: %s Stddev: %s Concern: %s
225
+ Evaluator rationale: %s
226
+
227
+ Choose a path:
228
+ • Type compass: proceed — override and allow this write
229
+ • Provide more context — compass will re-evaluate once with your clarification
230
+ • Type compass: cancel — abandon this write' \
231
+ "$block_reason" "$file_path" "$tool_name" \
232
+ "${confidence:-n/a}" "${stddev:-n/a}" "${primary_concern:-none}" \
233
+ "${rationale:-(none)}")
234
+
235
+ jq -n \
236
+ --arg message "$message" \
237
+ --arg decision "block" \
238
+ '{"decision": $decision, "reason": $message}' 2>/dev/null \
239
+ || printf '{"decision":"block","reason":"Compass blocked this write — intent unclear."}'
240
+ }
241
+
242
+ # -----------------------------------------------------------------------
243
+ # 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)
248
+ # $5 — session_id
249
+ # $6 — cwd
250
+ # -----------------------------------------------------------------------
251
+ compass_run_gate() {
252
+ local tool_name="$1"
253
+ local file_path="$2"
254
+ local operation="$3"
255
+ local context="$4"
256
+ local session_id="${5:-unknown}"
257
+ local cwd="${6:-}"
258
+
259
+ local _allow_exit=0
260
+ local _block_exit=0
261
+
262
+ # ---- Rule 1: skip sentinel ----------------------------------------
263
+ if printf '%s' "${context}${file_path}" | grep -qF '[compass:skip]' 2>/dev/null; then
264
+ compass_emit_event "compass.check.skipped" \
265
+ "$(jq -n --arg r "skip_sentinel" --arg f "$file_path" \
266
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
267
+ return $_allow_exit
268
+ fi
269
+
270
+ # ---- Rule 2: skip_globs -------------------------------------------
271
+ local skip_globs_json
272
+ skip_globs_json=$(compass_config_get_json '.compass.skip_globs') || skip_globs_json="[]"
273
+ if [[ -n "$file_path" ]] && _compass_matches_skip_glob "$file_path" "$skip_globs_json"; then
274
+ compass_emit_event "compass.check.skipped" \
275
+ "$(jq -n --arg r "skip_glob" --arg f "$file_path" \
276
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
277
+ return $_allow_exit
278
+ fi
279
+
280
+ # ---- Rule 3: dir+stem cooldown ------------------------------------
281
+ local cooldown_seconds
282
+ cooldown_seconds=$(compass_config_get '.compass.cooldown.seconds')
283
+ cooldown_seconds="${cooldown_seconds:-120}"
284
+ if [[ -n "$file_path" ]] && _compass_in_cooldown "$file_path" "$session_id" "$cooldown_seconds"; then
285
+ compass_emit_event "compass.check.skipped" \
286
+ "$(jq -n --arg r "dir_plus_stem_cooldown" --arg f "$file_path" \
287
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
288
+ return $_allow_exit
289
+ fi
290
+
291
+ # ---- Rule 4: turn budget ------------------------------------------
292
+ local max_checks
293
+ max_checks=$(compass_config_get '.compass.max_checks_per_turn')
294
+ max_checks="${max_checks:-3}"
295
+ local current_count
296
+ current_count=$(_compass_state_get "$session_id" '.turn_check_count') || current_count=0
297
+ current_count="${current_count:-0}"
298
+ if (( current_count >= max_checks )); then
299
+ compass_emit_event "compass.check.skipped" \
300
+ "$(jq -n --arg r "turn_budget_exhausted" --arg f "$file_path" \
301
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
302
+ return $_allow_exit
303
+ fi
304
+
305
+ # ---- Rule 5: context minimum -------------------------------------
306
+ local min_context_chars
307
+ min_context_chars=$(compass_config_get '.compass.min_context_chars')
308
+ min_context_chars="${min_context_chars:-80}"
309
+ local context_len="${#context}"
310
+ if (( context_len < min_context_chars )); then
311
+ compass_emit_event "compass.check.skipped" \
312
+ "$(jq -n --arg r "insufficient_context" --arg f "$file_path" \
313
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
314
+ return $_allow_exit
315
+ fi
316
+
317
+ # ---- Rule 6: circuit breaker -------------------------------------
318
+ local cb_enabled cb_state cb_failures cb_threshold cb_open_duration
319
+ cb_enabled=$(compass_config_get '.compass.circuit_breaker.enabled')
320
+ cb_enabled="${cb_enabled:-true}"
321
+ if [[ "$cb_enabled" == "true" ]]; then
322
+ cb_state=$(_compass_state_get "$session_id" '.circuit_breaker.state') || cb_state="closed"
323
+ cb_state="${cb_state:-closed}"
324
+ if [[ "$cb_state" == "open" ]]; then
325
+ cb_open_duration=$(compass_config_get '.compass.circuit_breaker.open_duration_seconds')
326
+ cb_open_duration="${cb_open_duration:-300}"
327
+ local opened_at
328
+ opened_at=$(_compass_state_get "$session_id" '.circuit_breaker.opened_at') || opened_at=0
329
+ opened_at="${opened_at:-0}"
330
+ local now
331
+ now=$(date +%s 2>/dev/null) || now=0
332
+ local open_age=$(( now - opened_at ))
333
+ if (( open_age < cb_open_duration )); then
334
+ compass_emit_event "compass.check.skipped" \
335
+ "$(jq -n --arg r "circuit_open" --arg f "$file_path" \
336
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
337
+ return $_allow_exit
338
+ else
339
+ # TTL expired — close the circuit and proceed.
340
+ _compass_state_update "$session_id" \
341
+ '.circuit_breaker.state = "closed" | .circuit_breaker.opened_at = null' \
342
+ 2>/dev/null || true
343
+ fi
344
+ fi
345
+ fi
346
+
347
+ # ---- Increment turn check count ----------------------------------
348
+ _compass_increment_turn_count "$session_id" 2>/dev/null || true
349
+
350
+ # ---- Read prior assistant turn -----------------------------------
351
+ local prior_turn_chars_max transcript_max_age
352
+ prior_turn_chars_max=$(compass_config_get '.compass.transcript.prior_turn_chars_max')
353
+ 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
+
357
+ local prior_turn=""
358
+ prior_turn=$(compass_read_prior_turn "$session_id" "$prior_turn_chars_max" "$transcript_max_age") \
359
+ || prior_turn=""
360
+
361
+ # ---- Symbolic skip layer -----------------------------------------
362
+ local skip_reply_enabled
363
+ skip_reply_enabled=$(compass_config_get '.compass.skip_patterns.reply_to_question.enabled')
364
+ skip_reply_enabled="${skip_reply_enabled:-true}"
365
+ if [[ "$skip_reply_enabled" == "true" ]] && _compass_symbolic_skip "$prior_turn" "$context"; then
366
+ compass_emit_event "compass.check.skipped" \
367
+ "$(jq -n --arg r "reply_to_question_pattern" --arg f "$file_path" \
368
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
369
+ return $_allow_exit
370
+ fi
371
+
372
+ # ---- Sanitize context excerpt ------------------------------------
373
+ local context_chars_max
374
+ context_chars_max=$(compass_config_get '.compass.context_chars_max')
375
+ context_chars_max="${context_chars_max:-600}"
376
+ local sanitized_context
377
+ sanitized_context=$(compass_sanitize "$context" "$context_chars_max")
378
+
379
+ local sanitized_path
380
+ sanitized_path=$(compass_sanitize "$file_path" 0)
381
+
382
+ # ---- Run evaluator -----------------------------------------------
383
+ local eval_result eval_exit
384
+ eval_result=$(compass_evaluate \
385
+ "$tool_name" "$sanitized_path" "$operation" \
386
+ "$prior_turn" "$sanitized_context" "$session_id")
387
+ eval_exit=$?
388
+
389
+ local decision confidence stddev primary_concern rationale
390
+ decision=$(printf '%s' "$eval_result" | jq -r '.decision // "error"' 2>/dev/null) || decision="error"
391
+ confidence=$(printf '%s' "$eval_result" | jq -r '.confidence // ""' 2>/dev/null) || confidence=""
392
+ stddev=$(printf '%s' "$eval_result" | jq -r '.stddev // ""' 2>/dev/null) || stddev=""
393
+ primary_concern=$(printf '%s' "$eval_result" | jq -r '.primary_concern // "none"' 2>/dev/null) || primary_concern="none"
394
+ rationale=$(printf '%s' "$eval_result" | jq -r '.rationale // ""' 2>/dev/null) || rationale=""
395
+
396
+ local had_prior_turn="false"
397
+ [[ -n "$prior_turn" ]] && had_prior_turn="true"
398
+
399
+ local confidence_threshold stddev_threshold
400
+ confidence_threshold=$(compass_config_get '.compass.confidence_threshold')
401
+ confidence_threshold="${confidence_threshold:-0.65}"
402
+ stddev_threshold=$(compass_config_get '.compass.stddev_threshold')
403
+ stddev_threshold="${stddev_threshold:-0.20}"
404
+
405
+ if [[ "$decision" == "error" ]]; then
406
+ local error_policy
407
+ error_policy=$(compass_config_get '.compass.error_policy')
408
+ error_policy="${error_policy:-closed}"
409
+
410
+ _compass_record_failure "$session_id" 2>/dev/null || true
411
+
412
+ cb_failures=$(_compass_state_get "$session_id" '.circuit_breaker.consecutive_failures') \
413
+ || cb_failures=0
414
+ cb_failures="${cb_failures:-0}"
415
+ cb_threshold=$(compass_config_get '.compass.circuit_breaker.consecutive_failures_to_open')
416
+ cb_threshold="${cb_threshold:-3}"
417
+ if [[ "$cb_enabled" == "true" ]] && (( cb_failures >= cb_threshold )); then
418
+ _compass_open_circuit "$session_id" 2>/dev/null || true
419
+ fi
420
+
421
+ compass_emit_event "compass.check.skipped" \
422
+ "$(jq -n --arg r "sampler_error" --arg f "$file_path" \
423
+ '{reason:$r,file_path:$f}' 2>/dev/null || echo '{}')" 2>/dev/null || true
424
+
425
+ [[ "$error_policy" == "open" ]] && return $_allow_exit
426
+ # fail-closed: block
427
+ _compass_intervention \
428
+ "$tool_name" "$file_path" "" "" "none" \
429
+ "Evaluator failed — cannot verify intent clarity." \
430
+ "$session_id" "$confidence_threshold" "$stddev_threshold"
431
+ return $_block_exit
432
+ fi
433
+
434
+ # ---- Emit result event -------------------------------------------
435
+ if [[ "$decision" == "pass" ]]; then
436
+ _compass_reset_failures "$session_id" 2>/dev/null || true
437
+ compass_emit_event "compass.check.passed" \
438
+ "$(jq -n \
439
+ --arg f "$file_path" \
440
+ --arg t "$tool_name" \
441
+ --arg hpt "$had_prior_turn" \
442
+ --argjson conf "${confidence:-0}" \
443
+ --argjson std "${stddev:-0}" \
444
+ '{confidence:$conf,stddev:$std,file_path:$f,tool_name:$t,had_prior_turn:($hpt=="true")}' \
445
+ 2>/dev/null || echo '{}')" 2>/dev/null || true
446
+ return $_allow_exit
447
+ fi
448
+
449
+ # decision == fail
450
+ compass_emit_event "compass.check.failed" \
451
+ "$(jq -n \
452
+ --arg f "$file_path" \
453
+ --arg concern "$primary_concern" \
454
+ --argjson conf "${confidence:-0}" \
455
+ --argjson std "${stddev:-0}" \
456
+ '{confidence:$conf,stddev:$std,primary_concern:$concern,file_path:$f}' \
457
+ 2>/dev/null || echo '{}')" 2>/dev/null || true
458
+
459
+ _compass_intervention \
460
+ "$tool_name" "$file_path" \
461
+ "${confidence:-0}" "${stddev:-0}" \
462
+ "$primary_concern" "$rationale" \
463
+ "$session_id" "$confidence_threshold" "$stddev_threshold"
464
+ return $_block_exit
465
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # Input sanitization for Compass evaluator-bound fields.
3
+ #
4
+ # Applied before any user-supplied content is interpolated into the
5
+ # evaluator prompt. Prevents prompt injection via crafted file names,
6
+ # file contents, or conversation text.
7
+ #
8
+ # Exposes:
9
+ # compass_sanitize <string> <max_chars> # echoes sanitized, truncated string
10
+ # compass_sanitize_field <field> <string> # echoes sanitized string for a named field
11
+ #
12
+ # Sanitization steps (applied in order):
13
+ # 1. Null-byte removal
14
+ # 2. Control-character removal (0x00–0x1F and 0x7F, except \t and \n)
15
+ # 3. XML delimiter stripping (evaluator prompt tag sequences → [STRIPPED])
16
+ # 4. Truncation to max_chars
17
+
18
+ # Tags that, if present in user-supplied data, would inject into the evaluator prompt.
19
+ _COMPASS_STRIP_SEQUENCES=(
20
+ "<prior_assistant_turn>"
21
+ "</prior_assistant_turn>"
22
+ "<context_excerpt>"
23
+ "</context_excerpt>"
24
+ "<tool_input>"
25
+ "</tool_input>"
26
+ "<instructions>"
27
+ "</instructions>"
28
+ "<|"
29
+ "[INST]"
30
+ "[/INST]"
31
+ "<<SYS>>"
32
+ "<</SYS>>"
33
+ )
34
+
35
+ # Remove null bytes and ASCII control characters except \t (0x09) and \n (0x0A).
36
+ _compass_strip_control_chars() {
37
+ local input="$1"
38
+ # tr: delete bytes 0x00-0x08, 0x0B-0x1F, 0x7F
39
+ printf '%s' "$input" \
40
+ | tr -d '\000-\010\013-\037\177' \
41
+ 2>/dev/null
42
+ }
43
+
44
+ # Replace all occurrences of a literal string with [STRIPPED].
45
+ _compass_strip_literal() {
46
+ local input="$1"
47
+ 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
52
+ }
53
+
54
+ # Truncate a string to at most max_chars UTF-8 characters.
55
+ _compass_truncate() {
56
+ local input="$1"
57
+ local max_chars="${2:-0}"
58
+ if [[ "$max_chars" -le 0 ]]; then
59
+ printf '%s' "$input"
60
+ return
61
+ fi
62
+ printf '%s' "$input" | cut -c "1-${max_chars}" 2>/dev/null
63
+ }
64
+
65
+ # Full sanitization pipeline. Echoes the sanitized string.
66
+ # $1 — raw input string
67
+ # $2 — max chars (0 = no truncation)
68
+ compass_sanitize() {
69
+ local input="$1"
70
+ local max_chars="${2:-0}"
71
+
72
+ local s
73
+ s=$(_compass_strip_control_chars "$input")
74
+
75
+ local seq
76
+ for seq in "${_COMPASS_STRIP_SEQUENCES[@]}"; do
77
+ s=$(_compass_strip_literal "$s" "$seq")
78
+ done
79
+
80
+ s=$(_compass_truncate "$s" "$max_chars")
81
+ printf '%s' "$s"
82
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # Prior assistant turn reader for Compass.
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).
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
16
+ #
17
+ # 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"
60
+ }
61
+
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
87
+
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
99
+
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"
104
+
105
+ [[ -n "$prior_turn" ]] || return 1
106
+ printf '%s' "$prior_turn"
107
+ }
108
+
109
+ # Read the prior assistant turn.
110
+ # $1 — session_id
111
+ # $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.
114
+ compass_read_prior_turn() {
115
+ local session_id="${1:-unknown}"
116
+ local max_chars="${2:-800}"
117
+ local max_age_seconds="${3:-300}"
118
+
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
130
+
131
+ [[ -z "$raw" ]] && return 0
132
+
133
+ # Sanitize and truncate — compass-sanitizer.sh must be sourced by the caller.
134
+ compass_sanitize "$raw" "$max_chars"
135
+ }