@onlooker-community/ecosystem 0.28.1 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -3
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +2 -0
- package/README.md +114 -13
- package/docs/plugin-catalog.md +125 -0
- package/package.json +3 -3
- package/plugins/compass/.claude-plugin/plugin.json +1 -1
- package/plugins/compass/CHANGELOG.md +7 -0
- package/plugins/compass/README.md +1 -3
- package/plugins/compass/config.json +1 -2
- package/plugins/compass/docs/design.md +1 -2
- package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
- package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +8 -1
- package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
- package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
- package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
- package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
- package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
- package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
- package/plugins/inspector/.claude-plugin/plugin.json +14 -0
- package/plugins/inspector/CHANGELOG.md +8 -0
- package/plugins/inspector/README.md +155 -0
- package/plugins/inspector/config.json +25 -0
- package/plugins/inspector/docs/design.md +286 -0
- package/plugins/inspector/hooks/hooks.json +33 -0
- package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
- package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
- package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
- package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
- package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
- package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
- package/release-please-config.json +17 -1
- package/test/bats/archivist-project-key.bats +79 -0
- package/test/bats/archivist-storage.bats +79 -0
- package/test/bats/compact-tracker.bats +125 -0
- package/test/bats/compass-config.bats +65 -0
- package/test/bats/compass-gate.bats +129 -0
- package/test/bats/compass-sanitizer.bats +69 -0
- package/test/bats/compass-symbolic-skip.bats +88 -0
- package/test/bats/compass-transcript.bats +80 -0
- package/test/bats/inspector-config.bats +118 -0
- package/test/bats/inspector-events.bats +156 -0
- package/test/bats/inspector-post-write-hook.bats +164 -0
- package/test/bats/inspector-project-key.bats +68 -0
- package/test/bats/inspector-ulid.bats +34 -0
- package/test/bats/librarian-session-end.bats +8 -1
- package/test/bats/onlooker-schema.bats +111 -0
- package/test/bats/prompt-rules.bats +98 -0
- package/test/bats/session-tracker.bats +260 -0
- package/test/bats/skill-usage-tracker.bats +63 -0
- package/test/bats/task-tracker.bats +102 -0
- package/test/bats/turn-tracker.bats +180 -0
- package/test/bats/validate-path.bats +125 -0
- package/test/bats/worktree-tracker.bats +167 -0
|
@@ -73,3 +73,82 @@ setup() {
|
|
|
73
73
|
[ -n "$kb" ]
|
|
74
74
|
[ "$ka" != "$kb" ]
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
@test "archivist_project_remote_url prints origin url when set" {
|
|
78
|
+
local d="${BATS_TEST_TMPDIR}/remote-set"
|
|
79
|
+
mkdir -p "$d"
|
|
80
|
+
git -C "$d" init -q
|
|
81
|
+
git -C "$d" remote add origin https://example.com/x.git
|
|
82
|
+
|
|
83
|
+
run archivist_project_remote_url "$d"
|
|
84
|
+
[ "$status" -eq 0 ]
|
|
85
|
+
[ "$output" = "https://example.com/x.git" ]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@test "archivist_project_remote_url returns empty when repo has no remote" {
|
|
89
|
+
local d="${BATS_TEST_TMPDIR}/remote-none"
|
|
90
|
+
mkdir -p "$d"
|
|
91
|
+
git -C "$d" init -q
|
|
92
|
+
|
|
93
|
+
run archivist_project_remote_url "$d"
|
|
94
|
+
[ "$status" -eq 0 ]
|
|
95
|
+
[ -z "$output" ]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@test "archivist_project_remote_url returns empty for a non-git directory" {
|
|
99
|
+
local d="${BATS_TEST_TMPDIR}/remote-non-git"
|
|
100
|
+
mkdir -p "$d"
|
|
101
|
+
|
|
102
|
+
run archivist_project_remote_url "$d"
|
|
103
|
+
[ "$status" -eq 0 ]
|
|
104
|
+
[ -z "$output" ]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@test "archivist_project_remote_url returns empty for a nonexistent path" {
|
|
108
|
+
run archivist_project_remote_url "${BATS_TEST_TMPDIR}/does-not-exist"
|
|
109
|
+
[ "$status" -eq 0 ]
|
|
110
|
+
[ -z "$output" ]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@test "archivist_project_repo_root prints the repo toplevel realpath" {
|
|
114
|
+
local d="${BATS_TEST_TMPDIR}/root-repo"
|
|
115
|
+
mkdir -p "$d"
|
|
116
|
+
git -C "$d" init -q
|
|
117
|
+
|
|
118
|
+
# Physical path: matches the function's pwd -P resolution on platforms
|
|
119
|
+
# (e.g. macOS) where the temp dir is reached through a symlink.
|
|
120
|
+
local expected
|
|
121
|
+
expected="$(cd "$d" && pwd -P)"
|
|
122
|
+
|
|
123
|
+
run archivist_project_repo_root "$d"
|
|
124
|
+
[ "$status" -eq 0 ]
|
|
125
|
+
[ "$output" = "$expected" ]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@test "archivist_project_repo_root resolves toplevel from a subdirectory" {
|
|
129
|
+
local d="${BATS_TEST_TMPDIR}/root-repo-sub"
|
|
130
|
+
mkdir -p "$d/nested/deep"
|
|
131
|
+
git -C "$d" init -q
|
|
132
|
+
|
|
133
|
+
local expected
|
|
134
|
+
expected="$(cd "$d" && pwd -P)"
|
|
135
|
+
|
|
136
|
+
run archivist_project_repo_root "$d/nested/deep"
|
|
137
|
+
[ "$status" -eq 0 ]
|
|
138
|
+
[ "$output" = "$expected" ]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@test "archivist_project_repo_root returns empty for a non-git directory" {
|
|
142
|
+
local d="${BATS_TEST_TMPDIR}/root-non-git"
|
|
143
|
+
mkdir -p "$d"
|
|
144
|
+
|
|
145
|
+
run archivist_project_repo_root "$d"
|
|
146
|
+
[ "$status" -eq 0 ]
|
|
147
|
+
[ -z "$output" ]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@test "archivist_project_repo_root returns empty for a nonexistent path" {
|
|
151
|
+
run archivist_project_repo_root "${BATS_TEST_TMPDIR}/root-missing"
|
|
152
|
+
[ "$status" -eq 0 ]
|
|
153
|
+
[ -z "$output" ]
|
|
154
|
+
}
|
|
@@ -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
|
+
}
|