@onlooker-community/ecosystem 0.20.0 → 0.21.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.
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env bash
2
+ # Storage layout helpers for Curator.
3
+ #
4
+ # Layout (under $ONLOOKER_DIR/curator/<project-key>/):
5
+ # manifest.json project metadata (remote_url, repo_root, last_seen_at)
6
+ # last_cheap_scan.json watermark: when cheap-tier last ran
7
+ # last_llm_sweep.json watermark: when LLM sweep last ran
8
+ # findings/<ulid>.json one finding per file (open, acknowledged, or resolved)
9
+
10
+ # ============================================================================
11
+ # Path helpers
12
+ # ============================================================================
13
+
14
+ curator_storage_root() {
15
+ local base="${ONLOOKER_DIR:-$HOME/.onlooker}"
16
+ printf '%s/curator' "$base"
17
+ }
18
+
19
+ curator_project_dir() {
20
+ local key="$1"
21
+ printf '%s/%s' "$(curator_storage_root)" "$key"
22
+ }
23
+
24
+ curator_findings_dir() {
25
+ local key="$1"
26
+ printf '%s/findings' "$(curator_project_dir "$key")"
27
+ }
28
+
29
+ curator_storage_init() {
30
+ local key="$1"
31
+ [[ -z "$key" ]] && return 1
32
+ local project_dir
33
+ project_dir=$(curator_project_dir "$key")
34
+ mkdir -p "$project_dir/findings" 2>/dev/null
35
+ }
36
+
37
+ curator_storage_write_manifest() {
38
+ local key="$1"
39
+ local remote_url="$2"
40
+ local repo_root="$3"
41
+ [[ -z "$key" ]] && return 1
42
+
43
+ curator_storage_init "$key" || return 1
44
+ local manifest_path now
45
+ manifest_path="$(curator_project_dir "$key")/manifest.json"
46
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
47
+
48
+ jq -n \
49
+ --arg key "$key" \
50
+ --arg remote "$remote_url" \
51
+ --arg root "$repo_root" \
52
+ --arg now "$now" \
53
+ '{
54
+ project_key: $key,
55
+ remote_url: (if $remote == "" then null else $remote end),
56
+ repo_root: (if $root == "" then null else $root end),
57
+ last_seen_at: $now
58
+ }' > "$manifest_path" 2>/dev/null
59
+ }
60
+
61
+ # ============================================================================
62
+ # Watermarks
63
+ # ============================================================================
64
+
65
+ curator_last_cheap_scan_path() {
66
+ printf '%s/last_cheap_scan.json' "$(curator_project_dir "$1")"
67
+ }
68
+
69
+ curator_last_llm_sweep_path() {
70
+ printf '%s/last_llm_sweep.json' "$(curator_project_dir "$1")"
71
+ }
72
+
73
+ curator_storage_read_watermark() {
74
+ local path="$1"
75
+ [[ -f "$path" ]] || return 0
76
+ jq -r '.scanned_at // empty' "$path" 2>/dev/null
77
+ }
78
+
79
+ curator_storage_write_watermark() {
80
+ local path="$1"
81
+ [[ -z "$path" ]] && return 1
82
+ mkdir -p "$(dirname "$path")" 2>/dev/null
83
+ local now
84
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
85
+ jq -n --arg t "$now" '{ scanned_at: $t }' > "$path" 2>/dev/null
86
+ }
87
+
88
+ # ============================================================================
89
+ # Findings
90
+ # ============================================================================
91
+
92
+ # Write a finding to disk, keyed by ULID. Dedup is by deduped_hash so a
93
+ # repeat scan that surfaces the same fact does not write a new finding.
94
+ #
95
+ # Usage: curator_storage_write_finding <key> <ulid> <json>
96
+ curator_storage_write_finding() {
97
+ local key="$1"
98
+ local id="$2"
99
+ local json="$3"
100
+ [[ -z "$key" || -z "$id" || -z "$json" ]] && return 1
101
+
102
+ curator_storage_init "$key" || return 1
103
+ local path
104
+ path="$(curator_findings_dir "$key")/${id}.json"
105
+ printf '%s\n' "$json" > "$path" 2>/dev/null && printf '%s' "$path"
106
+ }
107
+
108
+ # Load all findings for a project key as a JSON array.
109
+ curator_storage_load_findings() {
110
+ local key="$1"
111
+ [[ -z "$key" ]] && { echo '[]'; return 0; }
112
+ local dir
113
+ dir=$(curator_findings_dir "$key")
114
+ [[ -d "$dir" ]] || { echo '[]'; return 0; }
115
+
116
+ local file all='[]'
117
+ for file in "$dir"/*.json; do
118
+ [[ -f "$file" ]] || continue
119
+ local item
120
+ item=$(jq '.' "$file" 2>/dev/null) || continue
121
+ all=$(printf '%s' "$all" | jq --argjson item "$item" '. + [$item]')
122
+ done
123
+ printf '%s' "$all"
124
+ }
125
+
126
+ # Return 0 if a finding with the given dedup hash already exists (open).
127
+ curator_storage_has_finding_with_hash() {
128
+ local key="$1"
129
+ local hash="$2"
130
+ [[ -z "$key" || -z "$hash" ]] && return 1
131
+ local existing
132
+ existing=$(curator_storage_load_findings "$key")
133
+ printf '%s' "$existing" | jq -e --arg h "$hash" '
134
+ any(.[]; (.deduped_hash // "") == $h and (.status // "open") == "open")
135
+ ' >/dev/null 2>&1
136
+ }
137
+
138
+ curator_storage_count_open() {
139
+ local key="$1"
140
+ local all
141
+ all=$(curator_storage_load_findings "$key")
142
+ printf '%s' "$all" | jq '[.[] | select((.status // "open") == "open")] | length' 2>/dev/null
143
+ }
144
+
145
+ # Open-finding counts grouped by kind. Used by the surfacer to render a
146
+ # pointer like "2 path-broken, 1 date-decayed".
147
+ #
148
+ # jq's group_by groups CONSECUTIVE matches, so the array must be sorted
149
+ # by .kind first or the same kind can produce multiple groups (and the
150
+ # downstream summary double-counts).
151
+ curator_storage_open_counts_by_kind() {
152
+ local key="$1"
153
+ local all
154
+ all=$(curator_storage_load_findings "$key")
155
+ printf '%s' "$all" | jq -c '
156
+ [.[] | select((.status // "open") == "open")]
157
+ | sort_by(.kind)
158
+ | group_by(.kind)
159
+ | map({ kind: .[0].kind, count: length })
160
+ | sort_by(-.count)
161
+ '
162
+ }
163
+
164
+ # Hash a finding's identity-relevant fields. Two findings with the same
165
+ # kind + memory_file + matched_phrase (where applicable) share a hash.
166
+ # Plain shasum input — no expensive normalization needed.
167
+ curator_finding_hash() {
168
+ local raw="$1"
169
+ if command -v shasum >/dev/null 2>&1; then
170
+ printf '%s' "$raw" | shasum -a 256 2>/dev/null | cut -c1-16
171
+ elif command -v sha256sum >/dev/null 2>&1; then
172
+ printf '%s' "$raw" | sha256sum 2>/dev/null | cut -c1-16
173
+ else
174
+ return 1
175
+ fi
176
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for Curator finding IDs.
3
+ #
4
+ # Spec: https://github.com/ulid/spec — 48-bit timestamp + 80-bit randomness,
5
+ # lexicographically sortable, Crockford Base32. Monotonicity within a single
6
+ # millisecond is not required; findings are written infrequently.
7
+
8
+ _CURATOR_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
9
+
10
+ _curator_ulid_encode() {
11
+ local n="$1"
12
+ local len="$2"
13
+ local out=""
14
+ local i
15
+ for ((i = 0; i < len; i++)); do
16
+ out="${_CURATOR_ULID_ALPHABET:$((n % 32)):1}${out}"
17
+ n=$((n / 32))
18
+ done
19
+ printf '%s' "$out"
20
+ }
21
+
22
+ curator_ulid() {
23
+ local now_ms
24
+ if [[ "$(uname)" == "Darwin" ]]; then
25
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
26
+ || now_ms=$(($(date +%s) * 1000))
27
+ else
28
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
29
+ fi
30
+
31
+ local rand_hi rand_lo
32
+ rand_hi=$((RANDOM * 32768 + RANDOM))
33
+ rand_lo=$((RANDOM * 32768 + RANDOM))
34
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
35
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
36
+
37
+ local ts_part hi_part lo_part
38
+ ts_part=$(_curator_ulid_encode "$now_ms" 10)
39
+ hi_part=$(_curator_ulid_encode "$rand_hi" 8)
40
+ lo_part=$(_curator_ulid_encode "$rand_lo" 8)
41
+
42
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
43
+ }
@@ -174,6 +174,22 @@
174
174
  "jsonpath": "$.version"
175
175
  }
176
176
  ]
177
+ },
178
+ "plugins/curator": {
179
+ "changelog-path": "CHANGELOG.md",
180
+ "release-type": "simple",
181
+ "bump-minor-pre-major": true,
182
+ "bump-patch-for-minor-pre-major": false,
183
+ "component": "curator",
184
+ "draft": false,
185
+ "prerelease": false,
186
+ "extra-files": [
187
+ {
188
+ "type": "json",
189
+ "path": ".claude-plugin/plugin.json",
190
+ "jsonpath": "$.version"
191
+ }
192
+ ]
177
193
  }
178
194
  },
179
195
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # Exercises the curator SessionStart hook end-to-end against a synthetic
4
+ # typed memory store. Verifies the four cheap-tier finding kinds, dedup
5
+ # on repeated scans, and the SessionStart surfacer's pointer rendering.
6
+
7
+ setup() {
8
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
9
+ setup_test_env
10
+
11
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/curator"
12
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
13
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
14
+
15
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
16
+ mkdir -p "$PROJECT_REPO/scripts"
17
+ git -C "$PROJECT_REPO" init -q
18
+ git -C "$PROJECT_REPO" config user.email t@example.com
19
+ git -C "$PROJECT_REPO" config user.name "Test"
20
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/curator-test.git
21
+
22
+ # Seed a real file that the path-broken check should NOT flag.
23
+ printf 'live\n' > "${PROJECT_REPO}/scripts/live.py"
24
+
25
+ # shellcheck disable=SC1091
26
+ source "${PLUGIN_ROOT}/scripts/lib/curator-project-key.sh"
27
+ PROJECT_KEY=$(curator_project_key "$PROJECT_REPO")
28
+ [ -n "$PROJECT_KEY" ]
29
+
30
+ CURATOR_DIR="${ONLOOKER_DIR}/curator/${PROJECT_KEY}"
31
+ ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
32
+
33
+ # Memory store at a predictable path. Bypass the ${CLAUDE_PROJECT_ENCODED}
34
+ # template by overriding memory_store_path to an absolute path in the
35
+ # project settings file.
36
+ MEM_DIR="${TEST_HOME}/.claude/projects/test-project/memory"
37
+ mkdir -p "$MEM_DIR" "${PROJECT_REPO}/.claude"
38
+ jq -n --arg path "$MEM_DIR" \
39
+ '{
40
+ curator: {
41
+ enabled: true,
42
+ memory_store_path: $path,
43
+ date_check: { date_grace_period_days: 7 }
44
+ }
45
+ }' > "${PROJECT_REPO}/.claude/settings.json"
46
+
47
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/curator-session-start.sh"
48
+ }
49
+
50
+ _input() {
51
+ jq -cn --arg cwd "$PROJECT_REPO" --arg sid "sess-curator-test" \
52
+ '{cwd: $cwd, source: "startup", session_id: $sid}'
53
+ }
54
+
55
+ _seed_memory() {
56
+ local name="$1" type="$2" body="$3"
57
+ local file="${MEM_DIR}/${name}"
58
+ printf -- '---\nname: %s\ndescription: test\ntype: %s\n---\n\n%s\n' \
59
+ "$name" "$type" "$body" > "$file"
60
+ }
61
+
62
+ _write_index() {
63
+ local entries="$1" # newline-separated `- [Title](file.md) — hook` lines
64
+ printf '%b\n' "$entries" > "${MEM_DIR}/MEMORY.md"
65
+ }
66
+
67
+ @test "session-start no-ops when curator is disabled" {
68
+ rm -f "${PROJECT_REPO}/.claude/settings.json"
69
+ _seed_memory "feedback_stale.md" "feedback" "Decayed 2025-01-01 reference."
70
+
71
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
72
+ [ "$status" -eq 0 ]
73
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext == ""' >/dev/null
74
+ [ ! -f "$ONLOOKER_EVENTS_LOG" ] || ! grep -q 'curator' "$ONLOOKER_EVENTS_LOG"
75
+ }
76
+
77
+ @test "session-start emits scan events with empty memory store" {
78
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
79
+ [ "$status" -eq 0 ]
80
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext == ""' >/dev/null
81
+
82
+ grep -q '"event_type":"curator.scan.started"' "$ONLOOKER_EVENTS_LOG"
83
+ grep '"event_type":"curator.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
84
+ | jq -e '.payload.findings_new == 0 and .payload.outcome == "ok"' >/dev/null
85
+ }
86
+
87
+ @test "session-start flags a date past the grace period" {
88
+ _seed_memory "project_freeze.md" "project" \
89
+ "Merge freeze begins 2026-03-05 for mobile release cut."
90
+ _write_index '- [Freeze](project_freeze.md) — merge freeze date'
91
+
92
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
93
+ [ "$status" -eq 0 ]
94
+
95
+ grep '"event_type":"curator.finding.date_decayed"' "$ONLOOKER_EVENTS_LOG" \
96
+ | jq -e '.payload.memory_file == "project_freeze.md" and
97
+ .payload.matched_phrase == "2026-03-05" and
98
+ .payload.days_past > 7' >/dev/null
99
+
100
+ # Finding persisted on disk.
101
+ ls "${CURATOR_DIR}/findings"/*.json | grep -q '\.json$'
102
+
103
+ # Surfacer rendered the pointer.
104
+ local ctx
105
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
106
+ [[ "$ctx" == *"Curator: 1 open finding"* ]]
107
+ [[ "$ctx" == *"date-decayed"* ]]
108
+ [[ "$ctx" == *"/curator review"* ]]
109
+ }
110
+
111
+ @test "session-start flags a broken path reference" {
112
+ _seed_memory "reference_legacy.md" "reference" \
113
+ "See scripts/legacy_ingest.py for the old pipeline shape."
114
+ _write_index '- [Legacy](reference_legacy.md) — old pipeline'
115
+
116
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
117
+ [ "$status" -eq 0 ]
118
+
119
+ grep '"event_type":"curator.finding.path_broken"' "$ONLOOKER_EVENTS_LOG" \
120
+ | jq -e '.payload.memory_file == "reference_legacy.md" and
121
+ .payload.broken_path == "scripts/legacy_ingest.py"' >/dev/null
122
+
123
+ # Live file (scripts/live.py) does NOT produce a finding.
124
+ ! grep '"event_type":"curator.finding.path_broken"' "$ONLOOKER_EVENTS_LOG" \
125
+ | grep -q 'live.py' || false
126
+ }
127
+
128
+ @test "session-start flags MEMORY.md pointing at a missing file" {
129
+ _write_index '- [Ghost](feedback_ghost.md) — never existed'
130
+
131
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
132
+ [ "$status" -eq 0 ]
133
+
134
+ grep '"event_type":"curator.finding.broken_index"' "$ONLOOKER_EVENTS_LOG" \
135
+ | jq -e '.payload.referenced_file == "feedback_ghost.md"' >/dev/null
136
+ }
137
+
138
+ @test "session-start flags orphaned memory file" {
139
+ # File on disk, no MEMORY.md reference.
140
+ _seed_memory "user_orphan.md" "user" "Some orphaned context."
141
+
142
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
143
+ [ "$status" -eq 0 ]
144
+
145
+ grep '"event_type":"curator.finding.orphaned_memory"' "$ONLOOKER_EVENTS_LOG" \
146
+ | jq -e '.payload.memory_file == "user_orphan.md"' >/dev/null
147
+ }
148
+
149
+ @test "session-start dedupes findings on repeated scans" {
150
+ _seed_memory "project_freeze.md" "project" "Date 2025-01-01 in the past."
151
+ _write_index '- [Freeze](project_freeze.md) — old date'
152
+
153
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
154
+ [ "$status" -eq 0 ]
155
+ local first_count
156
+ first_count=$(ls "${CURATOR_DIR}/findings"/*.json 2>/dev/null | wc -l | tr -d ' ')
157
+ [ "$first_count" -ge 1 ]
158
+
159
+ # Second scan — the same finding should not produce a new file.
160
+ rm -f "$ONLOOKER_EVENTS_LOG"
161
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
162
+ [ "$status" -eq 0 ]
163
+ local second_count
164
+ second_count=$(ls "${CURATOR_DIR}/findings"/*.json 2>/dev/null | wc -l | tr -d ' ')
165
+ [ "$second_count" -eq "$first_count" ]
166
+
167
+ # scan.complete reports findings_new == 0 on the dedup'd pass.
168
+ grep '"event_type":"curator.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
169
+ | jq -e '.payload.findings_new == 0' >/dev/null
170
+ }
171
+
172
+ @test "surfacer pluralizes finding count" {
173
+ _seed_memory "project_a.md" "project" "Date 2025-01-01"
174
+ _seed_memory "project_b.md" "project" "Date 2024-06-30"
175
+ _write_index "$(printf '%s\n%s' \
176
+ '- [A](project_a.md) — a' \
177
+ '- [B](project_b.md) — b')"
178
+
179
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
180
+ [ "$status" -eq 0 ]
181
+ local ctx
182
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
183
+ [[ "$ctx" == *"2 open findings"* ]]
184
+ }
185
+
186
+ @test "MEMORY.md path-traversal entries are recorded as broken_index, never read" {
187
+ # Seed a sentinel file outside the memory dir that the parser must
188
+ # NEVER touch. The escape attempt below points at a path that would
189
+ # resolve to this file if filename sanitization were missing.
190
+ local outside_dir="${TEST_HOME}/.claude/projects"
191
+ local sentinel="${outside_dir}/sentinel.txt"
192
+ printf 'untouched\n' > "$sentinel"
193
+
194
+ # Linux's stat uses `-c FORMAT` and ignores `-f`; macOS uses `-f FORMAT`
195
+ # and does not accept `-c`. Try Linux first (CI runs on Ubuntu); fall
196
+ # back to BSD/macOS form for local runs.
197
+ _file_mtime() {
198
+ stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null
199
+ }
200
+ local sentinel_mtime_before
201
+ sentinel_mtime_before=$(_file_mtime "$sentinel")
202
+ [ -n "$sentinel_mtime_before" ]
203
+
204
+ # MEMORY.md tries to escape with `../sentinel.txt` and an absolute path.
205
+ _write_index "$(printf '%s\n%s\n%s' \
206
+ '- [Escape](../sentinel.txt) — traversal attempt' \
207
+ '- [Abs](/tmp/curator-abs-attempt.md) — absolute path attempt' \
208
+ '- [Normal](real.md) — clean reference')"
209
+ _seed_memory "real.md" "project" "Normal body."
210
+
211
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
212
+ [ "$status" -eq 0 ]
213
+
214
+ # Both unsafe entries surface as broken_index findings.
215
+ grep '"event_type":"curator.finding.broken_index"' "$ONLOOKER_EVENTS_LOG" \
216
+ | jq -e 'select(.payload.referenced_file == "../sentinel.txt")' >/dev/null
217
+ grep '"event_type":"curator.finding.broken_index"' "$ONLOOKER_EVENTS_LOG" \
218
+ | jq -e 'select(.payload.referenced_file == "/tmp/curator-abs-attempt.md")' >/dev/null
219
+
220
+ # The clean reference resolves as expected (no broken_index for it).
221
+ ! grep '"event_type":"curator.finding.broken_index"' "$ONLOOKER_EVENTS_LOG" \
222
+ | jq -e 'select(.payload.referenced_file == "real.md")' >/dev/null
223
+
224
+ # The sentinel file wasn't read (mtime unchanged) and content intact.
225
+ local sentinel_mtime_after
226
+ sentinel_mtime_after=$(_file_mtime "$sentinel")
227
+ [ "$sentinel_mtime_before" = "$sentinel_mtime_after" ]
228
+ grep -q 'untouched' "$sentinel"
229
+ }
230
+
231
+ @test "path_broken does not fire on URLs or absolute paths in memory bodies" {
232
+ _seed_memory "reference_urls.md" "reference" "$(cat <<'BODY'
233
+ See https://example.com/foo.py for upstream context.
234
+ Also /usr/bin/python3.11 is the system python on macOS.
235
+ But scripts/legacy_ingest.py is the broken in-repo path.
236
+ BODY
237
+ )"
238
+ _write_index '- [Refs](reference_urls.md) — mixed refs'
239
+
240
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
241
+ [ "$status" -eq 0 ]
242
+
243
+ # The in-repo broken path still fires.
244
+ grep '"event_type":"curator.finding.path_broken"' "$ONLOOKER_EVENTS_LOG" \
245
+ | jq -e 'select(.payload.broken_path == "scripts/legacy_ingest.py")' >/dev/null
246
+
247
+ # Neither the URL host nor the absolute path produce a finding.
248
+ ! grep '"event_type":"curator.finding.path_broken"' "$ONLOOKER_EVENTS_LOG" \
249
+ | jq -e 'select(.payload.broken_path | contains("example.com"))' >/dev/null
250
+ ! grep '"event_type":"curator.finding.path_broken"' "$ONLOOKER_EVENTS_LOG" \
251
+ | jq -e 'select(.payload.broken_path | contains("python3.11"))' >/dev/null
252
+ }
253
+
254
+ @test "surfacer counts multiple same-kind findings correctly" {
255
+ # Two date_decayed findings: with the group_by-without-sort bug, the
256
+ # summary would render as "1 date-decayed, 1 date-decayed" because
257
+ # ULID-ordered findings can interleave kinds. The sort_by(.kind) fix
258
+ # makes the summary aggregate correctly to "2 date-decayed".
259
+ _seed_memory "project_a.md" "project" "Stale date 2025-01-01"
260
+ _seed_memory "project_b.md" "project" "Older date 2024-06-30"
261
+ _write_index "$(printf '%s\n%s' \
262
+ '- [A](project_a.md) — a' \
263
+ '- [B](project_b.md) — b')"
264
+
265
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
266
+ [ "$status" -eq 0 ]
267
+ local ctx
268
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
269
+ [[ "$ctx" == *"2 date-decayed"* ]]
270
+ # The buggy output would have looked like "1 date-decayed, 1 date-decayed".
271
+ [[ "$ctx" != *"1 date-decayed, 1 date-decayed"* ]]
272
+ }
273
+
274
+ @test "cheap_checks.enabled=false skips the scan and emits skip_reason disabled" {
275
+ printf '%s\n' \
276
+ '{"curator":{"enabled":true,"memory_store_path":"'"$MEM_DIR"'","cheap_checks":{"enabled":false}}}' \
277
+ > "${PROJECT_REPO}/.claude/settings.json"
278
+
279
+ _seed_memory "project_stale.md" "project" "Decayed 2025-01-01"
280
+ _write_index '- [S](project_stale.md) — s'
281
+
282
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
283
+ [ "$status" -eq 0 ]
284
+
285
+ # scan.complete uses skip_reason: disabled.
286
+ grep '"event_type":"curator.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
287
+ | jq -e '.payload.outcome == "skipped" and .payload.skip_reason == "disabled"' >/dev/null
288
+
289
+ # No findings were written, even though the date would have matched.
290
+ [ ! -d "${CURATOR_DIR}/findings" ] || [ -z "$(ls -A "${CURATOR_DIR}/findings" 2>/dev/null)" ]
291
+
292
+ # No per-finding events emitted.
293
+ ! grep -q '"event_type":"curator.finding.date_decayed"' "$ONLOOKER_EVENTS_LOG"
294
+ }
295
+
296
+ @test "surfacer truncates context past max_pointer_chars" {
297
+ printf '%s\n' \
298
+ '{"curator":{"enabled":true,"memory_store_path":"'"$MEM_DIR"'","surfacer":{"max_pointer_chars":40}}}' \
299
+ > "${PROJECT_REPO}/.claude/settings.json"
300
+
301
+ _seed_memory "project_a.md" "project" "Date 2025-01-01"
302
+ _seed_memory "project_b.md" "project" "Date 2024-06-30"
303
+ _write_index "$(printf '%s\n%s' \
304
+ '- [A](project_a.md) — a' \
305
+ '- [B](project_b.md) — b')"
306
+
307
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
308
+ [ "$status" -eq 0 ]
309
+ local ctx ctx_len
310
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
311
+ # Grapheme-aware length so the trailing "…" doesn't confuse a
312
+ # byte-counting bash check.
313
+ ctx_len=$(python3 -c 'import sys; print(len(sys.argv[1]))' "$ctx")
314
+ [ "$ctx_len" -le 40 ]
315
+ [[ "$ctx" == *"…"* ]]
316
+ }