@onlooker-community/ecosystem 0.19.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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +26 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -2
  4. package/CHANGELOG.md +14 -0
  5. package/docs/memory-architecture.md +102 -0
  6. package/package.json +3 -3
  7. package/plugins/curator/.claude-plugin/plugin.json +14 -0
  8. package/plugins/curator/CHANGELOG.md +10 -0
  9. package/plugins/curator/README.md +55 -0
  10. package/plugins/curator/config.json +41 -0
  11. package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
  12. package/plugins/curator/docs/design.md +311 -0
  13. package/plugins/curator/hooks/hooks.json +15 -0
  14. package/plugins/curator/scripts/hooks/curator-session-start.sh +343 -0
  15. package/plugins/curator/scripts/lib/curator-checks.sh +155 -0
  16. package/plugins/curator/scripts/lib/curator-config.sh +67 -0
  17. package/plugins/curator/scripts/lib/curator-emit.sh +61 -0
  18. package/plugins/curator/scripts/lib/curator-memory-reader.sh +225 -0
  19. package/plugins/curator/scripts/lib/curator-project-key.sh +82 -0
  20. package/plugins/curator/scripts/lib/curator-storage.sh +176 -0
  21. package/plugins/curator/scripts/lib/curator-ulid.sh +43 -0
  22. package/plugins/historian/docs/adr/001-local-embeddings-only.md +96 -0
  23. package/plugins/historian/docs/design.md +317 -0
  24. package/plugins/librarian/.claude-plugin/plugin.json +14 -0
  25. package/plugins/librarian/CHANGELOG.md +10 -0
  26. package/plugins/librarian/README.md +51 -0
  27. package/plugins/librarian/config.json +52 -0
  28. package/plugins/librarian/docs/adr/001-propose-dont-auto-write.md +87 -0
  29. package/plugins/librarian/docs/design.md +301 -0
  30. package/plugins/librarian/hooks/hooks.json +26 -0
  31. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +312 -0
  32. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +103 -0
  33. package/plugins/librarian/scripts/lib/librarian-archivist-reader.sh +67 -0
  34. package/plugins/librarian/scripts/lib/librarian-classifier.sh +139 -0
  35. package/plugins/librarian/scripts/lib/librarian-config.sh +74 -0
  36. package/plugins/librarian/scripts/lib/librarian-durability.sh +77 -0
  37. package/plugins/librarian/scripts/lib/librarian-emit.sh +72 -0
  38. package/plugins/librarian/scripts/lib/librarian-project-key.sh +83 -0
  39. package/plugins/librarian/scripts/lib/librarian-storage.sh +222 -0
  40. package/plugins/librarian/scripts/lib/librarian-ulid.sh +50 -0
  41. package/release-please-config.json +32 -0
  42. package/test/bats/curator-session-start.bats +316 -0
  43. package/test/bats/librarian-session-end.bats +182 -0
  44. package/test/bats/librarian-session-start.bats +136 -0
