@onlooker-community/ecosystem 0.28.0 → 0.29.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 (52) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +2 -2
  4. package/CHANGELOG.md +14 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/plugin-catalog.md +125 -0
  7. package/package.json +3 -3
  8. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  9. package/plugins/compass/CHANGELOG.md +14 -0
  10. package/plugins/compass/README.md +1 -3
  11. package/plugins/compass/config.json +1 -2
  12. package/plugins/compass/docs/design.md +1 -2
  13. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
  14. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +17 -5
  15. package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
  16. package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
  17. package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
  18. package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
  19. package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
  20. package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
  21. package/plugins/inspector/.claude-plugin/plugin.json +14 -0
  22. package/plugins/inspector/README.md +155 -0
  23. package/plugins/inspector/config.json +25 -0
  24. package/plugins/inspector/docs/design.md +286 -0
  25. package/plugins/inspector/hooks/hooks.json +33 -0
  26. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
  27. package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
  28. package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
  29. package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
  30. package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
  31. package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
  32. package/test/bats/archivist-project-key.bats +79 -0
  33. package/test/bats/archivist-storage.bats +79 -0
  34. package/test/bats/compact-tracker.bats +125 -0
  35. package/test/bats/compass-config.bats +65 -0
  36. package/test/bats/compass-gate.bats +129 -0
  37. package/test/bats/compass-sanitizer.bats +69 -0
  38. package/test/bats/compass-symbolic-skip.bats +88 -0
  39. package/test/bats/compass-transcript.bats +80 -0
  40. package/test/bats/inspector-config.bats +118 -0
  41. package/test/bats/inspector-events.bats +156 -0
  42. package/test/bats/inspector-post-write-hook.bats +164 -0
  43. package/test/bats/inspector-project-key.bats +68 -0
  44. package/test/bats/inspector-ulid.bats +34 -0
  45. package/test/bats/onlooker-schema.bats +111 -0
  46. package/test/bats/prompt-rules.bats +98 -0
  47. package/test/bats/session-tracker.bats +260 -0
  48. package/test/bats/skill-usage-tracker.bats +63 -0
  49. package/test/bats/task-tracker.bats +102 -0
  50. package/test/bats/turn-tracker.bats +180 -0
  51. package/test/bats/validate-path.bats +125 -0
  52. package/test/bats/worktree-tracker.bats +167 -0
@@ -117,3 +117,82 @@ setup() {
117
117
  first_id=$(printf '%s' "$ranked" | jq -r '.[0].id')
118
118
  [ "$first_id" = "$newer_id" ]
119
119
  }
