@onlooker-community/ecosystem 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +3 -2
- package/CHANGELOG.md +7 -0
- package/docs/memory-architecture.md +102 -0
- package/package.json +3 -3
- package/plugins/curator/docs/adr/001-staleness-tiers.md +100 -0
- package/plugins/curator/docs/design.md +311 -0
- package/plugins/historian/docs/adr/001-local-embeddings-only.md +96 -0
- package/plugins/historian/docs/design.md +317 -0
- package/plugins/librarian/.claude-plugin/plugin.json +14 -0
- package/plugins/librarian/CHANGELOG.md +10 -0
- package/plugins/librarian/README.md +51 -0
- package/plugins/librarian/config.json +52 -0
- package/plugins/librarian/docs/adr/001-propose-dont-auto-write.md +87 -0
- package/plugins/librarian/docs/design.md +301 -0
- package/plugins/librarian/hooks/hooks.json +26 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +312 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +103 -0
- package/plugins/librarian/scripts/lib/librarian-archivist-reader.sh +67 -0
- package/plugins/librarian/scripts/lib/librarian-classifier.sh +139 -0
- package/plugins/librarian/scripts/lib/librarian-config.sh +74 -0
- package/plugins/librarian/scripts/lib/librarian-durability.sh +77 -0
- package/plugins/librarian/scripts/lib/librarian-emit.sh +72 -0
- package/plugins/librarian/scripts/lib/librarian-project-key.sh +83 -0
- package/plugins/librarian/scripts/lib/librarian-storage.sh +222 -0
- package/plugins/librarian/scripts/lib/librarian-ulid.sh +50 -0
- package/release-please-config.json +16 -0
- package/test/bats/librarian-session-end.bats +182 -0
- package/test/bats/librarian-session-start.bats +136 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Storage layout helpers for Librarian.
|
|
3
|
+
#
|
|
4
|
+
# Layout (under $ONLOOKER_DIR/librarian/<project-key>/):
|
|
5
|
+
# manifest.json project metadata: remote_url, repo_root, last_scan_at
|
|
6
|
+
# last_scan.json { "scanned_at": ISO-8601 } — watermark for incremental scans
|
|
7
|
+
# proposals/<ulid>.json one pending/resolved proposal per file
|
|
8
|
+
# tombstones/<body_hash>.json one tombstone per rejected/pruned body
|
|
9
|
+
#
|
|
10
|
+
# All paths inside proposals are stored relative to the repo root where they
|
|
11
|
+
# originated. The typed memory store the user maintains lives elsewhere
|
|
12
|
+
# (~/.claude/projects/<encoded>/memory/) and is resolved at promotion time.
|
|
13
|
+
|
|
14
|
+
# ============================================================================
|
|
15
|
+
# Path helpers
|
|
16
|
+
# ============================================================================
|
|
17
|
+
|
|
18
|
+
librarian_storage_root() {
|
|
19
|
+
local base="${ONLOOKER_DIR:-$HOME/.onlooker}"
|
|
20
|
+
printf '%s/librarian' "$base"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
librarian_project_dir() {
|
|
24
|
+
local key="$1"
|
|
25
|
+
printf '%s/%s' "$(librarian_storage_root)" "$key"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
librarian_proposals_dir() {
|
|
29
|
+
local key="$1"
|
|
30
|
+
printf '%s/proposals' "$(librarian_project_dir "$key")"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
librarian_tombstones_dir() {
|
|
34
|
+
local key="$1"
|
|
35
|
+
printf '%s/tombstones' "$(librarian_project_dir "$key")"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
librarian_storage_init() {
|
|
39
|
+
local key="$1"
|
|
40
|
+
[[ -z "$key" ]] && return 1
|
|
41
|
+
local project_dir
|
|
42
|
+
project_dir=$(librarian_project_dir "$key")
|
|
43
|
+
mkdir -p \
|
|
44
|
+
"$project_dir/proposals" \
|
|
45
|
+
"$project_dir/tombstones" 2>/dev/null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# ============================================================================
|
|
49
|
+
# Manifest
|
|
50
|
+
# ============================================================================
|
|
51
|
+
|
|
52
|
+
# Usage: librarian_storage_write_manifest <key> <remote_url> <repo_root>
|
|
53
|
+
librarian_storage_write_manifest() {
|
|
54
|
+
local key="$1"
|
|
55
|
+
local remote_url="$2"
|
|
56
|
+
local repo_root="$3"
|
|
57
|
+
[[ -z "$key" ]] && return 1
|
|
58
|
+
|
|
59
|
+
librarian_storage_init "$key" || return 1
|
|
60
|
+
|
|
61
|
+
local manifest_path
|
|
62
|
+
manifest_path="$(librarian_project_dir "$key")/manifest.json"
|
|
63
|
+
local now
|
|
64
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
65
|
+
|
|
66
|
+
jq -n \
|
|
67
|
+
--arg key "$key" \
|
|
68
|
+
--arg remote "$remote_url" \
|
|
69
|
+
--arg root "$repo_root" \
|
|
70
|
+
--arg now "$now" \
|
|
71
|
+
'{
|
|
72
|
+
project_key: $key,
|
|
73
|
+
remote_url: (if $remote == "" then null else $remote end),
|
|
74
|
+
repo_root: (if $root == "" then null else $root end),
|
|
75
|
+
last_seen_at: $now
|
|
76
|
+
}' > "$manifest_path" 2>/dev/null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# ============================================================================
|
|
80
|
+
# Scan watermark
|
|
81
|
+
# ============================================================================
|
|
82
|
+
|
|
83
|
+
librarian_last_scan_path() {
|
|
84
|
+
local key="$1"
|
|
85
|
+
printf '%s/last_scan.json' "$(librarian_project_dir "$key")"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Read the last scan time as ISO-8601, or empty if never scanned.
|
|
89
|
+
librarian_storage_read_last_scan() {
|
|
90
|
+
local key="$1"
|
|
91
|
+
local path
|
|
92
|
+
path=$(librarian_last_scan_path "$key")
|
|
93
|
+
[[ -f "$path" ]] || return 0
|
|
94
|
+
jq -r '.scanned_at // empty' "$path" 2>/dev/null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# Write the current time as the new watermark.
|
|
98
|
+
librarian_storage_write_last_scan() {
|
|
99
|
+
local key="$1"
|
|
100
|
+
[[ -z "$key" ]] && return 1
|
|
101
|
+
librarian_storage_init "$key" || return 1
|
|
102
|
+
local now path
|
|
103
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
104
|
+
path=$(librarian_last_scan_path "$key")
|
|
105
|
+
jq -n --arg t "$now" '{ scanned_at: $t }' > "$path" 2>/dev/null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# ============================================================================
|
|
109
|
+
# Proposal storage
|
|
110
|
+
# ============================================================================
|
|
111
|
+
|
|
112
|
+
# Write a single proposal file. Usage:
|
|
113
|
+
# librarian_storage_write_proposal <key> <ulid> <json>
|
|
114
|
+
librarian_storage_write_proposal() {
|
|
115
|
+
local key="$1"
|
|
116
|
+
local id="$2"
|
|
117
|
+
local json="$3"
|
|
118
|
+
[[ -z "$key" || -z "$id" || -z "$json" ]] && return 1
|
|
119
|
+
|
|
120
|
+
librarian_storage_init "$key" || return 1
|
|
121
|
+
local out_path
|
|
122
|
+
out_path="$(librarian_proposals_dir "$key")/${id}.json"
|
|
123
|
+
printf '%s\n' "$json" > "$out_path" 2>/dev/null && printf '%s' "$out_path"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Read all proposals for a project key as a JSON array. Each entry is the raw
|
|
127
|
+
# proposal JSON. Order is unspecified; callers sort/filter as needed.
|
|
128
|
+
librarian_storage_load_proposals() {
|
|
129
|
+
local key="$1"
|
|
130
|
+
[[ -z "$key" ]] && { echo '[]'; return 0; }
|
|
131
|
+
|
|
132
|
+
local dir
|
|
133
|
+
dir=$(librarian_proposals_dir "$key")
|
|
134
|
+
[[ -d "$dir" ]] || { echo '[]'; return 0; }
|
|
135
|
+
|
|
136
|
+
local file all='[]'
|
|
137
|
+
for file in "$dir"/*.json; do
|
|
138
|
+
[[ -f "$file" ]] || continue
|
|
139
|
+
local item
|
|
140
|
+
item=$(jq '.' "$file" 2>/dev/null) || continue
|
|
141
|
+
all=$(printf '%s' "$all" | jq --argjson item "$item" '. + [$item]')
|
|
142
|
+
done
|
|
143
|
+
printf '%s' "$all"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Count pending proposals (status == "pending").
|
|
147
|
+
librarian_storage_count_pending() {
|
|
148
|
+
local key="$1"
|
|
149
|
+
local all
|
|
150
|
+
all=$(librarian_storage_load_proposals "$key")
|
|
151
|
+
printf '%s' "$all" | jq '[.[] | select((.status // "pending") == "pending")] | length' 2>/dev/null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# ============================================================================
|
|
155
|
+
# Tombstone storage
|
|
156
|
+
# ============================================================================
|
|
157
|
+
|
|
158
|
+
# Write a tombstone keyed by body hash. Usage:
|
|
159
|
+
# librarian_storage_write_tombstone <key> <body_hash> <original_filename>
|
|
160
|
+
librarian_storage_write_tombstone() {
|
|
161
|
+
local key="$1"
|
|
162
|
+
local body_hash="$2"
|
|
163
|
+
local original_filename="${3:-}"
|
|
164
|
+
[[ -z "$key" || -z "$body_hash" ]] && return 1
|
|
165
|
+
|
|
166
|
+
librarian_storage_init "$key" || return 1
|
|
167
|
+
local out_path now
|
|
168
|
+
out_path="$(librarian_tombstones_dir "$key")/${body_hash}.json"
|
|
169
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
170
|
+
|
|
171
|
+
jq -n \
|
|
172
|
+
--arg body_hash "$body_hash" \
|
|
173
|
+
--arg original "$original_filename" \
|
|
174
|
+
--arg created "$now" \
|
|
175
|
+
'{
|
|
176
|
+
body_hash: $body_hash,
|
|
177
|
+
original_filename: (if $original == "" then null else $original end),
|
|
178
|
+
created_at: $created
|
|
179
|
+
}' > "$out_path" 2>/dev/null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Returns 0 if a tombstone exists for this body hash (and is not expired).
|
|
183
|
+
# Usage: librarian_storage_has_tombstone <key> <body_hash> <ttl_days>
|
|
184
|
+
librarian_storage_has_tombstone() {
|
|
185
|
+
local key="$1"
|
|
186
|
+
local body_hash="$2"
|
|
187
|
+
local ttl_days="${3:-180}"
|
|
188
|
+
[[ -z "$key" || -z "$body_hash" ]] && return 1
|
|
189
|
+
|
|
190
|
+
local path
|
|
191
|
+
path="$(librarian_tombstones_dir "$key")/${body_hash}.json"
|
|
192
|
+
[[ -f "$path" ]] || return 1
|
|
193
|
+
|
|
194
|
+
local created_at age_days
|
|
195
|
+
created_at=$(jq -r '.created_at // empty' "$path" 2>/dev/null)
|
|
196
|
+
[[ -z "$created_at" ]] && return 0
|
|
197
|
+
|
|
198
|
+
# Age check via python3 for portable date math.
|
|
199
|
+
age_days=$(python3 -c "
|
|
200
|
+
import sys, datetime
|
|
201
|
+
created = datetime.datetime.strptime(sys.argv[1], '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=datetime.timezone.utc)
|
|
202
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
203
|
+
print(int((now - created).days))
|
|
204
|
+
" "$created_at" 2>/dev/null) || age_days=0
|
|
205
|
+
|
|
206
|
+
(( age_days <= ttl_days ))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# Compute a stable hash of a normalized memory body. Used for tombstone keys
|
|
210
|
+
# and conflict-state dedup. Strips whitespace runs and lowercases.
|
|
211
|
+
librarian_body_hash() {
|
|
212
|
+
local body="$1"
|
|
213
|
+
local normalized
|
|
214
|
+
normalized=$(printf '%s' "$body" | tr '[:upper:]' '[:lower:]' | tr -s '[:space:]' ' ' | sed 's/^ //;s/ $//')
|
|
215
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
216
|
+
printf '%s' "$normalized" | shasum -a 256 2>/dev/null | cut -c1-16
|
|
217
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
218
|
+
printf '%s' "$normalized" | sha256sum 2>/dev/null | cut -c1-16
|
|
219
|
+
else
|
|
220
|
+
return 1
|
|
221
|
+
fi
|
|
222
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Minimal ULID generator for Librarian proposal and tombstone IDs.
|
|
3
|
+
#
|
|
4
|
+
# Spec: https://github.com/ulid/spec
|
|
5
|
+
# - 48-bit timestamp (ms since epoch) → 10 chars Crockford Base32
|
|
6
|
+
# - 80-bit randomness → 16 chars Crockford Base32
|
|
7
|
+
# - lexicographically sortable, time-ordered
|
|
8
|
+
#
|
|
9
|
+
# Monotonicity across rapid bursts inside a single ms is not required; librarian
|
|
10
|
+
# writes proposals at SessionEnd and SessionStart cadence, never in tight loops.
|
|
11
|
+
|
|
12
|
+
_LIBRARIAN_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
13
|
+
|
|
14
|
+
# Encode a decimal integer to a fixed-length Crockford Base32 string (uppercase).
|
|
15
|
+
# Usage: _librarian_ulid_encode <integer> <length>
|
|
16
|
+
_librarian_ulid_encode() {
|
|
17
|
+
local n="$1"
|
|
18
|
+
local len="$2"
|
|
19
|
+
local out=""
|
|
20
|
+
local i
|
|
21
|
+
for ((i = 0; i < len; i++)); do
|
|
22
|
+
out="${_LIBRARIAN_ULID_ALPHABET:$((n % 32)):1}${out}"
|
|
23
|
+
n=$((n / 32))
|
|
24
|
+
done
|
|
25
|
+
printf '%s' "$out"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Generate one ULID. Prints 26 chars (timestamp + randomness).
|
|
29
|
+
librarian_ulid() {
|
|
30
|
+
local now_ms
|
|
31
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
32
|
+
now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
|
|
33
|
+
|| now_ms=$(($(date +%s) * 1000))
|
|
34
|
+
else
|
|
35
|
+
now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
local rand_hi rand_lo
|
|
39
|
+
rand_hi=$((RANDOM * 32768 + RANDOM))
|
|
40
|
+
rand_lo=$((RANDOM * 32768 + RANDOM))
|
|
41
|
+
rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
42
|
+
rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
43
|
+
|
|
44
|
+
local ts_part hi_part lo_part
|
|
45
|
+
ts_part=$(_librarian_ulid_encode "$now_ms" 10)
|
|
46
|
+
hi_part=$(_librarian_ulid_encode "$rand_hi" 8)
|
|
47
|
+
lo_part=$(_librarian_ulid_encode "$rand_lo" 8)
|
|
48
|
+
|
|
49
|
+
printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
|
|
50
|
+
}
|
|
@@ -158,6 +158,22 @@
|
|
|
158
158
|
"jsonpath": "$.version"
|
|
159
159
|
}
|
|
160
160
|
]
|
|
161
|
+
},
|
|
162
|
+
"plugins/librarian": {
|
|
163
|
+
"changelog-path": "CHANGELOG.md",
|
|
164
|
+
"release-type": "simple",
|
|
165
|
+
"bump-minor-pre-major": true,
|
|
166
|
+
"bump-patch-for-minor-pre-major": false,
|
|
167
|
+
"component": "librarian",
|
|
168
|
+
"draft": false,
|
|
169
|
+
"prerelease": false,
|
|
170
|
+
"extra-files": [
|
|
171
|
+
{
|
|
172
|
+
"type": "json",
|
|
173
|
+
"path": ".claude-plugin/plugin.json",
|
|
174
|
+
"jsonpath": "$.version"
|
|
175
|
+
}
|
|
176
|
+
]
|
|
161
177
|
}
|
|
162
178
|
},
|
|
163
179
|
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
|
@@ -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
|
+
}
|