@@ -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
+ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # Exercises the librarian SessionEnd scan pipeline end-to-end with a stub
4
+ # `claude` CLI. Verifies:
5
+ # - Disabled config: no proposals, no events.
6
+ # - Empty archivist dir: scan.started + scan.complete{outcome: empty}
7
+ # emitted, watermark advances.
8
+ # - Synthetic artifacts that pass durability filter and classifier:
9
+ # proposals land on disk with the expected provenance and scan events
10
+ # report the correct counts.
11
+ # - Durability-filtered artifacts (no marker phrase) emit
12
+ # candidate.dropped events.
13
+
14
+ setup() {
15
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
16
+ setup_test_env
17
+
18
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
19
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
20
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
21
+
22
+ # Stand up a fake project repo so project-key resolution succeeds.
23
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
24
+ mkdir -p "$PROJECT_REPO"
25
+ git -C "$PROJECT_REPO" init -q
26
+ git -C "$PROJECT_REPO" config user.email t@example.com
27
+ git -C "$PROJECT_REPO" config user.name "Test"
28
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/librarian-scan-test.git
29
+
30
+ # shellcheck disable=SC1091
31
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
32
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
33
+ [ -n "$PROJECT_KEY" ]
34
+
35
+ ARCHIVIST_DIR="${ONLOOKER_DIR}/archivist/${PROJECT_KEY}"
36
+ LIBRARIAN_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}"
37
+ ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
38
+
39
+ # Project-scoped settings.json that enables librarian.
40
+ mkdir -p "${PROJECT_REPO}/.claude"
41
+ printf '%s\n' '{"librarian":{"enabled":true}}' > "${PROJECT_REPO}/.claude/settings.json"
42
+
43
+ # Stub `claude` CLI on PATH. Returns a deterministic classifier response
44
+ # based on the artifact's summary contents.
45
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
46
+ mkdir -p "$STUB_BIN"
47
+ cat > "${STUB_BIN}/claude" <<'STUB'
48
+ #!/usr/bin/env bash
49
+ # Read the prompt from stdin and decide which classifier response to emit.
50
+ prompt=$(cat)
51
+ if [[ "$prompt" == *"prefer-functional-stub"* ]]; then
52
+ printf '%s' '{"type":"feedback","title":"Prefer functional patterns","body":"User prefers functional patterns over class-based.\n\n**Why:** Stated explicitly during code review.\n**How to apply:** Default to plain functions and composition.","confidence":0.84}'
53
+ elif [[ "$prompt" == *"compliance-stub"* ]]; then
54
+ printf '%s' '{"type":"project","title":"Auth rewrite is compliance driven","body":"Auth middleware rewrite is driven by legal/compliance requirements around session token storage.\n\n**Why:** Compliance ask, not tech debt cleanup.\n**How to apply:** Favor compliance posture over ergonomics when scoping.","confidence":0.91}'
55
+ elif [[ "$prompt" == *"low-conf-stub"* ]]; then
56
+ printf '%s' '{"type":"user","title":"User edits","body":"User edits files.","confidence":0.4}'
57
+ else
58
+ printf '%s' '{"type":null,"title":"","body":"","confidence":0.2}'
59
+ fi
60
+ STUB
61
+ chmod +x "${STUB_BIN}/claude"
62
+ export PATH="${STUB_BIN}:${PATH}"
63
+
64
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/librarian-session-end.sh"
65
+ }
66
+
67
+ # Helper: write an archivist artifact for the project.
68
+ _seed_artifact() {
69
+ local kind="$1" id="$2" summary="$3" detail="$4" created_at="${5:-2026-06-01T12:00:00Z}"
70
+ local dir="${ARCHIVIST_DIR}/${kind}"
71
+ mkdir -p "$dir"
72
+ jq -n \
73
+ --arg id "$id" --arg kind "${kind%s}" \
74
+ --arg project_key "$PROJECT_KEY" \
75
+ --arg summary "$summary" --arg detail "$detail" \
76
+ --arg created_at "$created_at" --arg session_id "sess-1" \
77
+ '{ id: $id, kind: $kind, project_key: $project_key, source: "local",
78
+ created_at: $created_at, updated_at: $created_at,
79
+ summary: $summary, detail: $detail, files: [], session_id: $session_id }' \
80
+ > "${dir}/${id}.json"
81
+ }
82
+
83
+ _hook_input() {
84
+ jq -cn --arg cwd "$PROJECT_REPO" --arg sid "sess-end-test" \
85
+ '{cwd: $cwd, session_id: $sid, hook_event_name: "SessionEnd"}'
86
+ }
87
+
88
+ @test "session-end is a no-op when librarian is disabled" {
89
+ rm -f "${PROJECT_REPO}/.claude/settings.json"
90
+ run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
91
+ [ "$status" -eq 0 ]
92
+ # No proposals written.
93
+ [ ! -d "${LIBRARIAN_DIR}/proposals" ] || [ -z "$(ls -A "${LIBRARIAN_DIR}/proposals" 2>/dev/null)" ]
94
+ # No events emitted.
95
+ [ ! -f "$ONLOOKER_EVENTS_LOG" ] || ! grep -q 'librarian' "$ONLOOKER_EVENTS_LOG"
96
+ }
97
+
98
+ @test "session-end emits empty scan when archivist has nothing" {
99
+ run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
100
+ [ "$status" -eq 0 ]
101
+
102
+ # scan.started fired with artifact_count_in_window = 0.
103
+ grep -q '"event_type":"librarian.scan.started"' "$ONLOOKER_EVENTS_LOG"
104
+ grep '"event_type":"librarian.scan.started"' "$ONLOOKER_EVENTS_LOG" \
105
+ | jq -e '.payload.artifact_count_in_window == 0' >/dev/null
106
+
107
+ # scan.complete fired with outcome=empty and zero counts.
108
+ grep -q '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG"
109
+ grep '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
110
+ | jq -e '.payload.outcome == "empty" and .payload.candidates_proposed == 0 and .payload.candidates_dropped == 0' >/dev/null
111
+
112
+ # Watermark advanced for next scan.
113
+ [ -f "${LIBRARIAN_DIR}/last_scan.json" ]
114
+ jq -e '.scanned_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T")' "${LIBRARIAN_DIR}/last_scan.json" >/dev/null
115
+ }
116
+
117
+ @test "session-end proposes promotion for marker-phrase + classifier success" {
118
+ # Seed two promotable artifacts and one filter-rejected one.
119
+ _seed_artifact "decisions" "01PROPOSEEFEEDBACK00000000" \
120
+ "User prefers functional patterns prefer-functional-stub" \
121
+ "User explicitly said: always prefer plain functions over classes when adding new code in the api layer."
122
+
123
+ _seed_artifact "decisions" "01PROPOSEEPROJECT000000000" \
124
+ "Compliance-driven auth rewrite compliance-stub" \
125
+ "The reason for the auth middleware rewrite is legal compliance, not tech debt; remember this when sizing scope."
126
+
127
+ _seed_artifact "open_questions" "01FILTERREJECTED000000000" \
128
+ "ad hoc question" \
129
+ "this short text contains no marker phrase and should be filtered out before the classifier runs"
130
+
131
+ run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
132
+ [ "$status" -eq 0 ]
133
+
134
+ # Two proposals on disk.
135
+ proposals=("${LIBRARIAN_DIR}/proposals"/*.json)
136
+ [ "${#proposals[@]}" -eq 2 ]
137
+
138
+ # Both carry provenance back to their source artifact.
139
+ for p in "${proposals[@]}"; do
140
+ jq -e '.status == "pending" and .conflict_state == "none"' "$p" >/dev/null
141
+ jq -e '.proposed.type | IN("user", "feedback", "project", "reference")' "$p" >/dev/null
142
+ jq -e '.proposed.classifier_confidence >= 0.6' "$p" >/dev/null
143
+ jq -e '(.source_artifact_ids | length) > 0' "$p" >/dev/null
144
+ done
145
+
146
+ # scan.started reported the right window size (2 marker matches + 1 filtered = 3).
147
+ grep '"event_type":"librarian.scan.started"' "$ONLOOKER_EVENTS_LOG" \
148
+ | jq -e '.payload.artifact_count_in_window == 3' >/dev/null
149
+
150
+ # candidate.proposed fired twice with correct types.
151
+ proposed_types=$(grep '"event_type":"librarian.candidate.proposed"' "$ONLOOKER_EVENTS_LOG" \
152
+ | jq -r '.payload.memory_type' | sort | paste -sd, -)
153
+ [ "$proposed_types" = "feedback,project" ]
154
+
155
+ # candidate.dropped fired for the marker-missing artifact.
156
+ grep '"event_type":"librarian.candidate.dropped"' "$ONLOOKER_EVENTS_LOG" \
157
+ | jq -e 'select(.payload.reason == "filter_marker_missing")' >/dev/null
158
+
159
+ # scan.complete with ok outcome and accurate counts.
160
+ scan_complete=$(grep '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG")
161
+ echo "$scan_complete" | jq -e '.payload.outcome == "ok" and .payload.candidates_proposed == 2 and .payload.candidates_dropped >= 1' >/dev/null
162
+ }
163
+
164
+ @test "session-end drops candidates below confidence floor" {
165
+ _seed_artifact "decisions" "01LOWCONFCANDIDATE0000000" \
166
+ "low-conf-stub trigger" \
167
+ "always prefer some thing because reasons that show a marker phrase but the stub returns low confidence"
168
+
169
+ run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
170
+ [ "$status" -eq 0 ]
171
+
172
+ # No proposal written.
173
+ [ ! -d "${LIBRARIAN_DIR}/proposals" ] || [ -z "$(ls -A "${LIBRARIAN_DIR}/proposals" 2>/dev/null)" ]
174
+
175
+ # candidate.dropped fired with low_confidence reason.
176
+ grep '"event_type":"librarian.candidate.dropped"' "$ONLOOKER_EVENTS_LOG" \
177
+ | jq -e 'select(.payload.reason == "low_confidence")' >/dev/null
178
+
179
+ # scan.complete reports empty outcome (zero proposals).
180
+ grep '"event_type":"librarian.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
181
+ | jq -e '.payload.outcome == "empty" and .payload.candidates_proposed == 0 and .payload.candidates_dropped >= 1' >/dev/null
182
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # Tests the librarian SessionStart surfacer. Verifies:
4
+ # - Disabled config: empty additionalContext, exit 0.
5
+ # - No git context: empty additionalContext, exit 0.
6
+ # - Empty proposal queue + skip_inject_when_zero=true: empty context.
7
+ # - Pending proposals: one-line pointer with the count and pluralization.
8
+ # - Overflow: counts above max_pending_for_inject render as "<cap>+".
9
+
10
+ setup() {
11
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
12
+ setup_test_env
13
+
14
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
15
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
16
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
17
+
18
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
19
+ mkdir -p "$PROJECT_REPO"
20
+ git -C "$PROJECT_REPO" init -q
21
+ git -C "$PROJECT_REPO" config user.email t@example.com
22
+ git -C "$PROJECT_REPO" config user.name "Test"
23
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/librarian-surfacer-test.git
24
+
25
+ # shellcheck disable=SC1091
26
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
27
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
28
+ [ -n "$PROJECT_KEY" ]
29
+ LIBRARIAN_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}"
30
+
31
+ mkdir -p "${PROJECT_REPO}/.claude"
32
+ printf '%s\n' '{"librarian":{"enabled":true}}' > "${PROJECT_REPO}/.claude/settings.json"
33
+
34
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/librarian-session-start.sh"
35
+ }
36
+
37
+ _input() {
38
+ jq -cn --arg cwd "$PROJECT_REPO" \
39
+ '{cwd: $cwd, source: "startup", session_id: "sess-start-test"}'
40
+ }
41
+
42
+ # Helper: drop a proposal file with the given status into the queue.
43
+ _seed_proposal() {
44
+ local id="$1" status="${2:-pending}"
45
+ mkdir -p "${LIBRARIAN_DIR}/proposals"
46
+ jq -n --arg id "$id" --arg status "$status" \
47
+ '{
48
+ id: $id,
49
+ created_at: "2026-06-01T00:00:00Z",
50
+ source_artifact_ids: [],
51
+ source_session_ids: [],
52
+ proposed: { type: "feedback", filename: ($id + ".md"),
53
+ title: "t", body: "b", classifier_confidence: 0.8 },
54
+ conflict_state: "none",
55
+ conflict_with: [],
56
+ status: $status
57
+ }' > "${LIBRARIAN_DIR}/proposals/${id}.json"
58
+ }
59
+
60
+ @test "surfacer emits empty context when librarian is disabled" {
61
+ rm -f "${PROJECT_REPO}/.claude/settings.json"
62
+ _seed_proposal "01PROPOSALA000000000000000"
63
+
64
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
65
+ [ "$status" -eq 0 ]
66
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext == ""' >/dev/null
67
+ echo "$output" | jq -e '.hookSpecificOutput.hookEventName == "SessionStart"' >/dev/null
68
+ }
69
+
70
+ @test "surfacer emits empty context when there is no git context" {
71
+ local non_git="${BATS_TEST_TMPDIR}/no-git"
72
+ mkdir -p "$non_git"
73
+ local input
74
+ input=$(jq -cn --arg cwd "$non_git" '{cwd: $cwd, source: "startup", session_id: "s"}')
75
+
76
+ run bash -c "printf '%s' '$input' | '$HOOK'"
77
+ [ "$status" -eq 0 ]
78
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext == ""' >/dev/null
79
+ }
80
+
81
+ @test "surfacer emits empty context when no proposals are pending" {
82
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
83
+ [ "$status" -eq 0 ]
84
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext == ""' >/dev/null
85
+ }
86
+
87
+ @test "surfacer surfaces one-line pointer when proposals exist (plural)" {
88
+ _seed_proposal "01PROPOSAL11111111111111A"
89
+ _seed_proposal "01PROPOSAL11111111111111B"
90
+ _seed_proposal "01PROPOSAL11111111111111C"
91
+
92
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
93
+ [ "$status" -eq 0 ]
94
+ local ctx
95
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
96
+ [[ "$ctx" == *"Librarian has 3 pending memory promotion proposals"* ]]
97
+ [[ "$ctx" == *"/librarian review"* ]]
98
+ }
99
+
100
+ @test "surfacer pluralizes singular vs plural correctly" {
101
+ _seed_proposal "01PROPOSALSINGULAR0000000"
102
+
103
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
104
+ [ "$status" -eq 0 ]
105
+ local ctx
106
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
107
+ [[ "$ctx" == *"Librarian has 1 pending memory promotion proposal"* ]]
108
+ [[ "$ctx" != *"proposals."* ]]
109
+ }
110
+
111
+ @test "surfacer ignores accepted/rejected proposals when counting pending" {
112
+ _seed_proposal "01PROPOSALACCEPTED000000" "accepted"
113
+ _seed_proposal "01PROPOSALREJECTED000000" "rejected"
114
+ _seed_proposal "01PROPOSALPENDING0000000" "pending"
115
+
116
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
117
+ [ "$status" -eq 0 ]
118
+ local ctx
119
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
120
+ [[ "$ctx" == *"1 pending memory promotion proposal"* ]]
121
+ }
122
+
123
+ @test "surfacer caps display at max_pending_for_inject + '+'" {
124
+ # Override max to 3 via a project settings overlay.
125
+ printf '%s\n' '{"librarian":{"enabled":true,"surfacer":{"max_pending_for_inject":3}}}' \
126
+ > "${PROJECT_REPO}/.claude/settings.json"
127
+ for i in A B C D E; do
128
+ _seed_proposal "01PROPOSALCAP$i$i$i$i$i$i$i$i$i$i$i$i"
129
+ done
130
+
131
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
132
+ [ "$status" -eq 0 ]
133
+ local ctx
134
+ ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
135
+ [[ "$ctx" == *"Librarian has 3+ pending memory promotion proposals"* ]]
136
+ }