120
+
121
+ @test "storage_root prints the archivist dir under ONLOOKER_DIR" {
122
+ run archivist_storage_root
123
+ [ "$status" -eq 0 ]
124
+ [ "$output" = "${ONLOOKER_DIR}/archivist" ]
125
+ }
126
+
127
+ @test "project_dir prints root joined with the key" {
128
+ local key="abc123def456"
129
+ run archivist_project_dir "$key"
130
+ [ "$status" -eq 0 ]
131
+ [ "$output" = "${ONLOOKER_DIR}/archivist/${key}" ]
132
+ }
133
+
134
+ @test "kind_dir prints the per-kind subdir under the project dir" {
135
+ local key="abc123def456"
136
+ run archivist_kind_dir "$key" "decisions"
137
+ [ "$status" -eq 0 ]
138
+ [ "$output" = "${ONLOOKER_DIR}/archivist/${key}/decisions" ]
139
+ }
140
+
141
+ @test "kind_dir honors an arbitrary kind name" {
142
+ local key="abc123def456"
143
+ run archivist_kind_dir "$key" "dead_ends"
144
+ [ "$status" -eq 0 ]
145
+ [ "$output" = "${ONLOOKER_DIR}/archivist/${key}/dead_ends" ]
146
+ }
147
+
148
+ @test "write_manifest creates manifest.json under the project dir" {
149
+ local key="abc123def456"
150
+ run archivist_storage_write_manifest "$key" "git@github.com:org/repo.git" "$REPO"
151
+ [ "$status" -eq 0 ]
152
+ [ -f "${ONLOOKER_DIR}/archivist/${key}/manifest.json" ]
153
+ }
154
+
155
+ @test "write_manifest records the project_key, remote_url, and repo_root" {
156
+ local key="abc123def456"
157
+ local remote="git@github.com:org/repo.git"
158
+ archivist_storage_write_manifest "$key" "$remote" "$REPO"
159
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
160
+
161
+ [ "$(jq -r '.project_key' "$manifest")" = "$key" ]
162
+ [ "$(jq -r '.remote_url' "$manifest")" = "$remote" ]
163
+ [ "$(jq -r '.repo_root' "$manifest")" = "$REPO" ]
164
+ [ "$(jq -r '.source' "$manifest")" = "local" ]
165
+ }
166
+
167
+ @test "write_manifest stamps an ISO-8601 last_compact_at timestamp" {
168
+ local key="abc123def456"
169
+ archivist_storage_write_manifest "$key" "remote" "$REPO"
170
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
171
+
172
+ local ts
173
+ ts=$(jq -r '.last_compact_at' "$manifest")
174
+ [[ "$ts" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
175
+ }
176
+
177
+ @test "write_manifest stores null for an empty remote_url" {
178
+ local key="abc123def456"
179
+ archivist_storage_write_manifest "$key" "" "$REPO"
180
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
181
+
182
+ [ "$(jq -r '.remote_url' "$manifest")" = "null" ]
183
+ [ "$(jq '.remote_url == null' "$manifest")" = "true" ]
184
+ }
185
+
186
+ @test "write_manifest stores null for an empty repo_root" {
187
+ local key="abc123def456"
188
+ archivist_storage_write_manifest "$key" "remote" ""
189
+ local manifest="${ONLOOKER_DIR}/archivist/${key}/manifest.json"
190
+
191
+ [ "$(jq -r '.repo_root' "$manifest")" = "null" ]
192
+ [ "$(jq '.repo_root == null' "$manifest")" = "true" ]
193
+ }
194
+
195
+ @test "write_manifest rejects an empty key" {
196
+ run archivist_storage_write_manifest "" "remote" "$REPO"
197
+ [ "$status" -ne 0 ]
198
+ }
@@ -71,3 +71,128 @@ setup() {
71
71
  @test "compact_tracker_estimate_tokens estimates from string length" {
72
72
  [ "$(compact_tracker_estimate_tokens "abcdefghij" false)" -eq 2 ]
73
73
  }
74
+
75
+ @test "compact_tracker_state_file prints path under compact trackers dir" {
76
+ local session_id="unit-state-file"
77
+ local result
78
+ result=$(compact_tracker_state_file "$session_id")
79
+ [ "$result" = "${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}" ]
80
+ }
81
+
82
+ @test "compact_tracker_record_pre creates state file with pending and compact_count 1" {
83
+ local session_id="unit-record-pre-1"
84
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
85
+ rm -f "$state_file"
86
+
87
+ compact_tracker_record_pre "$session_id" '{"trigger":"manual","transcript_path":""}'
88
+
89
+ [ -f "$state_file" ]
90
+ jq -e '.pending == true
91
+ and .trigger == "manual"
92
+ and .compact_count == 1
93
+ and (.started_ms | type) == "number"' \
94
+ "$state_file" >/dev/null
95
+ }
96
+
97
+ @test "compact_tracker_record_pre increments compact_count on second call" {
98
+ local session_id="unit-record-pre-2"
99
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
100
+ rm -f "$state_file"
101
+
102
+ compact_tracker_record_pre "$session_id" '{"trigger":"manual","transcript_path":""}'
103
+ jq -e '.compact_count == 1' "$state_file" >/dev/null
104
+
105
+ compact_tracker_record_pre "$session_id" '{"trigger":"manual","transcript_path":""}'
106
+ jq -e '.compact_count == 2' "$state_file" >/dev/null
107
+ }
108
+
109
+ @test "compact_tracker_record_pre preserves custom_instructions when present" {
110
+ local session_id="unit-record-pre-3"
111
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
112
+ rm -f "$state_file"
113
+
114
+ compact_tracker_record_pre "$session_id" \
115
+ '{"trigger":"manual","transcript_path":"","custom_instructions":"keep the API design notes"}'
116
+
117
+ jq -e '.custom_instructions == "keep the API design notes"' "$state_file" >/dev/null
118
+ }
119
+
120
+ @test "compact_tracker_append_summary appends a JSONL record" {
121
+ local session_id="unit-append-1"
122
+ local summary_file="${ONLOOKER_SESSION_SUMMARIES_DIR}/${session_id}.jsonl"
123
+ rm -f "$summary_file"
124
+
125
+ compact_tracker_append_summary "$session_id" \
126
+ '{"trigger":"manual","compact_summary":"first summary"}'
127
+
128
+ [ -f "$summary_file" ]
129
+ # Records are written as a JSON stream; count objects rather than physical lines.
130
+ [ "$(jq -s 'length' "$summary_file")" -eq 1 ]
131
+ jq -se '.[0].compact_summary == "first summary" and .[0].trigger == "manual"' "$summary_file" >/dev/null
132
+ }
133
+
134
+ @test "compact_tracker_append_summary appends a second line on second call" {
135
+ local session_id="unit-append-2"
136
+ local summary_file="${ONLOOKER_SESSION_SUMMARIES_DIR}/${session_id}.jsonl"
137
+ rm -f "$summary_file"
138
+
139
+ compact_tracker_append_summary "$session_id" '{"trigger":"manual","compact_summary":"one"}'
140
+ compact_tracker_append_summary "$session_id" '{"trigger":"auto","compact_summary":"two"}'
141
+
142
+ [ "$(jq -s 'length' "$summary_file")" -eq 2 ]
143
+ [ "$(jq -s -r '.[0].compact_summary' "$summary_file")" = "one" ]
144
+ [ "$(jq -s -r '.[1].trigger' "$summary_file")" = "auto" ]
145
+ }
146
+
147
+ @test "compact_tracker_append_summary is a no-op when compact_summary is empty" {
148
+ local session_id="unit-append-3"
149
+ local summary_file="${ONLOOKER_SESSION_SUMMARIES_DIR}/${session_id}.jsonl"
150
+ rm -f "$summary_file"
151
+
152
+ run compact_tracker_append_summary "$session_id" '{"trigger":"manual","compact_summary":""}'
153
+ [ "$status" -eq 0 ]
154
+ [ ! -f "$summary_file" ]
155
+ }
156
+
157
+ @test "compact_tracker_build_compact_payload uses tokens_before from state file" {
158
+ local session_id="unit-build-payload"
159
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
160
+ printf '%s\n' '{"tokens_before":1000}' >"$state_file"
161
+
162
+ local payload
163
+ payload=$(compact_tracker_build_compact_payload "$session_id" \
164
+ '{"compact_summary":"a short compacted summary of the prior context"}')
165
+
166
+ echo "$payload" | jq -e '.tokens_before == 1000
167
+ and (.tokens_after | type) == "number"
168
+ and (.compression_ratio | type) == "number"' >/dev/null
169
+ }
170
+
171
+ @test "compact_tracker_record_post finalizes state and resets turn_tool_seq" {
172
+ local session_id="unit-record-post-1"
173
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
174
+ local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
175
+
176
+ printf '%s\n' '{"pending":true,"started_ms":1700000000000,"compact_count":1}' >"$state_file"
177
+ printf '%s\n' '{"turn_number":3,"turn_tool_seq":5}' >"$tracker_file"
178
+
179
+ compact_tracker_record_post "$session_id" '{"trigger":"manual","compact_summary":"done"}'
180
+
181
+ jq -e '.pending == false and (.completed_ms | type) == "number"' "$state_file" >/dev/null
182
+ jq -e '.turn_tool_seq == 0 and .turn_number == 3' "$tracker_file" >/dev/null
183
+ }
184
+
185
+ @test "compact_tracker_record_post falls back to create state when none exists" {
186
+ local session_id="unit-record-post-2"
187
+ local state_file="${ONLOOKER_COMPACT_TRACKERS_DIR}/${session_id}"
188
+ local tracker_file="${ONLOOKER_SESSION_TRACKERS_DIR}/${session_id}"
189
+ rm -f "$state_file" "$tracker_file"
190
+
191
+ compact_tracker_record_post "$session_id" '{"trigger":"auto","compact_summary":"recovered"}'
192
+
193
+ [ -f "$state_file" ]
194
+ jq -e '.pending == false
195
+ and (.completed_ms | type) == "number"
196
+ and .compact_count == 1' \
197
+ "$state_file" >/dev/null
198
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/compass"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/compass-config.sh"
11
+ }
12
+
13
+ @test "compass is disabled by default" {
14
+ compass_config_load ""
15
+ run compass_config_enabled
16
+ [ "$status" -ne 0 ]
17
+ }
18
+
19
+ @test "user-level settings.json can enable compass" {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"compass":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ compass_config_load ""
23
+ run compass_config_enabled
24
+ [ "$status" -eq 0 ]
25
+ }
26
+
27
+ @test "repo-level settings.json overrides user-level" {
28
+ mkdir -p "${HOME}/.claude"
29
+ printf '%s\n' '{"compass":{"enabled":true}}' > "${HOME}/.claude/settings.json"
30
+ local repo="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "${repo}/.claude"
32
+ printf '%s\n' '{"compass":{"enabled":false}}' > "${repo}/.claude/settings.json"
33
+ compass_config_load "$repo"
34
+ run compass_config_enabled
35
+ [ "$status" -ne 0 ]
36
+ }
37
+
38
+ @test "shipped defaults survive a partial overlay" {
39
+ mkdir -p "${HOME}/.claude"
40
+ printf '%s\n' \
41
+ '{"compass":{"enabled":true,"evaluator":{"n":7}}}' \
42
+ > "${HOME}/.claude/settings.json"
43
+ compass_config_load ""
44
+ # Overlay key is picked up.
45
+ [ "$(compass_config_get '.compass.evaluator.n')" = "7" ]
46
+ # Defaults under the same parent key survive the deep merge.
47
+ [ "$(compass_config_get '.compass.evaluator.temperature')" = "0.3" ]
48
+ [ "$(compass_config_get '.compass.confidence_threshold')" = "0.65" ]
49
+ [ "$(compass_config_get '.compass.stddev_threshold')" = "0.2" ]
50
+ }
51
+
52
+ @test "config_get_json returns skip_globs array" {
53
+ compass_config_load ""
54
+ run compass_config_get_json '.compass.skip_globs'
55
+ [ "$status" -eq 0 ]
56
+ printf '%s' "$output" \
57
+ | jq -e 'index("**/*.lock") != null and index("**/.git/**") != null' >/dev/null
58
+ }
59
+
60
+ @test "transcript block no longer carries the obsolete transcript_max_age_seconds knob" {
61
+ compass_config_load ""
62
+ # Future readers should not find the removed knob — see ADR-001 (read by
63
+ # transcript_path from hook JSON, no event-log fallback).
64
+ [ -z "$(compass_config_get '.compass.transcript.transcript_max_age_seconds')" ]
65
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Covers compass_run_gate's pre-evaluator rules: skip sentinel,
4
+ # skip_globs, dir+stem cooldown, turn budget, context minimum. These are
5
+ # the cheap gating steps that run before any LLM call.
6
+
7
+ setup() {
8
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
9
+ setup_test_env
10
+
11
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/compass"
12
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
13
+
14
+ # Source the libs the gate depends on.
15
+ # shellcheck disable=SC1091
16
+ source "${PLUGIN_ROOT}/scripts/lib/compass-config.sh"
17
+ # shellcheck disable=SC1091
18
+ source "${PLUGIN_ROOT}/scripts/lib/compass-events.sh"
19
+ # shellcheck disable=SC1091
20
+ source "${PLUGIN_ROOT}/scripts/lib/compass-sanitizer.sh"
21
+ # shellcheck disable=SC1091
22
+ source "${PLUGIN_ROOT}/scripts/lib/compass-transcript.sh"
23
+ # shellcheck disable=SC1091
24
+ source "${PLUGIN_ROOT}/scripts/lib/compass-evaluator.sh"
25
+ # shellcheck disable=SC1091
26
+ source "${PLUGIN_ROOT}/scripts/lib/compass-gate.sh"
27
+
28
+ # Enable compass and load config.
29
+ mkdir -p "${HOME}/.claude"
30
+ printf '%s\n' '{"compass":{"enabled":true}}' > "${HOME}/.claude/settings.json"
31
+ compass_config_load ""
32
+
33
+ # Seed a session-state file so the gate's state lookups succeed.
34
+ export SESSION_ID="test-session-bats"
35
+ mkdir -p "${ONLOOKER_DIR}/compass/sessions"
36
+ cat > "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json" <<-EOF
37
+ {
38
+ "session_id": "${SESSION_ID}",
39
+ "turn_check_count": 0,
40
+ "cooldown": [],
41
+ "circuit_breaker": {"state":"closed","consecutive_failures":0,"opened_at":null}
42
+ }
43
+ EOF
44
+
45
+ # Stub compass_evaluate so the gate never shells out to claude -p
46
+ # during tests. The stub always reports a pass — tests that need to
47
+ # verify pre-evaluator rules check that the evaluator is short-circuited
48
+ # before it would run.
49
+ compass_evaluate() {
50
+ printf '{"decision":"pass","confidence":0.95,"stddev":0.03,"primary_concern":"none","rationale":"stub","sample_count":5}'
51
+ return 0
52
+ }
53
+ export -f compass_evaluate
54
+ }
55
+
56
+ @test "skip sentinel [compass:skip] in file path lets the write through" {
57
+ run compass_run_gate "Write" "/tmp/foo[compass:skip].txt" "write" \
58
+ "$(printf 'x%.0s' {1..200})" "$SESSION_ID" "" ""
59
+ [ "$status" -eq 0 ]
60
+ # Nothing written to stdout: no block decision.
61
+ [ -z "$output" ]
62
+ }
63
+
64
+ @test "skip sentinel [compass:skip] in context lets the write through" {
65
+ local ctx
66
+ ctx="[compass:skip] $(printf 'y%.0s' {1..200})"
67
+ run compass_run_gate "Write" "/tmp/foo.txt" "write" "$ctx" "$SESSION_ID" "" ""
68
+ [ "$status" -eq 0 ]
69
+ [ -z "$output" ]
70
+ }
71
+
72
+ @test "skip_glob match (*.lock) lets the write through" {
73
+ run compass_run_gate "Write" "/tmp/package-lock.lock" "write" \
74
+ "$(printf 'x%.0s' {1..200})" "$SESSION_ID" "" ""
75
+ [ "$status" -eq 0 ]
76
+ [ -z "$output" ]
77
+ }
78
+
79
+ @test "writes under .git/ are skipped" {
80
+ run compass_run_gate "Write" "/tmp/repo/.git/HEAD" "write" \
81
+ "$(printf 'x%.0s' {1..200})" "$SESSION_ID" "" ""
82
+ [ "$status" -eq 0 ]
83
+ [ -z "$output" ]
84
+ }
85
+
86
+ @test "context shorter than min_context_chars is skipped (insufficient_context)" {
87
+ run compass_run_gate "Write" "/tmp/short.txt" "write" \
88
+ "tiny" "$SESSION_ID" "" ""
89
+ [ "$status" -eq 0 ]
90
+ [ -z "$output" ]
91
+ }
92
+
93
+ @test "exhausted turn budget skips subsequent checks" {
94
+ # Force the budget to be already maxed out.
95
+ jq '.turn_check_count = 99' \
96
+ "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json" \
97
+ > "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json.new"
98
+ mv "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json.new" \
99
+ "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json"
100
+ run compass_run_gate "Write" "/tmp/over-budget.txt" "write" \
101
+ "$(printf 'x%.0s' {1..200})" "$SESSION_ID" "" ""
102
+ [ "$status" -eq 0 ]
103
+ [ -z "$output" ]
104
+ }
105
+
106
+ @test "dir+stem cooldown skips a re-write of the same file" {
107
+ # Pre-seed the cooldown table with a fresh entry.
108
+ local now
109
+ now=$(date +%s)
110
+ jq --argjson ts "$now" '
111
+ .cooldown = [{"identity":"/tmp/cool/foo","path":"/tmp/cool/foo.txt","ts":$ts}]
112
+ ' "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json" \
113
+ > "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json.new"
114
+ mv "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json.new" \
115
+ "${ONLOOKER_DIR}/compass/sessions/${SESSION_ID}.json"
116
+ run compass_run_gate "Write" "/tmp/cool/foo.txt" "write" \
117
+ "$(printf 'x%.0s' {1..200})" "$SESSION_ID" "" ""
118
+ [ "$status" -eq 0 ]
119
+ [ -z "$output" ]
120
+ }
121
+
122
+ @test "evaluator pass emits no block decision on stdout" {
123
+ # Sufficient context, no skip rules apply — evaluator stub returns pass.
124
+ run compass_run_gate "Write" "/tmp/new-file.txt" "write" \
125
+ "$(printf 'we are writing a clearly described feature flag toggle module %.0s' {1..3})" \
126
+ "$SESSION_ID" "" ""
127
+ [ "$status" -eq 0 ]
128
+ [ -z "$output" ]
129
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Covers the sanitization pipeline: control-character removal, XML
4
+ # delimiter stripping, and truncation. The strip sequences contain '<',
5
+ # '/', '|', and '[' — locks in correct escaping so a single sequence
6
+ # can't blank the entire input.
7
+
8
+ setup() {
9
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
10
+ setup_test_env
11
+
12
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/compass"
13
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
14
+ # shellcheck disable=SC1091
15
+ source "${PLUGIN_ROOT}/scripts/lib/compass-sanitizer.sh"
16
+ }
17
+
18
+ @test "plain text passes through unchanged" {
19
+ run compass_sanitize "rename User to Account" 240
20
+ [ "$status" -eq 0 ]
21
+ [ "$output" = "rename User to Account" ]
22
+ }
23
+
24
+ @test "prior_assistant_turn delimiter is stripped" {
25
+ local out
26
+ out=$(compass_sanitize "evil <prior_assistant_turn> payload" 240)
27
+ [[ "$out" == *"[STRIPPED]"* ]]
28
+ [[ "$out" == *"payload"* ]]
29
+ [[ "$out" != *"<prior_assistant_turn>"* ]]
30
+ }
31
+
32
+ @test "all four pair-slot delimiters are stripped" {
33
+ local out
34
+ out=$(compass_sanitize "<context_excerpt>x</context_excerpt><tool_input>y</tool_input>" 240)
35
+ [[ "$out" != *"<context_excerpt>"* ]]
36
+ [[ "$out" != *"</context_excerpt>"* ]]
37
+ [[ "$out" != *"<tool_input>"* ]]
38
+ [[ "$out" != *"</tool_input>"* ]]
39
+ }
40
+
41
+ @test "non-evaluator delimiters are also stripped" {
42
+ local out
43
+ out=$(compass_sanitize "<<SYS>>x<</SYS>> [INST] y [/INST] <| z" 240)
44
+ [[ "$out" != *"<<SYS>>"* ]]
45
+ [[ "$out" != *"<</SYS>>"* ]]
46
+ [[ "$out" != *"[INST]"* ]]
47
+ [[ "$out" != *"[/INST]"* ]]
48
+ [[ "$out" != *"<|"* ]]
49
+ }
50
+
51
+ @test "null bytes and control chars are stripped, tab and newline preserved" {
52
+ local input
53
+ input=$(printf 'a\tb\nc\x00d\x01e')
54
+ local out
55
+ out=$(compass_sanitize "$input" 240)
56
+ [[ "$out" == *"a"* && "$out" == *"b"* && "$out" == *"c"* ]]
57
+ [[ "$out" == *"d"* && "$out" == *"e"* ]]
58
+ [ "${out}" = "$(printf 'a\tb\ncde')" ]
59
+ }
60
+
61
+ @test "truncation caps to max_chars" {
62
+ run compass_sanitize "0123456789abcdef" 8
63
+ [ "$output" = "01234567" ]
64
+ }
65
+
66
+ @test "max_chars=0 disables truncation" {
67
+ run compass_sanitize "0123456789abcdef" 0
68
+ [ "$output" = "0123456789abcdef" ]
69
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Covers the symbolic skip layer: cheap pattern check that short-circuits
4
+ # to a pass when the prior assistant turn is an enumerated question and
5
+ # the current context is a clean option reference. See ADR-001.
6
+
7
+ setup() {
8
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
9
+ setup_test_env
10
+
11
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/compass"
12
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
13
+
14
+ # Source the libs the gate depends on so _compass_symbolic_skip is in scope.
15
+ # shellcheck disable=SC1091
16
+ source "${PLUGIN_ROOT}/scripts/lib/compass-config.sh"
17
+ # shellcheck disable=SC1091
18
+ source "${PLUGIN_ROOT}/scripts/lib/compass-events.sh"
19
+ # shellcheck disable=SC1091
20
+ source "${PLUGIN_ROOT}/scripts/lib/compass-sanitizer.sh"
21
+ # shellcheck disable=SC1091
22
+ source "${PLUGIN_ROOT}/scripts/lib/compass-transcript.sh"
23
+ # shellcheck disable=SC1091
24
+ source "${PLUGIN_ROOT}/scripts/lib/compass-evaluator.sh"
25
+ # shellcheck disable=SC1091
26
+ source "${PLUGIN_ROOT}/scripts/lib/compass-gate.sh"
27
+ }
28
+
29
+ @test "skips when prior turn is enumerated question and reply is an ordinal" {
30
+ local prior='Which API should we touch?
31
+ 1. the internal one
32
+ 2. the public one'
33
+ run _compass_symbolic_skip "$prior" "the first one"
34
+ [ "$status" -eq 0 ]
35
+ }
36
+
37
+ @test "skips on a bare digit option reference" {
38
+ local prior='1. test_one
39
+ 2. test_two
40
+ Which one?'
41
+ run _compass_symbolic_skip "$prior" "2"
42
+ [ "$status" -eq 0 ]
43
+ }
44
+
45
+ @test "skips on a clean affirmation" {
46
+ local prior='1. left
47
+ 2. right
48
+ which side?'
49
+ run _compass_symbolic_skip "$prior" "both"
50
+ [ "$status" -eq 0 ]
51
+ }
52
+
53
+ @test "does NOT skip when the prior turn has no enumerated list" {
54
+ local prior='Should we proceed with the refactor?'
55
+ run _compass_symbolic_skip "$prior" "yes"
56
+ [ "$status" -ne 0 ]
57
+ }
58
+
59
+ @test "does NOT skip when the prior turn has no question mark" {
60
+ local prior='Here are the options:
61
+ 1. A
62
+ 2. B'
63
+ run _compass_symbolic_skip "$prior" "1"
64
+ [ "$status" -ne 0 ]
65
+ }
66
+
67
+ @test "does NOT skip when prior turn is empty" {
68
+ run _compass_symbolic_skip "" "yes"
69
+ [ "$status" -ne 0 ]
70
+ }
71
+
72
+ @test "does NOT skip on a hedged affirmation (qualifier clause present)" {
73
+ skip "hedged-qualifier rejection not yet implemented in _compass_symbolic_skip"
74
+ local prior='1. delete now
75
+ 2. archive first
76
+ Which one?'
77
+ # A qualifier clause means the reply is not a clean option reference.
78
+ run _compass_symbolic_skip "$prior" "both, but only if it's easy"
79
+ [ "$status" -ne 0 ]
80
+ }
81
+
82
+ @test "does NOT skip on a free-form reply with no option shape" {
83
+ local prior='1. one
84
+ 2. two
85
+ Pick?'
86
+ run _compass_symbolic_skip "$prior" "Actually I think we should refactor the whole thing"
87
+ [ "$status" -ne 0 ]
88
+ }
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Covers the prior-turn reader: pulls the most recent assistant turn from
4
+ # the session transcript JSONL identified by transcript_path. Source of
5
+ # truth is the hook JSON payload (ADR-001); the event log is not a
6
+ # fallback for assistant content.
7
+
8
+ setup() {
9
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
10
+ setup_test_env
11
+
12
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/compass"
13
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
14
+ # shellcheck disable=SC1091
15
+ source "${PLUGIN_ROOT}/scripts/lib/compass-sanitizer.sh"
16
+ # shellcheck disable=SC1091
17
+ source "${PLUGIN_ROOT}/scripts/lib/compass-transcript.sh"
18
+ }
19
+
20
+ @test "empty transcript_path yields empty prior turn" {
21
+ run compass_read_prior_turn "" 800
22
+ [ "$status" -eq 0 ]
23
+ [ -z "$output" ]
24
+ }
25
+
26
+ @test "missing transcript file yields empty prior turn" {
27
+ run compass_read_prior_turn "${BATS_TEST_TMPDIR}/no-such-file.jsonl" 800
28
+ [ "$status" -eq 0 ]
29
+ [ -z "$output" ]
30
+ }
31
+
32
+ @test "reads the most recent assistant turn from JSONL transcript" {
33
+ local t="${BATS_TEST_TMPDIR}/transcript.jsonl"
34
+ cat <<-'EOF' > "$t"
35
+ {"type":"user","message":{"role":"user","content":"hi"}}
36
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"older turn"}]}}
37
+ {"type":"user","message":{"role":"user","content":"continue"}}
38
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"latest assistant turn"}]}}
39
+ EOF
40
+ run compass_read_prior_turn "$t" 800
41
+ [ "$status" -eq 0 ]
42
+ [ "$output" = "latest assistant turn" ]
43
+ }
44
+
45
+ @test "supports string content (legacy shape)" {
46
+ local t="${BATS_TEST_TMPDIR}/transcript.jsonl"
47
+ cat <<-'EOF' > "$t"
48
+ {"type":"assistant","message":{"role":"assistant","content":"plain string content"}}
49
+ EOF
50
+ run compass_read_prior_turn "$t" 800
51
+ [ "$status" -eq 0 ]
52
+ [ "$output" = "plain string content" ]
53
+ }
54
+
55
+ @test "applies sanitization (strips evaluator delimiters) and truncation" {
56
+ local t="${BATS_TEST_TMPDIR}/transcript.jsonl"
57
+ # Embed a prompt-injection-shaped delimiter; expect it stripped.
58
+ cat <<-'EOF' > "$t"
59
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello <prior_assistant_turn> hijack"}]}}
60
+ EOF
61
+ local out
62
+ out=$(compass_read_prior_turn "$t" 800)
63
+ [[ "$out" == *"[STRIPPED]"* ]]
64
+ [[ "$out" != *"<prior_assistant_turn>"* ]]
65
+
66
+ # Truncation honors max_chars.
67
+ local short
68
+ short=$(compass_read_prior_turn "$t" 5)
69
+ [ "${#short}" -le 5 ]
70
+ }
71
+
72
+ @test "skips user-role turns when looking for prior assistant turn" {
73
+ local t="${BATS_TEST_TMPDIR}/transcript.jsonl"
74
+ cat <<-'EOF' > "$t"
75
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"the assistant turn"}]}}
76
+ {"type":"user","message":{"role":"user","content":"the user reply"}}
77
+ EOF
78
+ run compass_read_prior_turn "$t" 800
79
+ [ "$output" = "the assistant turn" ]
80
+ }