@onlooker-community/ecosystem 0.16.0 → 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.
- package/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -2
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +88 -0
- package/package.json +2 -2
- package/plugins/compass/.claude-plugin/plugin.json +14 -0
- package/plugins/compass/CHANGELOG.md +8 -0
- package/plugins/compass/config.json +71 -0
- package/plugins/compass/docs/adr/001-evaluate-prompts-in-context.md +82 -0
- package/plugins/compass/docs/design.md +421 -0
- package/plugins/compass/hooks/hooks.json +82 -0
- package/plugins/compass/scripts/hooks/compass-bash-gate.sh +95 -0
- package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +86 -0
- package/plugins/compass/scripts/hooks/compass-record-write.sh +97 -0
- package/plugins/compass/scripts/hooks/compass-session-start.sh +77 -0
- package/plugins/compass/scripts/lib/compass-config.sh +72 -0
- package/plugins/compass/scripts/lib/compass-evaluator.sh +374 -0
- package/plugins/compass/scripts/lib/compass-events.sh +81 -0
- package/plugins/compass/scripts/lib/compass-gate.sh +465 -0
- package/plugins/compass/scripts/lib/compass-sanitizer.sh +82 -0
- package/plugins/compass/scripts/lib/compass-transcript.sh +135 -0
- package/plugins/governor/.claude-plugin/plugin.json +1 -1
- package/plugins/governor/CHANGELOG.md +7 -0
- package/plugins/scribe/.claude-plugin/plugin.json +12 -0
- package/plugins/scribe/CHANGELOG.md +8 -0
- package/plugins/scribe/config.json +20 -0
- package/plugins/scribe/hooks/hooks.json +37 -0
- package/plugins/scribe/scripts/hooks/scribe-capture.sh +76 -0
- package/plugins/scribe/scripts/hooks/scribe-session-start.sh +58 -0
- package/plugins/scribe/scripts/hooks/scribe-stop.sh +67 -0
- package/plugins/scribe/scripts/lib/scribe-config.sh +72 -0
- package/plugins/scribe/scripts/lib/scribe-distill.sh +239 -0
- package/plugins/scribe/scripts/lib/scribe-events.sh +80 -0
- package/plugins/scribe/scripts/lib/scribe-extract.sh +147 -0
- package/plugins/scribe/scripts/lib/scribe-project-key.sh +89 -0
- package/plugins/scribe/scripts/lib/scribe-ulid.sh +50 -0
- package/release-please-config.json +32 -0
- package/test/bats/scribe-extract.bats +102 -0
- 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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "governor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Resource governance and budget enforcement for the Onlooker ecosystem. Tracks per-session token and cost spend, gates Task spawns before they exceed a configurable budget ceiling, and emits governor.* events for audit. Named for the steam-engine governor — a device that regulates output. Builds on the Onlooker ecosystem plugin.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|