@onlooker-community/ecosystem 0.33.0 → 0.34.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 (57) hide show
  1. package/.agents/skills/beads/SKILL.md +80 -0
  2. package/.agents/skills/beads/agents/openai.yaml +4 -0
  3. package/.claude/settings.json +13 -0
  4. package/.claude-plugin/plugin.json +1 -1
  5. package/.codex/config.toml +2 -0
  6. package/.codex/hooks.json +51 -0
  7. package/.markdownlint.json +3 -0
  8. package/.release-please-manifest.json +8 -8
  9. package/AGENTS.md +135 -0
  10. package/CHANGELOG.md +14 -0
  11. package/CLAUDE.md +56 -1
  12. package/docs/lesson-promotion-pipeline.md +210 -0
  13. package/docs/superpowers/plans/2026-08-09-lesson-transform.md +1537 -0
  14. package/docs/superpowers/specs/2026-08-09-lesson-transform-design.md +261 -0
  15. package/package.json +3 -2
  16. package/plugins/archivist/scripts/lib/archivist-config.sh +10 -34
  17. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  18. package/plugins/assayer/CHANGELOG.md +7 -0
  19. package/plugins/assayer/scripts/lib/assayer-config.sh +39 -62
  20. package/plugins/cartographer/scripts/lib/cartographer-config.sh +11 -29
  21. package/plugins/compass/scripts/lib/compass-config.sh +16 -46
  22. package/plugins/counsel/scripts/lib/counsel-config.sh +15 -46
  23. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  24. package/plugins/curator/CHANGELOG.md +7 -0
  25. package/plugins/curator/scripts/lib/curator-config.sh +19 -44
  26. package/plugins/echo/scripts/lib/echo-config.sh +30 -59
  27. package/plugins/governor/scripts/lib/governor-config.sh +11 -41
  28. package/plugins/historian/scripts/lib/historian-config.sh +9 -33
  29. package/plugins/inspector/.claude-plugin/plugin.json +1 -1
  30. package/plugins/inspector/CHANGELOG.md +7 -0
  31. package/plugins/inspector/scripts/lib/inspector-config.sh +39 -63
  32. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  33. package/plugins/librarian/CHANGELOG.md +7 -0
  34. package/plugins/librarian/config.json +4 -0
  35. package/plugins/librarian/schema/PROVENANCE.json +7 -0
  36. package/plugins/librarian/schema/lesson-applies-to.subschema.json +74 -0
  37. package/plugins/librarian/schema/lesson-evidence.subschema.json +36 -0
  38. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +26 -0
  39. package/plugins/librarian/scripts/lib/librarian-config.sh +10 -34
  40. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +135 -0
  41. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +311 -0
  42. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +140 -0
  43. package/plugins/lineage/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lineage/CHANGELOG.md +7 -0
  45. package/plugins/lineage/scripts/lib/lineage-config.sh +17 -53
  46. package/plugins/scribe/.claude-plugin/plugin.json +1 -1
  47. package/plugins/scribe/CHANGELOG.md +7 -0
  48. package/plugins/scribe/scripts/lib/scribe-config.sh +17 -47
  49. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  50. package/plugins/tribunal/CHANGELOG.md +7 -0
  51. package/plugins/tribunal/scripts/lib/tribunal-config.sh +25 -63
  52. package/plugins/warden/scripts/lib/warden-config.sh +17 -54
  53. package/scripts/lib/config-loader.sh +8 -1
  54. package/scripts/lint/check-lesson-schema-drift.mjs +36 -0
  55. package/test/bats/librarian-lesson-transform.bats +609 -0
  56. package/test/node/lesson-schema-drift.test.mjs +28 -0
  57. package/test/node/lesson-validate-agreement.test.mjs +154 -0
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // Guards the vendored lesson sub-schemas against silent drift.
3
+ //
4
+ // plugins/librarian/schema/*.subschema.json are copied out of the
5
+ // published lesson contract (see PROVENANCE.json) because ajv is
6
+ // unavailable at runtime (ADR-005); the jq rules in
7
+ // librarian-lesson-validate.sh mirror these files by hand. This check
8
+ // only confirms the provenance pin and JSON validity survive edits — it
9
+ // cannot compare against the upstream schema until schema.onlooker.dev
10
+ // publishes lesson schemas.
11
+ //
12
+ // Exit codes:
13
+ // 0 no problems
14
+ // 1 provenance pin changed or a vendored file is missing/invalid
15
+
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ const dir = 'plugins/librarian/schema';
19
+ let failures = 0;
20
+ const fail = (m) => {
21
+ console.error(`check-lesson-schema: ${m}`);
22
+ failures++;
23
+ };
24
+
25
+ try {
26
+ const prov = JSON.parse(readFileSync(`${dir}/PROVENANCE.json`, 'utf8'));
27
+ if (prov.schema_version !== 2) fail(`expected schema_version 2, got ${prov.schema_version}`);
28
+ for (const f of ['lesson-evidence.subschema.json', 'lesson-applies-to.subschema.json']) {
29
+ JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
30
+ }
31
+ } catch (err) {
32
+ fail(err.message);
33
+ }
34
+
35
+ if (failures > 0) process.exit(1);
36
+ console.log('check-lesson-schema: ok');
@@ -0,0 +1,609 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # Pure validation rules for the lesson transform. No I/O, no CLI, no project
4
+ # key — these are the rules every candidate must satisfy before it is written.
5
+
6
+ setup() {
7
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
8
+ setup_test_env
9
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
10
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
11
+ # shellcheck disable=SC1091
12
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
13
+ }
14
+
15
+ _artifact() {
16
+ jq -cn --arg s "$1" --arg d "$2" '{summary: $s, detail: $d}'
17
+ }
18
+
19
+ @test "pregate accepts an artifact carrying a dotted version" {
20
+ run librarian_lesson_pregate "$(_artifact "Vitest 4.1.9 breaks" "against Vite 5.4.21")"
21
+ [ "$status" -eq 0 ]
22
+ }
23
+
24
+ @test "pregate accepts a v-prefixed version" {
25
+ run librarian_lesson_pregate "$(_artifact "broken on v5" "see notes")"
26
+ [ "$status" -eq 0 ]
27
+ }
28
+
29
+ @test "pregate accepts an x-range" {
30
+ run librarian_lesson_pregate "$(_artifact "fails on 5.x" "see notes")"
31
+ [ "$status" -eq 0 ]
32
+ }
33
+
34
+ @test "pregate skips an artifact with no version token at all" {
35
+ run librarian_lesson_pregate "$(_artifact "Prefer functional patterns" "User said so during review.")"
36
+ [ "$status" -eq 1 ]
37
+ }
38
+
39
+ @test "valid_range accepts the four comparator forms and a two-sided range" {
40
+ for r in "<6" "<=6" "=6" ">4" ">=4" ">=4 <6"; do
41
+ run librarian_lesson_valid_range "$r"
42
+ [ "$status" -eq 0 ]
43
+ done
44
+ }
45
+
46
+ @test "valid_range rejects npm-style ranges a model reaches for by default" {
47
+ for r in "^5.4.21" "~5" "5.x" "5.4.21" "" "latest"; do
48
+ run librarian_lesson_valid_range "$r"
49
+ [ "$status" -eq 1 ]
50
+ done
51
+ }
52
+
53
+ @test "valid_range rejects unbounded lower bounds that would never expire" {
54
+ for r in ">=0" ">=0.0" ">=0.0.0" ">0"; do
55
+ run librarian_lesson_valid_range "$r"
56
+ [ "$status" -eq 1 ]
57
+ done
58
+ }
59
+
60
+ @test "valid_range rejects a string with an embedded newline containing a valid line" {
61
+ # grep's ^/$ anchor to line boundaries, not string boundaries; a naive
62
+ # grep-based check would let a compound string like this slip through
63
+ # because its second line ("<6") is independently valid.
64
+ run librarian_lesson_valid_range "$(printf '>=999\n<6')"
65
+ [ "$status" -eq 1 ]
66
+ }
67
+
68
+ _candidate() {
69
+ jq -cn --argjson versions "$1" --argjson stack "$2" '{
70
+ claim: "Vitest 4 cannot import vite/module-runner on Vite 5",
71
+ rationale: "vite/module-runner ships in Vite 6; Vitest 4 assumes it exists.",
72
+ evidence: {
73
+ artifact_ids: ["01KZ45MKAM734ZS7JK24D2DK0R"],
74
+ session_ids: ["sess-1"],
75
+ project_key: "6a7678979e31",
76
+ observed_at: "2026-08-03T15:59:48Z",
77
+ resolution: "Pin vitest to 3.x until Vite 6 lands."
78
+ },
79
+ applies_to: {
80
+ stack: $stack,
81
+ scope: {kind: "versioned", versions: $versions},
82
+ file_patterns: [],
83
+ task_kinds: []
84
+ }
85
+ }'
86
+ }
87
+
88
+ @test "validate_candidate accepts a well-formed versioned candidate" {
89
+ run librarian_lesson_validate_candidate \
90
+ "$(_candidate '{"vite":"<6","vitest":">=4"}' '["vite","vitest"]')"
91
+ [ "$status" -eq 0 ]
92
+ }
93
+
94
+ @test "validate_candidate rejects a versions key absent from stack" {
95
+ run librarian_lesson_validate_candidate \
96
+ "$(_candidate '{"vite":"<6","vitest":">=4"}' '["vite"]')"
97
+ [ "$status" -eq 1 ]
98
+ }
99
+
100
+ @test "validate_candidate rejects an invalid range inside an otherwise valid candidate" {
101
+ run librarian_lesson_validate_candidate \
102
+ "$(_candidate '{"vite":"^5.4.21"}' '["vite"]')"
103
+ [ "$status" -eq 1 ]
104
+ }
105
+
106
+ @test "validate_candidate rejects an empty versions object" {
107
+ run librarian_lesson_validate_candidate "$(_candidate '{}' '["vite"]')"
108
+ [ "$status" -eq 1 ]
109
+ }
110
+
111
+ @test "validate_candidate rejects a missing resolution" {
112
+ candidate=$(_candidate '{"vite":"<6"}' '["vite"]' | jq -c 'del(.evidence.resolution)')
113
+ run librarian_lesson_validate_candidate "$candidate"
114
+ [ "$status" -eq 1 ]
115
+ }
116
+
117
+ @test "validate_candidate rejects an empty resolution" {
118
+ candidate=$(_candidate '{"vite":"<6"}' '["vite"]' | jq -c '.evidence.resolution = ""')
119
+ run librarian_lesson_validate_candidate "$candidate"
120
+ [ "$status" -eq 1 ]
121
+ }
122
+
123
+ @test "validate_candidate rejects version_independent even when well-formed" {
124
+ candidate=$(_candidate '{"vite":"<6"}' '["vite"]' \
125
+ | jq -c '.applies_to.scope = {kind: "version_independent", justification: "git behavior is stable"}')
126
+ run librarian_lesson_validate_candidate "$candidate"
127
+ [ "$status" -eq 1 ]
128
+ }
129
+
130
+ @test "validate_candidate rejects an empty-string range" {
131
+ run librarian_lesson_validate_candidate \
132
+ "$(_candidate '{"vite":""}' '["vite"]')"
133
+ [ "$status" -eq 1 ]
134
+ }
135
+
136
+ @test "validate_candidate rejects a versions value with an embedded newline" {
137
+ # A single value like ">=999\n<6" must not be split by a newline-delimited
138
+ # read into two lines that each pass individually.
139
+ local newline_range
140
+ newline_range=$(printf '>=999\n<6')
141
+ run librarian_lesson_validate_candidate \
142
+ "$(_candidate "$(jq -cn --arg v "$newline_range" '{vite: $v}')" '["vite"]')"
143
+ [ "$status" -eq 1 ]
144
+ }
145
+
146
+ # ----------------------------------------------------------------------------
147
+ # Storage: the lessons/ subtree, proposal writes, the declined ledger, and
148
+ # artifact-keyed idempotency.
149
+ # ----------------------------------------------------------------------------
150
+
151
+ _storage_setup() {
152
+ # shellcheck disable=SC1091
153
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
154
+ # shellcheck disable=SC1091
155
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
156
+ # shellcheck disable=SC1091
157
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
158
+ # shellcheck disable=SC1091
159
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
160
+
161
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
162
+ mkdir -p "$PROJECT_REPO"
163
+ git -C "$PROJECT_REPO" init -q
164
+ git -C "$PROJECT_REPO" config user.email t@example.com
165
+ git -C "$PROJECT_REPO" config user.name "Test"
166
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/lesson-fixture.git
167
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
168
+ [ -n "$PROJECT_KEY" ]
169
+ LESSONS_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}/lessons"
170
+ }
171
+
172
+ @test "storage_init creates the proposals and approved directories" {
173
+ _storage_setup
174
+ librarian_lesson_storage_init "$PROJECT_KEY"
175
+ [ -d "${LESSONS_DIR}/proposals" ]
176
+ [ -d "${LESSONS_DIR}/approved" ]
177
+ }
178
+
179
+ @test "write_proposal lands a ULID-keyed file carrying its artifact_id" {
180
+ _storage_setup
181
+ librarian_lesson_storage_init "$PROJECT_KEY"
182
+ candidate=$(jq -cn '{claim: "c", rationale: "r"}')
183
+ id=$(librarian_lesson_write_proposal "$PROJECT_KEY" "$candidate" "01KZ45MKAM734ZS7JK24D2DK0R")
184
+ [ -n "$id" ]
185
+ [ -f "${LESSONS_DIR}/proposals/${id}.json" ]
186
+ jq -e '.artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R" and .candidate.claim == "c"' \
187
+ "${LESSONS_DIR}/proposals/${id}.json"
188
+ }
189
+
190
+ @test "append_declined writes one JSONL line per decline" {
191
+ _storage_setup
192
+ librarian_lesson_storage_init "$PROJECT_KEY"
193
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
194
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZEAF9EY4C6TTR0V7YFN9VYJ" "no_versions"
195
+ [ "$(wc -l < "${LESSONS_DIR}/declined.jsonl")" -eq 2 ]
196
+ head -n 1 "${LESSONS_DIR}/declined.jsonl" \
197
+ | jq -e '.artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R" and .reason == "no_resolution"'
198
+ }
199
+
200
+ @test "seen reports a fresh artifact as new" {
201
+ _storage_setup
202
+ librarian_lesson_storage_init "$PROJECT_KEY"
203
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
204
+ [ "$status" -eq 1 ]
205
+ }
206
+
207
+ @test "seen finds an artifact recorded in declined.jsonl" {
208
+ _storage_setup
209
+ librarian_lesson_storage_init "$PROJECT_KEY"
210
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
211
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
212
+ [ "$status" -eq 0 ]
213
+ }
214
+
215
+ @test "seen finds an artifact already sitting in proposals" {
216
+ _storage_setup
217
+ librarian_lesson_storage_init "$PROJECT_KEY"
218
+ librarian_lesson_write_proposal "$PROJECT_KEY" "$(jq -cn '{claim: "c"}')" "01KZ45MKAM734ZS7JK24D2DK0R"
219
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
220
+ [ "$status" -eq 0 ]
221
+ }
222
+
223
+ @test "seen finds an artifact already promoted into the approved pool" {
224
+ _storage_setup
225
+ librarian_lesson_storage_init "$PROJECT_KEY"
226
+ jq -n '{artifact_id: "01KZ45MKAM734ZS7JK24D2DK0R"}' \
227
+ > "${LESSONS_DIR}/approved/01KZ45MKGQ7QZWMABQ4H12SHSV.json"
228
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
229
+ [ "$status" -eq 0 ]
230
+ }
231
+
232
+ @test "seen still finds a declined artifact when declined.jsonl has a truncated trailing line" {
233
+ _storage_setup
234
+ librarian_lesson_storage_init "$PROJECT_KEY"
235
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
236
+ # Simulate a process killed mid-append: a trailing line that never closed.
237
+ printf '{"artifact_id":"01KZEAF9EY4C6TTR0V7YFN9VYJ","reason":"trunc' \
238
+ >> "${LESSONS_DIR}/declined.jsonl"
239
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
240
+ [ "$status" -eq 0 ]
241
+ }
242
+
243
+ @test "seen does not poison its scan on a valid-JSON non-object ledger line" {
244
+ # fromjson? only guards the *parse* — a line that is valid JSON but not an
245
+ # object (a bare 123) parses cleanly, then errors on `.artifact_id`
246
+ # indexing, which aborts the whole jq invocation unless `objects` filters
247
+ # it out first. That failure mode reads every previously-declined artifact
248
+ # as unseen, so both directions matter here: a real decline must still be
249
+ # found despite the poison line, and a genuinely new artifact must not be
250
+ # swept up as a false match by whatever mechanism tolerates that line.
251
+ _storage_setup
252
+ librarian_lesson_storage_init "$PROJECT_KEY"
253
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
254
+ printf '123\n' >> "${LESSONS_DIR}/declined.jsonl"
255
+
256
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
257
+ [ "$status" -eq 0 ]
258
+
259
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKGQ7QZWMABQ4H12SHSV"
260
+ [ "$status" -eq 1 ]
261
+ }
262
+
263
+ @test "seen reports a genuinely absent artifact as new, not merely 'the ledger has entries'" {
264
+ # This is deliberately NOT just "seen returns 1 for an absent id with a
265
+ # truncated line present" — that formulation cannot discriminate a correct
266
+ # implementation from a broken one. Whether the truncated line makes jq
267
+ # error out entirely or jq completes and simply finds no match, the
268
+ # calling code treats both as "not found" and falls through to the same
269
+ # answer either way, so a standalone not-found assertion there passes
270
+ # regardless of which code path produced it.
271
+ #
272
+ # What a not-found assertion CAN catch is a false-positive implementation:
273
+ # one that treats "the ledger is non-empty" or "contains any well-formed
274
+ # entry" as "seen", instead of checking whether an entry actually matches
275
+ # this artifact_id. The ledger below carries two genuine, well-formed,
276
+ # non-matching declines plus the same truncated trailing line, so the only
277
+ # way this test fails is a false match.
278
+ _storage_setup
279
+ librarian_lesson_storage_init "$PROJECT_KEY"
280
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
281
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZEAF9EY4C6TTR0V7YFN9VYJ" "no_versions"
282
+ printf '{"artifact_id":"01KZ45MKS84KPZQNWC02Z8FE0K","reason":"trunc' \
283
+ >> "${LESSONS_DIR}/declined.jsonl"
284
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKGQ7QZWMABQ4H12SHSV"
285
+ [ "$status" -eq 1 ]
286
+ }
287
+
288
+ # ----------------------------------------------------------------------------
289
+ # Transform: prompt building, the `claude -p` call, and orchestration. A
290
+ # stubbed `claude` on PATH stands in for the model.
291
+ # ----------------------------------------------------------------------------
292
+
293
+ _transform_setup() {
294
+ _storage_setup
295
+ # shellcheck disable=SC1091
296
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
297
+ # shellcheck disable=SC1091
298
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
299
+ librarian_config_load "$PROJECT_REPO"
300
+ # shellcheck disable=SC1091
301
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-transform.sh"
302
+ librarian_lesson_storage_init "$PROJECT_KEY"
303
+
304
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
305
+ mkdir -p "$STUB_BIN"
306
+ cat > "${STUB_BIN}/claude" <<'STUB'
307
+ #!/usr/bin/env bash
308
+ prompt=$(cat)
309
+ # Stub-selector markers are checked before the generic "module-runner"
310
+ # content match: several fixtures embed real vitest/vite prose (which
311
+ # contains "module-runner") alongside their marker, and the marker names
312
+ # the intended stub behavior.
313
+ if [[ "$prompt" == *"no-resolution-stub"* ]]; then
314
+ printf '%s' '{"eligible":false,"reason":"no_resolution"}'
315
+ elif [[ "$prompt" == *"no-versions-stub"* ]]; then
316
+ printf '%s' '{"eligible":false,"reason":"no_versions"}'
317
+ elif [[ "$prompt" == *"npm-range-stub"* ]]; then
318
+ printf '%s' '{"claim":"c","rationale":"r","evidence":{"resolution":"fix"},"applies_to":{"stack":["vite"],"scope":{"kind":"versioned","versions":{"vite":"^5.4.21"}},"file_patterns":[],"task_kinds":[]}}'
319
+ elif [[ "$prompt" == *"module-runner"* ]]; then
320
+ printf '%s' '{"claim":"Vitest 4 cannot import vite/module-runner on Vite 5","rationale":"vite/module-runner ships in Vite 6; Vitest 4 assumes it exists.","evidence":{"resolution":"Pin vitest to 3.x until Vite 6 lands."},"applies_to":{"stack":["vite","vitest"],"scope":{"kind":"versioned","versions":{"vite":"<6","vitest":">=4"}},"file_patterns":[],"task_kinds":[]}}'
321
+ else
322
+ printf '%s' 'not json at all'
323
+ fi
324
+ STUB
325
+ chmod +x "${STUB_BIN}/claude"
326
+ export PATH="${STUB_BIN}:${PATH}"
327
+ }
328
+
329
+ _seed() {
330
+ jq -cn --arg id "$1" --arg s "$2" --arg d "$3" --arg k "$PROJECT_KEY" \
331
+ '{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
332
+ created_at: "2026-08-03T15:59:48Z", summary: $s, detail: $d}'
333
+ }
334
+
335
+ @test "transform_one proposes a candidate for a groundable artifact" {
336
+ _transform_setup
337
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
338
+ "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.")
339
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
340
+ [ "$status" -eq 0 ]
341
+ [[ "$output" == proposed:* ]]
342
+ id="${output#proposed:}"
343
+ jq -e '.candidate.applies_to.scope.versions.vite == "<6"
344
+ and .candidate.applies_to.scope.versions.vitest == ">=4"' \
345
+ "${LESSONS_DIR}/proposals/${id}.json"
346
+ }
347
+
348
+ @test "transform_one declines the real vitest artifact for having no resolution" {
349
+ _transform_setup
350
+ # The artifact that motivated the pipeline. Its session ended on an open
351
+ # question — the fix was never found — so it cannot become a lesson.
352
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" \
353
+ "no-resolution-stub: Vitest 4.1.9 / Vite 5.x mismatch confirmed as real, blocking bug." \
354
+ "Running pnpm test reproduces failures. Vitest 4.1.9 attempts to import vite/module-runner which does not exist in Vite 5.4.21.")
355
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
356
+ [ "$status" -eq 0 ]
357
+ [ "$output" = "declined:no_resolution" ]
358
+ tail -n 1 "${LESSONS_DIR}/declined.jsonl" | jq -e '.reason == "no_resolution"'
359
+ }
360
+
361
+ @test "transform_one declines when the model cannot infer versions" {
362
+ _transform_setup
363
+ art=$(_seed "01KZ45MKGQ7QZWMABQ4H12SHSV" "no-versions-stub: 5.4 something" "detail 1.2")
364
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
365
+ [ "$output" = "declined:no_versions" ]
366
+ }
367
+
368
+ @test "transform_one declines unparseable model output as transform_invalid" {
369
+ _transform_setup
370
+ art=$(_seed "01KZ45MKME229J0QK0690TREAB" "garbage 1.0" "detail 2.0")
371
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
372
+ [ "$output" = "declined:transform_invalid" ]
373
+ }
374
+
375
+ @test "transform_one declines an npm-style range as schema_invalid" {
376
+ _transform_setup
377
+ art=$(_seed "01KZ45MKS84KPZQNWC02Z8FE0K" "npm-range-stub 5.4" "detail 1.0")
378
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
379
+ [ "$output" = "declined:schema_invalid" ]
380
+ }
381
+
382
+ @test "transform_one skips a version-free artifact without touching the ledger" {
383
+ _transform_setup
384
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Prefer functional patterns" "User said so.")
385
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
386
+ [ "$output" = "skipped:pregate" ]
387
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
388
+ }
389
+
390
+ @test "a missing claude CLI is not a verdict and writes nothing" {
391
+ _transform_setup
392
+ rm -f "${STUB_BIN}/claude"
393
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
394
+ "Vitest 4.1.9 imports vite/module-runner absent in Vite 5.4.21.")
395
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
396
+ [ "$status" -eq 0 ]
397
+ [ "$output" = "unavailable" ]
398
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
399
+ [ -z "$(ls -A "${LESSONS_DIR}/proposals")" ]
400
+ }
401
+
402
+ @test "an empty model response is not a verdict and writes nothing" {
403
+ _transform_setup
404
+ cat > "${STUB_BIN}/claude" <<'STUB'
405
+ #!/usr/bin/env bash
406
+ cat >/dev/null
407
+ printf ''
408
+ STUB
409
+ chmod +x "${STUB_BIN}/claude"
410
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
411
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
412
+ [ "$output" = "unavailable" ]
413
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
414
+ }
415
+
416
+ @test "an already-declined artifact is not sent to the model a second time" {
417
+ _transform_setup
418
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
419
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
420
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
421
+ [ "$output" = "skipped:seen" ]
422
+ [ "$(wc -l < "${LESSONS_DIR}/declined.jsonl")" -eq 1 ]
423
+ }
424
+
425
+ # ----------------------------------------------------------------------------
426
+ # Review round 1 fixes: config-driven timeout, tightened provenance
427
+ # validation, prose-tolerant JSON extraction, and the gaps a wrong
428
+ # implementation could slip through undetected.
429
+ # ----------------------------------------------------------------------------
430
+
431
+ @test "the reason clamp discards a reason slug the model invented" {
432
+ # Deleting the clamp in librarian_lesson_transform_one lets an arbitrary
433
+ # model-supplied reason string enter the permanent ledger unchecked.
434
+ _transform_setup
435
+ cat > "${STUB_BIN}/claude" <<'STUB'
436
+ #!/usr/bin/env bash
437
+ cat >/dev/null
438
+ printf '%s' '{"eligible":false,"reason":"model_made_this_up"}'
439
+ STUB
440
+ chmod +x "${STUB_BIN}/claude"
441
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
442
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
443
+ [ "$status" -eq 0 ]
444
+ [ "$output" = "declined:transform_invalid" ]
445
+ tail -n 1 "${LESSONS_DIR}/declined.jsonl" | jq -e '.reason == "transform_invalid"'
446
+ }
447
+
448
+ @test "transform_one stitches all four provenance fields, not just versions" {
449
+ # Swapping session_id/artifact_id or using the wrong timestamp source
450
+ # passed every other assertion in this file before this test existed.
451
+ #
452
+ # The two bare assertions below are chained with && into one statement
453
+ # rather than left as separate lines: under the macOS system bash (3.2)
454
+ # that `bats` resolves via `#!/usr/bin/env bash`, a bare `[[ ]]` that
455
+ # fails and isn't the test's last statement does not abort the test —
456
+ # only a `&&`/`||` chain's own short-circuited exit status reliably gates
457
+ # regardless of bash version. See ecosystem-75a.
458
+ _transform_setup
459
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
460
+ "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.")
461
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
462
+ id="${output#proposed:}"
463
+ [ "$status" -eq 0 ] \
464
+ && [[ "$output" == proposed:* ]] \
465
+ && jq -e --arg pk "$PROJECT_KEY" '
466
+ .candidate.evidence.artifact_ids == ["01KZ45MKAM734ZS7JK24D2DK0R"]
467
+ and .candidate.evidence.session_ids == ["sess-1"]
468
+ and .candidate.evidence.project_key == $pk
469
+ and .candidate.evidence.observed_at == "2026-08-03T15:59:48Z"
470
+ ' "${LESSONS_DIR}/proposals/${id}.json"
471
+ }
472
+
473
+ @test "build_prompt states there is no version-independent option and forbids npm ranges" {
474
+ # All three checks are chained with && into one statement — see the note
475
+ # on the provenance-stitching test above: a bare, non-last `[[ ]]` does
476
+ # not gate the test under the bash 3.2 that `bats` resolves on this
477
+ # machine. Only the third check was originally last, so deleting the
478
+ # "no version-independent option" paragraph from build_prompt passed
479
+ # this test as first written — the check for it ran, failed, and the
480
+ # failure was silently swallowed.
481
+ _transform_setup
482
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
483
+ prompt=$(librarian_lesson_build_prompt "$art")
484
+ [[ "$prompt" == *"no version-independent option"* ]] \
485
+ && [[ "$prompt" == *'"^5.4.21"'* ]] \
486
+ && [[ "$prompt" == *'">=0"'* ]]
487
+ }
488
+
489
+ @test "transform_one declines a provenance-less artifact instead of writing invalid evidence" {
490
+ # archivist-extract.sh writes session_id: null when the hook payload
491
+ # lacked one. That must not silently become evidence.session_ids: [""] in
492
+ # a written proposal — it must fail validation like any other schema
493
+ # violation, not get buried as though it had been judged and approved.
494
+ _transform_setup
495
+ art=$(jq -cn --arg id "01KZ45MKAM734ZS7JK24D2DK0R" --arg k "$PROJECT_KEY" \
496
+ '{id: $id, kind: "decision", project_key: $k, session_id: null,
497
+ created_at: "2026-08-03T15:59:48Z",
498
+ summary: "Vitest 4.1.9 / Vite 5.x mismatch",
499
+ detail: "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21."}')
500
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
501
+ [ "$status" -eq 0 ]
502
+ [ "$output" = "declined:schema_invalid" ]
503
+ [ -z "$(ls -A "${LESSONS_DIR}/proposals")" ]
504
+ }
505
+
506
+ @test "transform_one recovers a candidate wrapped in prose instead of declining it as unparseable" {
507
+ _transform_setup
508
+ cat > "${STUB_BIN}/claude" <<'STUB'
509
+ #!/usr/bin/env bash
510
+ cat >/dev/null
511
+ printf '%s' 'Here is the JSON you requested: {"claim":"Vitest 4 cannot import vite/module-runner on Vite 5","rationale":"vite/module-runner ships in Vite 6; Vitest 4 assumes it exists.","evidence":{"resolution":"Pin vitest to 3.x until Vite 6 lands."},"applies_to":{"stack":["vite","vitest"],"scope":{"kind":"versioned","versions":{"vite":"<6","vitest":">=4"}},"file_patterns":[],"task_kinds":[]}} Hope that helps!'
512
+ STUB
513
+ chmod +x "${STUB_BIN}/claude"
514
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
515
+ "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.")
516
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
517
+ [ "$status" -eq 0 ]
518
+ [[ "$output" == proposed:* ]]
519
+ }
520
+
521
+ @test "librarian_lesson_call reads timeout_seconds from config instead of hardcoding it" {
522
+ _transform_setup
523
+ mkdir -p "${PROJECT_REPO}/.claude"
524
+ printf '%s\n' '{"librarian":{"lesson_transform":{"timeout_seconds":7}}}' \
525
+ > "${PROJECT_REPO}/.claude/settings.json"
526
+ librarian_config_load "$PROJECT_REPO"
527
+
528
+ TIMEOUT_CAPTURE="${BATS_TEST_TMPDIR}/timeout-seconds-used"
529
+ export TIMEOUT_CAPTURE
530
+ cat > "${STUB_BIN}/timeout" <<'STUB'
531
+ #!/usr/bin/env bash
532
+ printf '%s' "$1" > "${TIMEOUT_CAPTURE}"
533
+ shift
534
+ exec "$@"
535
+ STUB
536
+ chmod +x "${STUB_BIN}/timeout"
537
+
538
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
539
+ "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.")
540
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
541
+ [ "$status" -eq 0 ] \
542
+ && [ "$(cat "$TIMEOUT_CAPTURE")" = "7" ]
543
+ }
544
+
545
+ @test "a pathologically large response terminates quickly instead of hanging on extraction" {
546
+ # _librarian_lesson_extract_json_object is a per-character bash scan —
547
+ # effectively O(n^2) on long input. It runs after the claude call,
548
+ # uncapped, inside a SessionEnd hook that must not stall session end, and
549
+ # nothing bounds the model's response size (claude -p has no such flag).
550
+ # This response has no brace anywhere, so recovery is impossible and the
551
+ # correct outcome is declined:transform_invalid — the test asserts that
552
+ # verdict is reached well inside a 5s budget, not just that it's eventually
553
+ # reached at all. 40k chars is sized to fail loudly (tens of seconds, not
554
+ # a borderline few) if the bound in librarian_lesson_call is ever removed,
555
+ # rather than flake near the threshold.
556
+ _transform_setup
557
+ cat > "${STUB_BIN}/claude" <<'STUB'
558
+ #!/usr/bin/env bash
559
+ cat >/dev/null
560
+ printf '%s' 'not json, just prose. '
561
+ printf '%*s' 40000 '' | tr ' ' 'x'
562
+ STUB
563
+ chmod +x "${STUB_BIN}/claude"
564
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
565
+ "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.")
566
+
567
+ start=$(date +%s)
568
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
569
+ end=$(date +%s)
570
+ elapsed=$((end - start))
571
+
572
+ [ "$status" -eq 0 ] \
573
+ && [ "$output" = "declined:transform_invalid" ] \
574
+ && [ "$elapsed" -lt 5 ]
575
+ }
576
+
577
+ # ----------------------------------------------------------------------------
578
+ # SessionEnd wiring: the hook runs the lesson stage over the same durability
579
+ # survivors the classifier saw and lands a proposal on disk.
580
+ # ----------------------------------------------------------------------------
581
+
582
+ @test "SessionEnd runs the lesson stage and lands a candidate on disk" {
583
+ _transform_setup
584
+
585
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/librarian-session-end.sh"
586
+ ARCHIVIST_DIR="${ONLOOKER_DIR}/archivist/${PROJECT_KEY}"
587
+ mkdir -p "${ARCHIVIST_DIR}/decisions"
588
+
589
+ created_at=$(relative_iso_days_ago 1)
590
+ # The detail carries "because" so this fixture clears the durability
591
+ # filter's marker-phrase gate (plugins/librarian/scripts/lib/
592
+ # librarian-durability.sh) — a plain version-mismatch summary with no
593
+ # marker phrase gets dropped as filter_marker_missing before it ever
594
+ # reaches $KEPT, regardless of whether the lesson stage runs.
595
+ jq -n --arg id "01KZ45MKAM734ZS7JK24D2DK0R" --arg at "$created_at" --arg k "$PROJECT_KEY" \
596
+ '{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
597
+ created_at: $at, updated_at: $at,
598
+ summary: "Vitest 4.1.9 / Vite 5.x mismatch, decided to pin",
599
+ detail: "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21, because Vite 6 has not shipped yet.",
600
+ files: ["packages/db"]}' \
601
+ > "${ARCHIVIST_DIR}/decisions/01KZ45MKAM734ZS7JK24D2DK0R.json"
602
+
603
+ input=$(jq -cn --arg cwd "$PROJECT_REPO" \
604
+ '{cwd: $cwd, session_id: "sess-1", hook_event_name: "SessionEnd"}')
605
+ run bash -c "printf '%s' '$input' | '$HOOK'"
606
+
607
+ [ "$status" -eq 0 ]
608
+ [ -n "$(ls -A "${LESSONS_DIR}/proposals" 2>/dev/null)" ]
609
+ }
@@ -0,0 +1,28 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+
5
+ const dir = 'plugins/librarian/schema';
6
+ const read = (f) => JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
7
+
8
+ test('provenance pins schema_version 2', () => {
9
+ assert.equal(read('PROVENANCE.json').schema_version, 2);
10
+ });
11
+
12
+ test('applies_to keeps a two-branch scope union', () => {
13
+ const appliesTo = read('lesson-applies-to.subschema.json');
14
+ const branches = appliesTo.properties.scope.oneOf;
15
+ assert.equal(branches.length, 2);
16
+ assert.deepEqual(branches.map((b) => b.properties.kind.const).sort(), ['version_independent', 'versioned']);
17
+ });
18
+
19
+ test('the versioned branch requires at least one version and carries the range pattern', () => {
20
+ const appliesTo = read('lesson-applies-to.subschema.json');
21
+ const versioned = appliesTo.properties.scope.oneOf.find((b) => b.properties.kind.const === 'versioned');
22
+ assert.equal(versioned.properties.versions.minProperties, 1);
23
+ assert.ok(versioned.properties.versions.additionalProperties.pattern);
24
+ });
25
+
26
+ test('evidence requires a resolution', () => {
27
+ assert.ok(read('lesson-evidence.subschema.json').required.includes('resolution'));
28
+ });