@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,1537 @@
1
+ # Lesson Transform Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add librarian's fifth stage — a Haiku transform that turns a durable archivist artifact into a lesson candidate, declining anything it cannot ground.
6
+
7
+ **Architecture:** Three new bash libs with clean boundaries (pure validation, storage, orchestration), sourced by the existing `librarian-session-end.sh` chain after conflict/dup detection. A free bash pre-gate runs before any model call; survivors go to `claude -p`; output is validated by dependency-free `jq` against rules vendored from the published lesson schema. Candidates land in a `lessons/` subtree separate from librarian's existing memory proposals.
8
+
9
+ **Tech Stack:** bash, `jq`, `bats`, `claude` CLI, node (event emission and the drift-guard lint only).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-09-lesson-transform-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Hooks and libs are **bash**. No Python. Node only for event emission and lint scripts.
16
+ - Always use `${ONLOOKER_DIR:-$HOME/.onlooker}`, never a literal `~/.onlooker`.
17
+ - ULIDs, not UUIDs. Use the existing `librarian_ulid` from `librarian-ulid.sh`.
18
+ - Event types follow `<plugin>.<noun>.<verb>`.
19
+ - Config defaults live in `plugins/librarian/config.json`; overrides under the plugin namespace key (ADR-004).
20
+ - Every function fails soft. Hooks always exit 0 — they never block a session.
21
+ - Config is read via `librarian_config_get '<jq-path>'`.
22
+ - **The transform emits `versioned` scope only.** It must never emit `version_independent`.
23
+ - Valid version ranges: `<6`, `<=6`, `=6`, `>4`, `>=4`, `>=4 <6`. Invalid: `^5.4.21`, `~5`, `5.x`, bare `5.4.21`, `>=0`, `>=0.0`, `>=0.0.0`.
24
+ - American English in all comments and docs.
25
+
26
+ ## File Structure
27
+
28
+ | File | Responsibility |
29
+ |---|---|
30
+ | `plugins/librarian/schema/lesson-evidence.subschema.json` | Vendored `evidence` sub-schema (source of truth for the jq rules) |
31
+ | `plugins/librarian/schema/lesson-applies-to.subschema.json` | Vendored `applies_to` sub-schema |
32
+ | `plugins/librarian/schema/PROVENANCE.json` | Where the vendored copies came from, and their `schema_version` |
33
+ | `plugins/librarian/scripts/lib/librarian-lesson-validate.sh` | Pure functions: pre-gate, range check, candidate validation. No I/O |
34
+ | `plugins/librarian/scripts/lib/librarian-lesson-storage.sh` | `lessons/` paths, proposal write, declined append, idempotency lookup |
35
+ | `plugins/librarian/scripts/lib/librarian-lesson-transform.sh` | Prompt building, `claude` call, orchestration |
36
+ | `plugins/librarian/scripts/hooks/librarian-session-end.sh` | Modified: source the libs, run the stage |
37
+ | `plugins/librarian/config.json` | Modified: `librarian.lesson_transform.*` defaults |
38
+ | `scripts/lint/check-lesson-schema-drift.mjs` | CI guard on the vendored copies |
39
+ | `test/bats/librarian-lesson-transform.bats` | All bats coverage for this stage |
40
+
41
+ **Note on decomposition:** the spec names a single `librarian-lesson-transform.sh`. This plan splits it three ways — pure logic, storage, orchestration — because the pure functions are where every high-risk rule lives and they are far easier to test without standing up a project key, a git repo, and a stubbed CLI. The spec's intent is unchanged.
42
+
43
+ **Task order note:** event emission is deliberately **last**. `librarian.lesson.proposed` and `librarian.lesson.declined` are not registered in `@onlooker-community/schema`, which is a separate published package. With a validator present (dev/CI) the emitter rejects an unregistered `event_type` and exits 1, so any test asserting on those events fails until the package publishes and is bumped here. Every other task is verified through on-disk artifacts and needs no schema change.
44
+
45
+ ---
46
+
47
+ ### Task 1: Vendored sub-schemas and provenance
48
+
49
+ **Files:**
50
+ - Create: `plugins/librarian/schema/lesson-evidence.subschema.json`
51
+ - Create: `plugins/librarian/schema/lesson-applies-to.subschema.json`
52
+ - Create: `plugins/librarian/schema/PROVENANCE.json`
53
+ - Create: `scripts/lint/check-lesson-schema-drift.mjs`
54
+ - Modify: `package.json`
55
+ - Test: `test/node/lesson-schema-drift.test.mjs`
56
+
57
+ **Interfaces:**
58
+ - Consumes: nothing.
59
+ - Produces: two JSON Schema files that Task 2's jq rules must mirror. `npm run lint:lesson-schema` exits 0 when provenance is intact.
60
+
61
+ - [ ] **Step 1: Extract the two sub-schemas from the published contract**
62
+
63
+ The source is `packages/lesson-contract/schema/lesson.schema.json` in the [onlooker](https://github.com/onlooker-community/onlooker) repo. If a sibling checkout exists, extract directly:
64
+
65
+ ```bash
66
+ ONL=../onlooker # adjust to your checkout
67
+ mkdir -p plugins/librarian/schema
68
+ jq '.properties.evidence' "$ONL/packages/lesson-contract/schema/lesson.schema.json" \
69
+ > plugins/librarian/schema/lesson-evidence.subschema.json
70
+ jq '.properties.applies_to' "$ONL/packages/lesson-contract/schema/lesson.schema.json" \
71
+ > plugins/librarian/schema/lesson-applies-to.subschema.json
72
+ ```
73
+
74
+ If no checkout is available, copy the two objects from the spec's *Validation* section by hand. Verify afterward that `applies_to` contains a `scope.oneOf` with exactly two branches, and that the `versioned` branch's `additionalProperties.pattern` is present.
75
+
76
+ - [ ] **Step 2: Record provenance**
77
+
78
+ ```bash
79
+ jq -n '{
80
+ source_repo: "https://github.com/onlooker-community/onlooker",
81
+ source_path: "packages/lesson-contract/schema/lesson.schema.json",
82
+ schema_version: 2,
83
+ extracted: ["properties.evidence", "properties.applies_to"],
84
+ note: "Vendored because ajv is unavailable at runtime (ADR-005). Runtime enforcement is jq in librarian-lesson-validate.sh; these files are the source of truth those rules mirror. Upgrade to a fetch-and-compare guard once lesson schemas are published — schema.onlooker.dev serves none today."
85
+ }' > plugins/librarian/schema/PROVENANCE.json
86
+ ```
87
+
88
+ - [ ] **Step 3: Write the failing drift test**
89
+
90
+ ```javascript
91
+ // test/node/lesson-schema-drift.test.mjs
92
+ import test from 'node:test';
93
+ import assert from 'node:assert/strict';
94
+ import { readFileSync } from 'node:fs';
95
+
96
+ const dir = 'plugins/librarian/schema';
97
+ const read = (f) => JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
98
+
99
+ test('provenance pins schema_version 2', () => {
100
+ assert.equal(read('PROVENANCE.json').schema_version, 2);
101
+ });
102
+
103
+ test('applies_to keeps a two-branch scope union', () => {
104
+ const appliesTo = read('lesson-applies-to.subschema.json');
105
+ const branches = appliesTo.properties.scope.oneOf;
106
+ assert.equal(branches.length, 2);
107
+ assert.deepEqual(
108
+ branches.map((b) => b.properties.kind.const).sort(),
109
+ ['version_independent', 'versioned'],
110
+ );
111
+ });
112
+
113
+ test('the versioned branch requires at least one version and carries the range pattern', () => {
114
+ const appliesTo = read('lesson-applies-to.subschema.json');
115
+ const versioned = appliesTo.properties.scope.oneOf
116
+ .find((b) => b.properties.kind.const === 'versioned');
117
+ assert.equal(versioned.properties.versions.minProperties, 1);
118
+ assert.ok(versioned.properties.versions.additionalProperties.pattern);
119
+ });
120
+
121
+ test('evidence requires a resolution', () => {
122
+ assert.ok(read('lesson-evidence.subschema.json').required.includes('resolution'));
123
+ });
124
+ ```
125
+
126
+ - [ ] **Step 4: Run it and watch it fail**
127
+
128
+ Run: `node --test test/node/lesson-schema-drift.test.mjs`
129
+ Expected: FAIL — files missing — until Steps 1-2 are done. If Steps 1-2 already ran, it passes; that is fine, the assertions still guard future edits.
130
+
131
+ - [ ] **Step 5: Wire the lint script**
132
+
133
+ ```javascript
134
+ // scripts/lint/check-lesson-schema-drift.mjs
135
+ import { readFileSync } from 'node:fs';
136
+
137
+ const dir = 'plugins/librarian/schema';
138
+ let failures = 0;
139
+ const fail = (m) => { console.error(`check-lesson-schema: ${m}`); failures++; };
140
+
141
+ try {
142
+ const prov = JSON.parse(readFileSync(`${dir}/PROVENANCE.json`, 'utf8'));
143
+ if (prov.schema_version !== 2) fail(`expected schema_version 2, got ${prov.schema_version}`);
144
+ for (const f of ['lesson-evidence.subschema.json', 'lesson-applies-to.subschema.json']) {
145
+ JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
146
+ }
147
+ } catch (err) {
148
+ fail(err.message);
149
+ }
150
+
151
+ if (failures > 0) process.exit(1);
152
+ console.log('check-lesson-schema: ok');
153
+ ```
154
+
155
+ Add to `package.json` scripts:
156
+
157
+ ```json
158
+ "lint:lesson-schema": "node scripts/lint/check-lesson-schema-drift.mjs"
159
+ ```
160
+
161
+ And append it to the `test:ci` chain, after `lint:references`.
162
+
163
+ - [ ] **Step 6: Verify**
164
+
165
+ Run: `node --test test/node/lesson-schema-drift.test.mjs && npm run lint:lesson-schema`
166
+ Expected: all tests PASS, lint prints `check-lesson-schema: ok`.
167
+
168
+ - [ ] **Step 7: Commit**
169
+
170
+ ```bash
171
+ git add plugins/librarian/schema scripts/lint/check-lesson-schema-drift.mjs \
172
+ test/node/lesson-schema-drift.test.mjs package.json
173
+ git commit -m "feat(librarian): vendor the lesson sub-schemas the transform validates against :seedling:"
174
+ ```
175
+
176
+ ---
177
+
178
+ ### Task 2: Pure validation library
179
+
180
+ **Files:**
181
+ - Create: `plugins/librarian/scripts/lib/librarian-lesson-validate.sh`
182
+ - Test: `test/bats/librarian-lesson-transform.bats`
183
+
184
+ **Interfaces:**
185
+ - Consumes: the vendored sub-schemas from Task 1 (as the specification the rules mirror; the code does not read them at runtime).
186
+ - Produces:
187
+ - `librarian_lesson_pregate <artifact_json>` → exit 0 to proceed, 1 to skip
188
+ - `librarian_lesson_valid_range <string>` → exit 0 when a valid version range
189
+ - `librarian_lesson_validate_candidate <candidate_json>` → prints `""` when valid, or a reason slug (`schema_invalid`) on stderr; exit 0 valid, 1 invalid
190
+
191
+ - [ ] **Step 1: Write the failing tests**
192
+
193
+ ```bash
194
+ #!/usr/bin/env bats
195
+ #
196
+ # Pure validation rules for the lesson transform. No I/O, no CLI, no project
197
+ # key — these are the rules every candidate must satisfy before it is written.
198
+
199
+ setup() {
200
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
201
+ setup_test_env
202
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
203
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
204
+ # shellcheck disable=SC1091
205
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
206
+ }
207
+
208
+ _artifact() {
209
+ jq -cn --arg s "$1" --arg d "$2" '{summary: $s, detail: $d}'
210
+ }
211
+
212
+ @test "pregate accepts an artifact carrying a dotted version" {
213
+ run librarian_lesson_pregate "$(_artifact "Vitest 4.1.9 breaks" "against Vite 5.4.21")"
214
+ [ "$status" -eq 0 ]
215
+ }
216
+
217
+ @test "pregate accepts a v-prefixed version" {
218
+ run librarian_lesson_pregate "$(_artifact "broken on v5" "see notes")"
219
+ [ "$status" -eq 0 ]
220
+ }
221
+
222
+ @test "pregate accepts an x-range" {
223
+ run librarian_lesson_pregate "$(_artifact "fails on 5.x" "see notes")"
224
+ [ "$status" -eq 0 ]
225
+ }
226
+
227
+ @test "pregate skips an artifact with no version token at all" {
228
+ run librarian_lesson_pregate "$(_artifact "Prefer functional patterns" "User said so during review.")"
229
+ [ "$status" -eq 1 ]
230
+ }
231
+
232
+ @test "valid_range accepts the four comparator forms and a two-sided range" {
233
+ for r in "<6" "<=6" "=6" ">4" ">=4" ">=4 <6"; do
234
+ run librarian_lesson_valid_range "$r"
235
+ [ "$status" -eq 0 ]
236
+ done
237
+ }
238
+
239
+ @test "valid_range rejects npm-style ranges a model reaches for by default" {
240
+ for r in "^5.4.21" "~5" "5.x" "5.4.21" "" "latest"; do
241
+ run librarian_lesson_valid_range "$r"
242
+ [ "$status" -eq 1 ]
243
+ done
244
+ }
245
+
246
+ @test "valid_range rejects unbounded lower bounds that would never expire" {
247
+ for r in ">=0" ">=0.0" ">=0.0.0" ">0"; do
248
+ run librarian_lesson_valid_range "$r"
249
+ [ "$status" -eq 1 ]
250
+ done
251
+ }
252
+
253
+ @test "valid_range anchors to the whole string, not to a line within it" {
254
+ # grep would match per line and accept this, diverging from the schema's
255
+ # ECMA262 pattern, which has no /m flag.
256
+ run librarian_lesson_valid_range "$(printf '<6\n>=999')"
257
+ [ "$status" -eq 1 ]
258
+ }
259
+
260
+ _candidate() {
261
+ jq -cn --argjson versions "$1" --argjson stack "$2" '{
262
+ claim: "Vitest 4 cannot import vite/module-runner on Vite 5",
263
+ rationale: "vite/module-runner ships in Vite 6; Vitest 4 assumes it exists.",
264
+ evidence: {
265
+ artifact_ids: ["01KZ45MKAM734ZS7JK24D2DK0R"],
266
+ session_ids: ["sess-1"],
267
+ project_key: "6a7678979e31",
268
+ observed_at: "2026-08-03T15:59:48Z",
269
+ resolution: "Pin vitest to 3.x until Vite 6 lands."
270
+ },
271
+ applies_to: {
272
+ stack: $stack,
273
+ scope: {kind: "versioned", versions: $versions},
274
+ file_patterns: [],
275
+ task_kinds: []
276
+ }
277
+ }'
278
+ }
279
+
280
+ @test "validate_candidate accepts a well-formed versioned candidate" {
281
+ run librarian_lesson_validate_candidate \
282
+ "$(_candidate '{"vite":"<6","vitest":">=4"}' '["vite","vitest"]')"
283
+ [ "$status" -eq 0 ]
284
+ }
285
+
286
+ @test "validate_candidate rejects a versions key absent from stack" {
287
+ run librarian_lesson_validate_candidate \
288
+ "$(_candidate '{"vite":"<6","vitest":">=4"}' '["vite"]')"
289
+ [ "$status" -eq 1 ]
290
+ }
291
+
292
+ @test "validate_candidate rejects an invalid range inside an otherwise valid candidate" {
293
+ run librarian_lesson_validate_candidate \
294
+ "$(_candidate '{"vite":"^5.4.21"}' '["vite"]')"
295
+ [ "$status" -eq 1 ]
296
+ }
297
+
298
+ @test "validate_candidate rejects an empty versions object" {
299
+ run librarian_lesson_validate_candidate "$(_candidate '{}' '["vite"]')"
300
+ [ "$status" -eq 1 ]
301
+ }
302
+
303
+ @test "validate_candidate rejects an empty-string range" {
304
+ run librarian_lesson_validate_candidate "$(_candidate '{"vite":""}' '["vite"]')"
305
+ [ "$status" -eq 1 ]
306
+ }
307
+
308
+ @test "validate_candidate rejects a range value carrying an embedded newline" {
309
+ run librarian_lesson_validate_candidate \
310
+ "$(_candidate "$(jq -cn '{vite: "<6\n>=999"}')" '["vite"]')"
311
+ [ "$status" -eq 1 ]
312
+ }
313
+
314
+ @test "validate_candidate rejects a missing resolution" {
315
+ candidate=$(_candidate '{"vite":"<6"}' '["vite"]' | jq -c 'del(.evidence.resolution)')
316
+ run librarian_lesson_validate_candidate "$candidate"
317
+ [ "$status" -eq 1 ]
318
+ }
319
+
320
+ @test "validate_candidate rejects an empty resolution" {
321
+ candidate=$(_candidate '{"vite":"<6"}' '["vite"]' | jq -c '.evidence.resolution = ""')
322
+ run librarian_lesson_validate_candidate "$candidate"
323
+ [ "$status" -eq 1 ]
324
+ }
325
+
326
+ @test "validate_candidate rejects version_independent even when well-formed" {
327
+ candidate=$(_candidate '{"vite":"<6"}' '["vite"]' \
328
+ | jq -c '.applies_to.scope = {kind: "version_independent", justification: "git behavior is stable"}')
329
+ run librarian_lesson_validate_candidate "$candidate"
330
+ [ "$status" -eq 1 ]
331
+ }
332
+ ```
333
+
334
+ - [ ] **Step 2: Run them and watch them fail**
335
+
336
+ Run: `bats test/bats/librarian-lesson-transform.bats`
337
+ Expected: FAIL — `librarian_lesson_pregate: command not found`.
338
+
339
+ - [ ] **Step 3: Implement the library**
340
+
341
+ ```bash
342
+ #!/usr/bin/env bash
343
+ # Pure validation rules for lesson candidates. No I/O, no network, no CLI.
344
+ #
345
+ # These rules mirror the vendored sub-schemas in plugins/librarian/schema/.
346
+ # ajv cannot run at runtime (installed plugins ship no node_modules, ADR-005),
347
+ # so enforcement here is jq. The two mechanisms have been proven able to
348
+ # disagree, so tests assert them separately.
349
+
350
+ # Version-shaped token check. Returns 0 when the artifact could plausibly
351
+ # yield a versioned scope, 1 when it definitionally cannot.
352
+ #
353
+ # This rejects only what is impossible, never what is merely low quality:
354
+ # the transform can emit `versioned` scope alone, so an artifact with no
355
+ # version token anywhere cannot produce a valid scope.versions.
356
+ #
357
+ # Usage: librarian_lesson_pregate <artifact_json>
358
+ librarian_lesson_pregate() {
359
+ local artifact="${1:-}"
360
+ [[ -z "$artifact" ]] && return 1
361
+
362
+ local text
363
+ text=$(printf '%s' "$artifact" | jq -r '((.summary // "") + " " + (.detail // ""))' 2>/dev/null) || return 1
364
+ [[ -z "$text" ]] && return 1
365
+
366
+ # Dotted (5.4.21), v-prefixed (v5), or x-range (5.x).
367
+ printf '%s' "$text" | grep -qE '([0-9]+\.[0-9]+)|(\bv[0-9]+)|([0-9]+\.x\b)'
368
+ }
369
+
370
+ # Version range check, mirroring the vendored pattern.
371
+ #
372
+ # Accepts: <6 <=6 =6 >4 >=4 ">=4 <6"
373
+ # Rejects: ^5.4.21 ~5 5.x 5.4.21 >=0 >=0.0.0
374
+ #
375
+ # The >= and > forms require a non-zero lower bound. An unbounded lower bound
376
+ # matches every session and would never expire — version independence in
377
+ # disguise, which this stage is not allowed to mint.
378
+ #
379
+ # Usage: librarian_lesson_valid_range <string>
380
+ librarian_lesson_valid_range() {
381
+ local r="${1:-}"
382
+ [[ -z "$r" ]] && return 1
383
+
384
+ # The integer-part alternative is [0-9]*[1-9][0-9]*, not [1-9][0-9]*: the
385
+ # vendored pattern's equivalent is \d*[1-9]\d*, which allows a leading
386
+ # zero digit (05, 007) as long as some digit is nonzero. [1-9][0-9]* only
387
+ # permits a nonzero *leading* digit, so it rejects >=05 while the vendored
388
+ # schema accepts it — a real jq/schema disagreement, not a style choice.
389
+ local nonzero='([0-9]*[1-9][0-9]*(\.[0-9]+)?(\.[0-9]+)?|0+\.[0-9]*[1-9][0-9]*(\.[0-9]+)?|0+\.0+\.[0-9]*[1-9][0-9]*)'
390
+ local any='[0-9]+(\.[0-9]+)?(\.[0-9]+)?'
391
+ local pattern="^((<|<=|=)${any}|(>|>=)${nonzero}|(>|>=)${any} (<|<=)${any})$"
392
+
393
+ # Use bash's own regex engine rather than grep: grep's ^/$ anchor to line
394
+ # boundaries, not string boundaries, so a value with an embedded newline
395
+ # could smuggle a valid line past an otherwise-rejected string. [[ =~ ]]
396
+ # anchors to the whole string. The pattern must stay unquoted here —
397
+ # quoting the right-hand side of =~ forces literal string matching.
398
+ #
399
+ # Do NOT escape the angle brackets as \< and \>. In GNU/glibc regex those
400
+ # are word-boundary assertions, not escaped literals, so an inlined
401
+ # escaped pattern behaves differently on Linux CI than on macOS. Build the
402
+ # pattern in a variable with plain < and > as above.
403
+ [[ "$r" =~ $pattern ]]
404
+ }
405
+
406
+ # Validate a full candidate. Prints nothing on success; prints a reason slug
407
+ # to stderr on failure.
408
+ #
409
+ # Usage: librarian_lesson_validate_candidate <candidate_json>
410
+ librarian_lesson_validate_candidate() {
411
+ local candidate="${1:-}"
412
+ [[ -z "$candidate" ]] && { printf 'schema_invalid\n' >&2; return 1; }
413
+
414
+ # Structural shape, including the versioned-only rule and a non-empty
415
+ # resolution. `versions` must be a non-empty object. artifact_ids,
416
+ # session_ids, and observed_at are checked against the same patterns as
417
+ # the vendored lesson-evidence.subschema.json (ULID, non-empty string,
418
+ # RFC3339 date-time) — a provenance-less artifact (session_id/created_at
419
+ # stitched in as "") must fail here, not pass through and get buried
420
+ # permanently once librarian_lesson_seen marks it handled.
421
+ #
422
+ # The `keys - [...] | length == 0` checks mirror `additionalProperties:
423
+ # false` on the vendored `evidence` and `applies_to` sub-schemas
424
+ # (including the "versioned" scope branch), and the `all(type ==
425
+ # "string" and length > 0)` checks mirror their array items' `minLength:
426
+ # 1`. Neither is decorative: without them a model that "helpfully" adds
427
+ # an extra field, or emits an empty-string array entry, produces a
428
+ # proposal that passes here but fails ajv against the contract it claims
429
+ # to satisfy — and lessons are meant to be shared with other people. Each
430
+ # `keys` call is guarded by a preceding `type == "object"` check in the
431
+ # same `and` chain: jq's `and` short-circuits left to right, so `keys` on
432
+ # a missing/non-object value is never reached.
433
+ if ! printf '%s' "$candidate" | jq -e '
434
+ (.claim | type) == "string" and (.claim | length) > 0
435
+ and (.rationale | type) == "string" and (.rationale | length) > 0
436
+ and (.evidence | type) == "object"
437
+ and ((.evidence | keys) - ["artifact_ids", "session_ids", "project_key", "observed_at", "resolution"] | length) == 0
438
+ and (.evidence.artifact_ids | type) == "array" and (.evidence.artifact_ids | length) > 0
439
+ and (.evidence.artifact_ids | all(type == "string" and test("^[0-9A-HJKMNP-TV-Z]{26}$")))
440
+ and (.evidence.session_ids | type) == "array" and (.evidence.session_ids | length) > 0
441
+ and (.evidence.session_ids | all(type == "string" and length > 0))
442
+ and (.evidence.project_key | type) == "string"
443
+ and (.evidence.project_key | test("^[0-9a-f]{12}$"))
444
+ and (.evidence.observed_at | type) == "string"
445
+ and (.evidence.observed_at | test("^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"))
446
+ and (.evidence.resolution | type) == "string" and (.evidence.resolution | length) > 0
447
+ and (.applies_to | type) == "object"
448
+ and ((.applies_to | keys) - ["stack", "scope", "file_patterns", "task_kinds"] | length) == 0
449
+ and (.applies_to.stack | type) == "array" and (.applies_to.stack | length) > 0
450
+ and (.applies_to.stack | all(type == "string" and length > 0))
451
+ and (.applies_to.file_patterns | type) == "array"
452
+ and (.applies_to.file_patterns | all(type == "string" and length > 0))
453
+ and (.applies_to.task_kinds | type) == "array"
454
+ and (.applies_to.task_kinds | all(type == "string" and length > 0))
455
+ and .applies_to.scope.kind == "versioned"
456
+ and ((.applies_to.scope | keys) - ["kind", "versions"] | length) == 0
457
+ and (.applies_to.scope.versions | type) == "object"
458
+ and (.applies_to.scope.versions | length) > 0
459
+ ' >/dev/null 2>&1; then
460
+ printf 'schema_invalid\n' >&2
461
+ return 1
462
+ fi
463
+
464
+ # Cross-field rule JSON Schema cannot express: every versions key must
465
+ # name an entry in stack.
466
+ if ! printf '%s' "$candidate" | jq -e '
467
+ (.applies_to.scope.versions | keys) - .applies_to.stack | length == 0
468
+ ' >/dev/null 2>&1; then
469
+ printf 'schema_invalid\n' >&2
470
+ return 1
471
+ fi
472
+
473
+ # Every range must satisfy the vendored pattern. NUL-delimited, not
474
+ # newline-delimited: a range value with an embedded newline would
475
+ # otherwise split into two lines that can each pass individually even
476
+ # though the single value they came from is not a valid range. Do not
477
+ # skip empty reads either — jq never emits one for a non-empty object
478
+ # of strings, so an empty read means the range itself is empty, and
479
+ # librarian_lesson_valid_range already rejects that.
480
+ local range
481
+ while IFS= read -r -d '' range; do
482
+ librarian_lesson_valid_range "$range" || { printf 'schema_invalid\n' >&2; return 1; }
483
+ done < <(printf '%s' "$candidate" | jq --raw-output0 '.applies_to.scope.versions[]' 2>/dev/null)
484
+
485
+ return 0
486
+ }
487
+ ```
488
+
489
+ - [ ] **Step 4: Run the tests**
490
+
491
+ Run: `bats test/bats/librarian-lesson-transform.bats`
492
+ Expected: all PASS.
493
+
494
+ - [ ] **Step 5: Shellcheck**
495
+
496
+ Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-validate.sh`
497
+ Expected: no output.
498
+
499
+ - [ ] **Step 6: Commit**
500
+
501
+ ```bash
502
+ git add plugins/librarian/scripts/lib/librarian-lesson-validate.sh \
503
+ test/bats/librarian-lesson-transform.bats
504
+ git commit -m "feat(librarian): enforce lesson candidate rules without a schema validator :closed_lock_with_key:"
505
+ ```
506
+
507
+ ---
508
+
509
+ ### Task 3: Lesson storage and idempotency
510
+
511
+ **Files:**
512
+ - Create: `plugins/librarian/scripts/lib/librarian-lesson-storage.sh`
513
+ - Test: `test/bats/librarian-lesson-transform.bats` (append)
514
+
515
+ **Interfaces:**
516
+ - Consumes: `librarian_project_dir <key>` and `librarian_ulid` from the existing libs.
517
+ - Produces:
518
+ - `librarian_lessons_dir <key>` → prints `<project_dir>/lessons`
519
+ - `librarian_lesson_storage_init <key>` → creates `lessons/proposals` and `lessons/approved`
520
+ - `librarian_lesson_write_proposal <key> <candidate_json> <artifact_id>` → prints the ULID written
521
+ - `librarian_lesson_append_declined <key> <artifact_id> <reason> [detail]` → appends one JSONL line
522
+ - `librarian_lesson_seen <key> <artifact_id>` → exit 0 when already handled, 1 when new
523
+
524
+ - [ ] **Step 1: Write the failing tests**
525
+
526
+ Append to `test/bats/librarian-lesson-transform.bats`. This block needs a project key, so it stands up its own git repo:
527
+
528
+ ```bash
529
+ _storage_setup() {
530
+ # shellcheck disable=SC1091
531
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
532
+ # shellcheck disable=SC1091
533
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
534
+ # shellcheck disable=SC1091
535
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
536
+ # shellcheck disable=SC1091
537
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
538
+
539
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
540
+ mkdir -p "$PROJECT_REPO"
541
+ git -C "$PROJECT_REPO" init -q
542
+ git -C "$PROJECT_REPO" config user.email t@example.com
543
+ git -C "$PROJECT_REPO" config user.name "Test"
544
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/lesson-fixture.git
545
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
546
+ [ -n "$PROJECT_KEY" ]
547
+ LESSONS_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}/lessons"
548
+ }
549
+
550
+ @test "storage_init creates the proposals and approved directories" {
551
+ _storage_setup
552
+ librarian_lesson_storage_init "$PROJECT_KEY"
553
+ [ -d "${LESSONS_DIR}/proposals" ]
554
+ [ -d "${LESSONS_DIR}/approved" ]
555
+ }
556
+
557
+ @test "write_proposal lands a ULID-keyed file carrying its artifact_id" {
558
+ _storage_setup
559
+ librarian_lesson_storage_init "$PROJECT_KEY"
560
+ candidate=$(jq -cn '{claim: "c", rationale: "r"}')
561
+ id=$(librarian_lesson_write_proposal "$PROJECT_KEY" "$candidate" "01KZ45MKAM734ZS7JK24D2DK0R")
562
+ [ -n "$id" ]
563
+ [ -f "${LESSONS_DIR}/proposals/${id}.json" ]
564
+ jq -e '.artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R" and .candidate.claim == "c"' \
565
+ "${LESSONS_DIR}/proposals/${id}.json"
566
+ }
567
+
568
+ @test "append_declined writes one JSONL line per decline" {
569
+ _storage_setup
570
+ librarian_lesson_storage_init "$PROJECT_KEY"
571
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
572
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZEAF9EY4C6TTR0V7YFN9VYJ" "no_versions"
573
+ [ "$(wc -l < "${LESSONS_DIR}/declined.jsonl")" -eq 2 ]
574
+ head -n 1 "${LESSONS_DIR}/declined.jsonl" \
575
+ | jq -e '.artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R" and .reason == "no_resolution"'
576
+ }
577
+
578
+ @test "seen reports a fresh artifact as new" {
579
+ _storage_setup
580
+ librarian_lesson_storage_init "$PROJECT_KEY"
581
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
582
+ [ "$status" -eq 1 ]
583
+ }
584
+
585
+ @test "seen finds an artifact recorded in declined.jsonl" {
586
+ _storage_setup
587
+ librarian_lesson_storage_init "$PROJECT_KEY"
588
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
589
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
590
+ [ "$status" -eq 0 ]
591
+ }
592
+
593
+ @test "seen survives a truncated trailing line in declined.jsonl" {
594
+ # What a process kill mid-append leaves behind. One bad line must not make
595
+ # every previously-declined artifact read as new.
596
+ _storage_setup
597
+ librarian_lesson_storage_init "$PROJECT_KEY"
598
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
599
+ printf '{"artifact_id":"01KZEAF9EY4C6TTR0V7YFN9VYJ","reason":"trunc' \
600
+ >> "${LESSONS_DIR}/declined.jsonl"
601
+
602
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
603
+ [ "$status" -eq 0 ]
604
+
605
+ # ...and the repair must not turn every lookup into a hit.
606
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKS84KPZQNWC02Z8FE0K"
607
+ [ "$status" -eq 1 ]
608
+ }
609
+
610
+ @test "seen finds an artifact already sitting in proposals" {
611
+ _storage_setup
612
+ librarian_lesson_storage_init "$PROJECT_KEY"
613
+ librarian_lesson_write_proposal "$PROJECT_KEY" "$(jq -cn '{claim: "c"}')" "01KZ45MKAM734ZS7JK24D2DK0R"
614
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
615
+ [ "$status" -eq 0 ]
616
+ }
617
+
618
+ @test "seen finds an artifact already promoted into the approved pool" {
619
+ _storage_setup
620
+ librarian_lesson_storage_init "$PROJECT_KEY"
621
+ jq -n '{artifact_id: "01KZ45MKAM734ZS7JK24D2DK0R"}' \
622
+ > "${LESSONS_DIR}/approved/01KZ45MKGQ7QZWMABQ4H12SHSV.json"
623
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
624
+ [ "$status" -eq 0 ]
625
+ }
626
+ ```
627
+
628
+ - [ ] **Step 2: Run and watch them fail**
629
+
630
+ Run: `bats test/bats/librarian-lesson-transform.bats`
631
+ Expected: the new tests FAIL with `librarian_lesson_storage_init: command not found`.
632
+
633
+ - [ ] **Step 3: Implement the library**
634
+
635
+ ```bash
636
+ #!/usr/bin/env bash
637
+ # Storage for the lesson subtree.
638
+ #
639
+ # <project_dir>/lessons/proposals/<ulid>.json awaiting human confirmation
640
+ # <project_dir>/lessons/approved/<ulid>.json jury passed (written by 4z8.4)
641
+ # <project_dir>/lessons/declined.jsonl append-only, never re-judged
642
+ #
643
+ # Lessons live apart from librarian's memory `proposals/` on purpose: a memory
644
+ # promotion writes to this machine, a lesson proposal is a step toward
645
+ # publishing beyond it. Separate trees keep a confirmation surface from
646
+ # merging the two by accident.
647
+ #
648
+ # Requires librarian-storage.sh (librarian_project_dir) and librarian-ulid.sh.
649
+
650
+ librarian_lessons_dir() {
651
+ local key="$1"
652
+ printf '%s/lessons' "$(librarian_project_dir "$key")"
653
+ }
654
+
655
+ librarian_lesson_storage_init() {
656
+ local key="$1"
657
+ [[ -z "$key" ]] && return 1
658
+ local dir
659
+ dir=$(librarian_lessons_dir "$key")
660
+ mkdir -p "$dir/proposals" "$dir/approved" 2>/dev/null
661
+ }
662
+
663
+ # Write one candidate. Prints the ULID on success.
664
+ # Usage: librarian_lesson_write_proposal <key> <candidate_json> <artifact_id>
665
+ librarian_lesson_write_proposal() {
666
+ local key="$1"
667
+ local candidate="$2"
668
+ local artifact_id="$3"
669
+ [[ -z "$key" || -z "$candidate" || -z "$artifact_id" ]] && return 1
670
+
671
+ librarian_lesson_storage_init "$key" || return 1
672
+
673
+ local id now out
674
+ id=$(librarian_ulid) || return 1
675
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
676
+ out="$(librarian_lessons_dir "$key")/proposals/${id}.json"
677
+
678
+ jq -n \
679
+ --arg id "$id" \
680
+ --arg artifact_id "$artifact_id" \
681
+ --arg created "$now" \
682
+ --argjson candidate "$candidate" \
683
+ '{
684
+ id: $id,
685
+ artifact_id: $artifact_id,
686
+ created_at: $created,
687
+ status: "pending",
688
+ candidate: $candidate
689
+ }' > "$out" 2>/dev/null || return 1
690
+
691
+ printf '%s' "$id"
692
+ }
693
+
694
+ # Append one decline. Only ever called for real determinations — never for a
695
+ # missing CLI, a timeout, or an empty response. Recording an outage here would
696
+ # bury a good artifact permanently, because the watermark has already moved
697
+ # past it and declined entries are never re-read.
698
+ #
699
+ # Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail]
700
+ librarian_lesson_append_declined() {
701
+ local key="$1"
702
+ local artifact_id="$2"
703
+ local reason="$3"
704
+ local detail="${4:-}"
705
+ [[ -z "$key" || -z "$artifact_id" || -z "$reason" ]] && return 1
706
+
707
+ librarian_lesson_storage_init "$key" || return 1
708
+
709
+ local now line
710
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
711
+ line=$(jq -cn \
712
+ --arg artifact_id "$artifact_id" \
713
+ --arg reason "$reason" \
714
+ --arg detail "$detail" \
715
+ --arg at "$now" \
716
+ '{
717
+ artifact_id: $artifact_id,
718
+ reason: $reason,
719
+ detail: (if $detail == "" then null else $detail end),
720
+ declined_at: $at
721
+ }') || return 1
722
+
723
+ printf '%s\n' "$line" >> "$(librarian_lessons_dir "$key")/declined.jsonl"
724
+ }
725
+
726
+ # Returns 0 when this artifact has already been handled.
727
+ #
728
+ # The watermark cannot answer this: last_scan.json records only *when* we
729
+ # scanned, not which artifacts were considered. Idempotency is artifact-keyed
730
+ # and permanent, unlike tombstones (body-hash keyed, TTL'd).
731
+ #
732
+ # Usage: librarian_lesson_seen <key> <artifact_id>
733
+ librarian_lesson_seen() {
734
+ local key="$1"
735
+ local artifact_id="$2"
736
+ [[ -z "$key" || -z "$artifact_id" ]] && return 1
737
+
738
+ local dir
739
+ dir=$(librarian_lessons_dir "$key")
740
+
741
+ # -R reads each line as a raw string and fromjson? yields nothing for a
742
+ # line that fails to parse, instead of aborting the whole jq invocation.
743
+ # Without this, one truncated trailing line (e.g. a process killed
744
+ # mid-append) makes jq exit 5 for the entire file, and every artifact
745
+ # declined before that line reads back as "not seen."
746
+ #
747
+ # `objects` after fromjson? is load-bearing, not decorative: fromjson?
748
+ # only guards the *parse*, not what comes after it in the pipe. A line
749
+ # that is valid JSON but not an object (a bare `123`, `true`, `"str"`, or
750
+ # `[1,2,3]`) parses cleanly, then `.artifact_id` indexing on that
751
+ # non-object errors out the whole jq invocation — the same
752
+ # every-prior-decline-reads-as-unseen failure the -R/fromjson? guard
753
+ # above exists to prevent, just reached through a different door.
754
+ # `objects` filters those values out before `.artifact_id` ever runs.
755
+ if [[ -f "$dir/declined.jsonl" ]] \
756
+ && jq -Re --arg a "$artifact_id" 'fromjson? | objects | select(.artifact_id == $a)' \
757
+ "$dir/declined.jsonl" >/dev/null 2>&1; then
758
+ return 0
759
+ fi
760
+
761
+ local f
762
+ for f in "$dir"/proposals/*.json "$dir"/approved/*.json; do
763
+ [[ -f "$f" ]] || continue
764
+ if jq -e --arg a "$artifact_id" '.artifact_id == $a' "$f" >/dev/null 2>&1; then
765
+ return 0
766
+ fi
767
+ done
768
+
769
+ return 1
770
+ }
771
+ ```
772
+
773
+ - [ ] **Step 4: Run the tests**
774
+
775
+ Run: `bats test/bats/librarian-lesson-transform.bats`
776
+ Expected: all PASS.
777
+
778
+ - [ ] **Step 5: Shellcheck**
779
+
780
+ Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-storage.sh`
781
+ Expected: no output.
782
+
783
+ - [ ] **Step 6: Commit**
784
+
785
+ ```bash
786
+ git add plugins/librarian/scripts/lib/librarian-lesson-storage.sh \
787
+ test/bats/librarian-lesson-transform.bats
788
+ git commit -m "feat(librarian): keep lesson state in its own subtree, keyed for idempotency :file_folder:"
789
+ ```
790
+
791
+ ---
792
+
793
+ ### Task 4: The transform itself
794
+
795
+ **Files:**
796
+ - Create: `plugins/librarian/scripts/lib/librarian-lesson-transform.sh`
797
+ - Modify: `plugins/librarian/config.json`
798
+ - Test: `test/bats/librarian-lesson-transform.bats` (append)
799
+
800
+ **Interfaces:**
801
+ - Consumes: `librarian_lesson_pregate`, `librarian_lesson_validate_candidate`, `librarian_lesson_write_proposal`, `librarian_lesson_append_declined`, `librarian_lesson_seen`.
802
+ - Produces:
803
+ - `librarian_lesson_build_prompt <artifact_json>` → prints the prompt
804
+ - `librarian_lesson_call <artifact_json> <model>` → prints raw model JSON, or empty on any infrastructure failure
805
+ - `librarian_lesson_transform_one <key> <artifact_json>` → prints `proposed:<ulid>`, `declined:<reason>`, `skipped:pregate`, or `unavailable` (infrastructure). Exit 0 always.
806
+
807
+ - [ ] **Step 1: Add config defaults**
808
+
809
+ In `plugins/librarian/config.json`, add under the `librarian` key:
810
+
811
+ ```json
812
+ "lesson_transform": {
813
+ "model": "claude-haiku-4-5-20251001",
814
+ "timeout_seconds": 20
815
+ }
816
+ ```
817
+
818
+ There is no `enabled` flag — that option was removed repo-wide in #108.
819
+
820
+ **Only two keys, deliberately.** The spec listed `temperature` and
821
+ `max_output_tokens` as well, but `claude --help` exposes `--model` and no
822
+ sampling flags, so those values cannot reach the model through this CLI. The
823
+ existing `librarian-classifier.sh` reads
824
+ `.librarian.classifier.{temperature,max_output_tokens}` and passes them to a
825
+ function that ignores both — dead config that reads as if it works. Do not
826
+ reproduce it here. If sampling control is genuinely needed, it requires moving
827
+ off `claude -p` to the API, which is a separate decision.
828
+
829
+ - [ ] **Step 2: Write the failing tests**
830
+
831
+ Append to the bats file. This block extends `_storage_setup` with a stubbed `claude`:
832
+
833
+ ```bash
834
+ _transform_setup() {
835
+ _storage_setup
836
+ # shellcheck disable=SC1091
837
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
838
+ # shellcheck disable=SC1091
839
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
840
+ librarian_config_load "$PROJECT_REPO"
841
+ # shellcheck disable=SC1091
842
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-transform.sh"
843
+ librarian_lesson_storage_init "$PROJECT_KEY"
844
+
845
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
846
+ mkdir -p "$STUB_BIN"
847
+ cat > "${STUB_BIN}/claude" <<'STUB'
848
+ #!/usr/bin/env bash
849
+ prompt=$(cat)
850
+ # Stub-selector markers are checked before the generic "module-runner"
851
+ # content match: several fixtures embed real vitest/vite prose (which
852
+ # contains "module-runner") alongside their marker, and the marker names
853
+ # the intended stub behavior.
854
+ if [[ "$prompt" == *"no-resolution-stub"* ]]; then
855
+ printf '%s' '{"eligible":false,"reason":"no_resolution"}'
856
+ elif [[ "$prompt" == *"no-versions-stub"* ]]; then
857
+ printf '%s' '{"eligible":false,"reason":"no_versions"}'
858
+ elif [[ "$prompt" == *"npm-range-stub"* ]]; then
859
+ 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":[]}}'
860
+ elif [[ "$prompt" == *"module-runner"* ]]; then
861
+ 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":[]}}'
862
+ else
863
+ printf '%s' 'not json at all'
864
+ fi
865
+ STUB
866
+ chmod +x "${STUB_BIN}/claude"
867
+ export PATH="${STUB_BIN}:${PATH}"
868
+ }
869
+
870
+ _seed() {
871
+ jq -cn --arg id "$1" --arg s "$2" --arg d "$3" --arg k "$PROJECT_KEY" \
872
+ '{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
873
+ created_at: "2026-08-03T15:59:48Z", summary: $s, detail: $d}'
874
+ }
875
+
876
+ @test "transform_one proposes a candidate for a groundable artifact" {
877
+ _transform_setup
878
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
879
+ "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.")
880
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
881
+ [ "$status" -eq 0 ]
882
+ [[ "$output" == proposed:* ]]
883
+ id="${output#proposed:}"
884
+ jq -e '.candidate.applies_to.scope.versions.vite == "<6"
885
+ and .candidate.applies_to.scope.versions.vitest == ">=4"' \
886
+ "${LESSONS_DIR}/proposals/${id}.json"
887
+ }
888
+
889
+ @test "transform_one declines the real vitest artifact for having no resolution" {
890
+ _transform_setup
891
+ # The artifact that motivated the pipeline. Its session ended on an open
892
+ # question — the fix was never found — so it cannot become a lesson.
893
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" \
894
+ "no-resolution-stub: Vitest 4.1.9 / Vite 5.x mismatch confirmed as real, blocking bug." \
895
+ "Running pnpm test reproduces failures. Vitest 4.1.9 attempts to import vite/module-runner which does not exist in Vite 5.4.21.")
896
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
897
+ [ "$status" -eq 0 ]
898
+ [ "$output" = "declined:no_resolution" ]
899
+ tail -n 1 "${LESSONS_DIR}/declined.jsonl" | jq -e '.reason == "no_resolution"'
900
+ }
901
+
902
+ @test "transform_one declines when the model cannot infer versions" {
903
+ _transform_setup
904
+ art=$(_seed "01KZ45MKGQ7QZWMABQ4H12SHSV" "no-versions-stub: 5.4 something" "detail 1.2")
905
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
906
+ [ "$output" = "declined:no_versions" ]
907
+ }
908
+
909
+ @test "transform_one declines unparseable model output as transform_invalid" {
910
+ _transform_setup
911
+ art=$(_seed "01KZ45MKME229J0QK0690TREAB" "garbage 1.0" "detail 2.0")
912
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
913
+ [ "$output" = "declined:transform_invalid" ]
914
+ }
915
+
916
+ @test "transform_one declines an npm-style range as schema_invalid" {
917
+ _transform_setup
918
+ art=$(_seed "01KZ45MKS84KPZQNWC02Z8FE0K" "npm-range-stub 5.4" "detail 1.0")
919
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
920
+ [ "$output" = "declined:schema_invalid" ]
921
+ }
922
+
923
+ @test "transform_one skips a version-free artifact without touching the ledger" {
924
+ _transform_setup
925
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Prefer functional patterns" "User said so.")
926
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
927
+ [ "$output" = "skipped:pregate" ]
928
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
929
+ }
930
+
931
+ @test "a missing claude CLI is not a verdict and writes nothing" {
932
+ _transform_setup
933
+ rm -f "${STUB_BIN}/claude"
934
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
935
+ "Vitest 4.1.9 imports vite/module-runner absent in Vite 5.4.21.")
936
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
937
+ [ "$status" -eq 0 ]
938
+ [ "$output" = "unavailable" ]
939
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
940
+ [ -z "$(ls -A "${LESSONS_DIR}/proposals")" ]
941
+ }
942
+
943
+ @test "an empty model response is not a verdict and writes nothing" {
944
+ _transform_setup
945
+ cat > "${STUB_BIN}/claude" <<'STUB'
946
+ #!/usr/bin/env bash
947
+ cat >/dev/null
948
+ printf ''
949
+ STUB
950
+ chmod +x "${STUB_BIN}/claude"
951
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
952
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
953
+ [ "$output" = "unavailable" ]
954
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
955
+ }
956
+
957
+ @test "an already-declined artifact is not sent to the model a second time" {
958
+ _transform_setup
959
+ librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
960
+ art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
961
+ run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
962
+ [ "$output" = "skipped:seen" ]
963
+ [ "$(wc -l < "${LESSONS_DIR}/declined.jsonl")" -eq 1 ]
964
+ }
965
+ ```
966
+
967
+ - [ ] **Step 3: Run and watch them fail**
968
+
969
+ Run: `bats test/bats/librarian-lesson-transform.bats`
970
+ Expected: the new tests FAIL with `librarian_lesson_transform_one: command not found`.
971
+
972
+ - [ ] **Step 4: Implement the transform**
973
+
974
+ ```bash
975
+ #!/usr/bin/env bash
976
+ # Lesson transform — librarian's fifth stage.
977
+ #
978
+ # Reads one durable, classified, deduped archivist artifact and emits a lesson
979
+ # candidate: the four fields inferable from an artifact (claim, rationale,
980
+ # evidence, applies_to). The other nine required Lesson fields belong to later
981
+ # stages, so this never produces a schema-complete Lesson and cannot be
982
+ # validated against the full lesson schema.
983
+ #
984
+ # Requires librarian-lesson-validate.sh, librarian-lesson-storage.sh, and
985
+ # librarian-config.sh (librarian_config_get).
986
+ #
987
+ # Config inputs (read via librarian_config_get from librarian_lesson_call):
988
+ # librarian.lesson_transform.model Anthropic model id
989
+ # librarian.lesson_transform.timeout_seconds Hard wall-clock ceiling
990
+
991
+ # Fallback when config hasn't been loaded or leaves the key unset.
992
+ _LIBRARIAN_LESSON_DEFAULT_TIMEOUT_SECONDS=20
993
+
994
+ # Cap on how much of a response the JSON-object extractor will scan. The
995
+ # scanner is a per-character bash loop — effectively O(n^2) on long input,
996
+ # since bash string slicing on a long string isn't O(1) per call — and it
997
+ # runs after the claude call, uncapped, inside a SessionEnd hook that must
998
+ # not stall session end. `claude -p` has no output-size flag to bound the
999
+ # response itself, so the bound is enforced here instead. A response that
1000
+ # exceeds this without yielding valid JSON in the scanned prefix has not
1001
+ # followed the "output ONLY a single JSON object on one line" instruction
1002
+ # anyway, so declining it is correct, not just expedient.
1003
+ _LIBRARIAN_LESSON_EXTRACT_MAX_CHARS=8192
1004
+
1005
+ # Usage: librarian_lesson_build_prompt <artifact_json>
1006
+ librarian_lesson_build_prompt() {
1007
+ local artifact="$1"
1008
+ local summary detail files_list artifact_id session_id project_key created_at
1009
+
1010
+ summary=$(printf '%s' "$artifact" | jq -r '.summary // ""')
1011
+ detail=$(printf '%s' "$artifact" | jq -r '.detail // ""')
1012
+ files_list=$(printf '%s' "$artifact" | jq -r '(.files // []) | join(", ")')
1013
+ artifact_id=$(printf '%s' "$artifact" | jq -r '.id // ""')
1014
+ session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
1015
+ project_key=$(printf '%s' "$artifact" | jq -r '.project_key // ""')
1016
+ created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
1017
+
1018
+ cat <<EOF
1019
+ You are turning a session artifact into a shareable lesson, or refusing to.
1020
+
1021
+ A lesson states something that was learned, why it follows, and the exact
1022
+ version range in which it holds. It is shared with other people, so a wrong
1023
+ lesson actively misleads. Refusing is the safe answer.
1024
+
1025
+ Output ONLY one JSON object on one line. No markdown fences, no prose.
1026
+
1027
+ REFUSE when either is true, by outputting exactly:
1028
+ { "eligible": false, "reason": "no_resolution" }
1029
+ { "eligible": false, "reason": "no_versions" }
1030
+
1031
+ - "no_resolution": the artifact records a problem but not what resolved it.
1032
+ "This breaks" without "and this fixed it" is a warning, not a lesson.
1033
+ Never invent a resolution that is not in the artifact.
1034
+ - "no_versions": you cannot determine which versions the claim is bound to.
1035
+
1036
+ Otherwise output:
1037
+ {
1038
+ "claim": "<what was learned, one sentence>",
1039
+ "rationale": "<why the claim follows from the evidence>",
1040
+ "evidence": { "resolution": "<what actually resolved it, from the artifact>" },
1041
+ "applies_to": {
1042
+ "stack": ["<tool or package name>", "<another tool or package name>"],
1043
+ "scope": { "kind": "versioned", "versions": { "<stack entry>": "<range>" } },
1044
+ "file_patterns": [],
1045
+ "task_kinds": []
1046
+ }
1047
+ }
1048
+
1049
+ VERSION RANGE RULES — these are strict and a violation is discarded:
1050
+ - Allowed: "<6", "<=6", "=6", ">4", ">=4", or two-sided ">=4 <6".
1051
+ - FORBIDDEN: npm syntax. Never "^5.4.21", "~5", "5.x", or a bare "5.4.21".
1052
+ - FORBIDDEN: ">=0", ">=0.0", ">=0.0.0". An unbounded lower bound matches
1053
+ everything and would never expire.
1054
+ - Every key in versions MUST also appear in stack.
1055
+ - Generalize honestly. Observing a break on vite 5.4.21 with vitest 4.1.9
1056
+ supports {"vite": "<6", "vitest": ">=4"} only if the cause is the missing
1057
+ API rather than that exact build.
1058
+
1059
+ There is no version-independent option. If the claim is not bound to a
1060
+ version range, refuse with "no_versions".
1061
+
1062
+ <artifact>
1063
+ id: ${artifact_id}
1064
+ summary: ${summary}
1065
+ detail: ${detail}
1066
+ files: ${files_list}
1067
+ project_key: ${project_key}
1068
+ session_id: ${session_id}
1069
+ created_at: ${created_at}
1070
+ </artifact>
1071
+ EOF
1072
+ }
1073
+
1074
+ # Extract the first balanced top-level JSON object from a string that may
1075
+ # carry surrounding prose ("Here is the JSON: {...}"). Prints the substring
1076
+ # on success, prints nothing and returns 1 on failure. Depth-tracks braces
1077
+ # while skipping ones inside string literals (honoring backslash escapes),
1078
+ # so a claim like `{"claim": "uses \"quotes\" and { in prose"}` still
1079
+ # extracts correctly.
1080
+ #
1081
+ # Prose wrapping is not the same failure as unparseable output: a model that
1082
+ # added a sentence around otherwise-valid JSON would very likely produce
1083
+ # clean JSON on a resample, so declining it as transform_invalid would bury
1084
+ # a good artifact over formatting noise rather than a real judgment problem.
1085
+ #
1086
+ # Usage: _librarian_lesson_extract_json_object <text>
1087
+ _librarian_lesson_extract_json_object() {
1088
+ local text="$1"
1089
+ local start=-1 depth=0 in_string=0 escape=0
1090
+ local i len ch
1091
+
1092
+ len=${#text}
1093
+ for (( i = 0; i < len; i++ )); do
1094
+ ch="${text:i:1}"
1095
+ if [[ $start -eq -1 ]]; then
1096
+ [[ "$ch" == "{" ]] && { start=$i; depth=1; }
1097
+ continue
1098
+ fi
1099
+ if [[ $escape -eq 1 ]]; then
1100
+ escape=0
1101
+ continue
1102
+ fi
1103
+ case "$ch" in
1104
+ '\') [[ $in_string -eq 1 ]] && escape=1 ;;
1105
+ '"') in_string=$((1 - in_string)) ;;
1106
+ '{') [[ $in_string -eq 0 ]] && depth=$((depth + 1)) ;;
1107
+ '}')
1108
+ if [[ $in_string -eq 0 ]]; then
1109
+ depth=$((depth - 1))
1110
+ if [[ $depth -eq 0 ]]; then
1111
+ printf '%s' "${text:start:i-start+1}"
1112
+ return 0
1113
+ fi
1114
+ fi
1115
+ ;;
1116
+ esac
1117
+ done
1118
+
1119
+ return 1
1120
+ }
1121
+
1122
+ # Call the model. Prints raw output, or empty string on ANY infrastructure
1123
+ # failure — missing CLI, timeout, empty response. Empty means "could not
1124
+ # judge", which is not a verdict.
1125
+ #
1126
+ # Usage: librarian_lesson_call <artifact_json> <model>
1127
+ librarian_lesson_call() {
1128
+ local artifact="$1"
1129
+ local model="${2:-}"
1130
+
1131
+ command -v claude >/dev/null 2>&1 || return 0
1132
+ [[ -z "$artifact" ]] && return 0
1133
+
1134
+ local prompt_file
1135
+ prompt_file=$(mktemp -t librarian-lesson.XXXXXX 2>/dev/null) \
1136
+ || prompt_file="/tmp/librarian-lesson.$$"
1137
+ # shellcheck disable=SC2064
1138
+ trap "rm -f '$prompt_file'" EXIT
1139
+
1140
+ librarian_lesson_build_prompt "$artifact" > "$prompt_file" || return 0
1141
+
1142
+ local args=(-p --max-turns 1)
1143
+ [[ -n "$model" ]] && args+=(--model "$model")
1144
+
1145
+ local timeout_seconds
1146
+ timeout_seconds=$(librarian_config_get '.librarian.lesson_transform.timeout_seconds' 2>/dev/null)
1147
+ [[ -z "$timeout_seconds" || "$timeout_seconds" == "null" ]] \
1148
+ && timeout_seconds="$_LIBRARIAN_LESSON_DEFAULT_TIMEOUT_SECONDS"
1149
+
1150
+ local response=""
1151
+ if command -v timeout >/dev/null 2>&1; then
1152
+ response=$(timeout "$timeout_seconds" \
1153
+ claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
1154
+ elif command -v gtimeout >/dev/null 2>&1; then
1155
+ response=$(gtimeout "$timeout_seconds" \
1156
+ claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
1157
+ else
1158
+ response=$(claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
1159
+ fi
1160
+
1161
+ rm -f "$prompt_file"
1162
+ trap - EXIT
1163
+
1164
+ [[ -z "$response" ]] && return 0
1165
+
1166
+ local cleaned
1167
+ cleaned=$(printf '%s' "$response" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
1168
+
1169
+ # Fast path: the response is already valid JSON on its own.
1170
+ if printf '%s' "$cleaned" | jq -e . >/dev/null 2>&1; then
1171
+ printf '%s' "$cleaned"
1172
+ return 0
1173
+ fi
1174
+
1175
+ # Slow path: pull the first balanced JSON object out of surrounding
1176
+ # prose. Bounded to a fixed prefix (see _LIBRARIAN_LESSON_EXTRACT_MAX_CHARS)
1177
+ # so a rambling, arbitrarily long response can't turn the O(n^2) scan
1178
+ # into an unbounded stall. Only used when it actually recovers valid
1179
+ # JSON — otherwise fall through to the original text so the
1180
+ # unparseable case still declines.
1181
+ local extracted
1182
+ extracted=$(_librarian_lesson_extract_json_object \
1183
+ "${cleaned:0:_LIBRARIAN_LESSON_EXTRACT_MAX_CHARS}")
1184
+ if [[ -n "$extracted" ]] && printf '%s' "$extracted" | jq -e . >/dev/null 2>&1; then
1185
+ printf '%s' "$extracted"
1186
+ return 0
1187
+ fi
1188
+
1189
+ printf '%s' "$cleaned"
1190
+ }
1191
+
1192
+ # Transform one artifact. Always exits 0. Prints exactly one of:
1193
+ # proposed:<ulid> candidate written
1194
+ # declined:<reason> a real verdict, recorded in declined.jsonl
1195
+ # skipped:pregate no version token; free to redo, nothing recorded
1196
+ # skipped:seen already handled
1197
+ # unavailable infrastructure failure; nothing recorded
1198
+ #
1199
+ # Usage: librarian_lesson_transform_one <key> <artifact_json>
1200
+ librarian_lesson_transform_one() {
1201
+ local key="$1"
1202
+ local artifact="$2"
1203
+ [[ -z "$key" || -z "$artifact" ]] && { printf 'unavailable'; return 0; }
1204
+
1205
+ local artifact_id session_id project_key created_at
1206
+ artifact_id=$(printf '%s' "$artifact" | jq -r '.id // ""')
1207
+ session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
1208
+ project_key=$(printf '%s' "$artifact" | jq -r '.project_key // ""')
1209
+ created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
1210
+ [[ -z "$artifact_id" ]] && { printf 'unavailable'; return 0; }
1211
+
1212
+ if librarian_lesson_seen "$key" "$artifact_id"; then
1213
+ printf 'skipped:seen'
1214
+ return 0
1215
+ fi
1216
+
1217
+ if ! librarian_lesson_pregate "$artifact"; then
1218
+ printf 'skipped:pregate'
1219
+ return 0
1220
+ fi
1221
+
1222
+ local model raw
1223
+ model=$(librarian_config_get '.librarian.lesson_transform.model')
1224
+
1225
+ raw=$(librarian_lesson_call "$artifact" "$model")
1226
+
1227
+ # Empty means infrastructure, not verdict. Leave the artifact untouched.
1228
+ if [[ -z "$raw" ]]; then
1229
+ printf 'unavailable'
1230
+ return 0
1231
+ fi
1232
+
1233
+ if ! printf '%s' "$raw" | jq -e . >/dev/null 2>&1; then
1234
+ librarian_lesson_append_declined "$key" "$artifact_id" "transform_invalid"
1235
+ printf 'declined:transform_invalid'
1236
+ return 0
1237
+ fi
1238
+
1239
+ # An explicit refusal is a real answer. Checked with jq -e rather than a
1240
+ # `// empty` string capture: jq's // operator treats JSON `false` as
1241
+ # falsy, same as null, so `.eligible // empty` silently discards a real
1242
+ # `"eligible": false` refusal instead of reporting it.
1243
+ local reason
1244
+ if printf '%s' "$raw" | jq -e '.eligible == false' >/dev/null 2>&1; then
1245
+ reason=$(printf '%s' "$raw" | jq -r '.reason // "transform_invalid"')
1246
+ case "$reason" in
1247
+ no_resolution|no_versions) ;;
1248
+ *) reason="transform_invalid" ;;
1249
+ esac
1250
+ librarian_lesson_append_declined "$key" "$artifact_id" "$reason"
1251
+ printf 'declined:%s' "$reason"
1252
+ return 0
1253
+ fi
1254
+
1255
+ # Stitch in the provenance the model is not asked to produce.
1256
+ local candidate
1257
+ candidate=$(printf '%s' "$raw" | jq -c \
1258
+ --arg aid "$artifact_id" \
1259
+ --arg sid "$session_id" \
1260
+ --arg pk "$project_key" \
1261
+ --arg at "$created_at" \
1262
+ '.evidence.artifact_ids = [$aid]
1263
+ | .evidence.session_ids = [$sid]
1264
+ | .evidence.project_key = $pk
1265
+ | .evidence.observed_at = $at' 2>/dev/null) || candidate=""
1266
+
1267
+ if [[ -z "$candidate" ]]; then
1268
+ librarian_lesson_append_declined "$key" "$artifact_id" "transform_invalid"
1269
+ printf 'declined:transform_invalid'
1270
+ return 0
1271
+ fi
1272
+
1273
+ if ! librarian_lesson_validate_candidate "$candidate" 2>/dev/null; then
1274
+ librarian_lesson_append_declined "$key" "$artifact_id" "schema_invalid"
1275
+ printf 'declined:schema_invalid'
1276
+ return 0
1277
+ fi
1278
+
1279
+ local id
1280
+ id=$(librarian_lesson_write_proposal "$key" "$candidate" "$artifact_id") || {
1281
+ printf 'unavailable'
1282
+ return 0
1283
+ }
1284
+ printf 'proposed:%s' "$id"
1285
+ }
1286
+ ```
1287
+
1288
+ - [ ] **Step 5: Run the tests**
1289
+
1290
+ Run: `bats test/bats/librarian-lesson-transform.bats`
1291
+ Expected: all PASS.
1292
+
1293
+ `_transform_setup` sources `librarian-config.sh` and calls `librarian_config_load "$PROJECT_REPO"` unconditionally (see Step 2) — not doing so is a real failure mode worth naming: `librarian_lesson_transform_one` calls `librarian_config_get`, which reads the `_LIBRARIAN_CONFIG` global, and without a load that global is `{}` and the model lookup returns empty. The call still succeeds (an empty model just omits `--model`), so skipping the load would leave the config path silently unexercised rather than failing loudly.
1294
+
1295
+ - [ ] **Step 6: Shellcheck**
1296
+
1297
+ Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-transform.sh`
1298
+ Expected: no output.
1299
+
1300
+ - [ ] **Step 7: Commit**
1301
+
1302
+ ```bash
1303
+ git add plugins/librarian/scripts/lib/librarian-lesson-transform.sh \
1304
+ plugins/librarian/config.json test/bats/librarian-lesson-transform.bats
1305
+ git commit -m "feat(librarian): transform artifacts into lesson candidates, refusing what it cannot ground :microscope:"
1306
+ ```
1307
+
1308
+ ---
1309
+
1310
+ ### Task 5: Wire the stage into SessionEnd
1311
+
1312
+ **Files:**
1313
+ - Modify: `plugins/librarian/scripts/hooks/librarian-session-end.sh`
1314
+ - Test: `test/bats/librarian-lesson-transform.bats` (append)
1315
+
1316
+ **Interfaces:**
1317
+ - Consumes: `librarian_lesson_transform_one <key> <artifact_json>` from Task 4.
1318
+ - Produces: no new functions. The hook runs the stage over the same filtered artifact set the classifier sees.
1319
+
1320
+ - [ ] **Step 1: Write the failing end-to-end test**
1321
+
1322
+ ```bash
1323
+ @test "SessionEnd runs the lesson stage and lands a candidate on disk" {
1324
+ _transform_setup
1325
+
1326
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/librarian-session-end.sh"
1327
+ ARCHIVIST_DIR="${ONLOOKER_DIR}/archivist/${PROJECT_KEY}"
1328
+ mkdir -p "${ARCHIVIST_DIR}/decisions"
1329
+
1330
+ created_at=$(relative_iso_days_ago 1)
1331
+ jq -n --arg id "01KZ45MKAM734ZS7JK24D2DK0R" --arg at "$created_at" --arg k "$PROJECT_KEY" \
1332
+ '{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
1333
+ created_at: $at, updated_at: $at,
1334
+ summary: "Vitest 4.1.9 / Vite 5.x mismatch, decided to pin",
1335
+ detail: "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.",
1336
+ files: ["packages/db"]}' \
1337
+ > "${ARCHIVIST_DIR}/decisions/01KZ45MKAM734ZS7JK24D2DK0R.json"
1338
+
1339
+ input=$(jq -cn --arg cwd "$PROJECT_REPO" \
1340
+ '{cwd: $cwd, session_id: "sess-1", hook_event_name: "SessionEnd"}')
1341
+ run bash -c "printf '%s' '$input' | '$HOOK'"
1342
+
1343
+ [ "$status" -eq 0 ]
1344
+ [ -n "$(ls -A "${LESSONS_DIR}/proposals" 2>/dev/null)" ]
1345
+ }
1346
+ ```
1347
+
1348
+ - [ ] **Step 2: Run it and watch it fail**
1349
+
1350
+ Run: `bats test/bats/librarian-lesson-transform.bats`
1351
+ Expected: FAIL — the proposals directory is empty, because the hook does not run the stage yet.
1352
+
1353
+ - [ ] **Step 3: Source the new libs in the hook**
1354
+
1355
+ In `plugins/librarian/scripts/hooks/librarian-session-end.sh`, after the existing `librarian-conflict-detector.sh` source line (around line 61), add:
1356
+
1357
+ ```bash
1358
+ # shellcheck source=../lib/librarian-lesson-validate.sh
1359
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
1360
+ # shellcheck source=../lib/librarian-lesson-storage.sh
1361
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
1362
+ # shellcheck source=../lib/librarian-lesson-transform.sh
1363
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-transform.sh"
1364
+ ```
1365
+
1366
+ - [ ] **Step 4: Run the stage over the filtered artifacts**
1367
+
1368
+ Iterate `$KEPT`, **not** `$FILTERED`. `librarian_durability_filter` returns an
1369
+ object `{kept: [...], dropped: [...]}` (hook line 151); `KEPT` is the array
1370
+ extracted from it at line 152, and `KEPT_COUNT` is already computed at line 208.
1371
+ Iterating `FILTERED` directly would yield the two arrays, not artifacts.
1372
+
1373
+ Match the classifier loop's idiom — a C-style index loop with `jq -c ".[$i]"` —
1374
+ rather than introducing a `while read` pattern the file does not use. Use
1375
+ distinct variable names so nothing from the classifier loop is clobbered.
1376
+
1377
+ Add after the classifier loop completes, before the watermark is written:
1378
+
1379
+ ```bash
1380
+ # ---------------------------------------------------------------------------
1381
+ # Stage 5 — lesson transform.
1382
+ #
1383
+ # Runs over the same durability survivors the classifier saw. Each artifact is
1384
+ # independent: a decline or an outage on one never stops the rest.
1385
+ # ---------------------------------------------------------------------------
1386
+ LESSON_PROPOSED=0
1387
+ LESSON_DECLINED=0
1388
+
1389
+ for ((li = 0; li < KEPT_COUNT; li++)); do
1390
+ LESSON_ARTIFACT=$(printf '%s' "$KEPT" | jq -c ".[$li]")
1391
+ [[ -z "$LESSON_ARTIFACT" || "$LESSON_ARTIFACT" == "null" ]] && continue
1392
+
1393
+ LESSON_RESULT=$(librarian_lesson_transform_one "$PROJECT_KEY" "$LESSON_ARTIFACT")
1394
+ case "$LESSON_RESULT" in
1395
+ proposed:*) LESSON_PROPOSED=$((LESSON_PROPOSED + 1)) ;;
1396
+ declined:*) LESSON_DECLINED=$((LESSON_DECLINED + 1)) ;;
1397
+ esac
1398
+ done
1399
+ ```
1400
+
1401
+ - [ ] **Step 5: Run the tests**
1402
+
1403
+ Run: `bats test/bats/librarian-lesson-transform.bats`
1404
+ Expected: all PASS.
1405
+
1406
+ - [ ] **Step 6: Confirm nothing upstream regressed**
1407
+
1408
+ Run: `bats test/bats/librarian-session-end.bats && bats test/bats/librarian-session-start.bats && bats test/bats/librarian-cli.bats`
1409
+ Expected: all PASS. The existing suite must be unaffected — the new stage only appends.
1410
+
1411
+ - [ ] **Step 7: Full check**
1412
+
1413
+ Run: `npm run test:ci`
1414
+ Expected: exit 0.
1415
+
1416
+ - [ ] **Step 8: Commit**
1417
+
1418
+ ```bash
1419
+ git add plugins/librarian/scripts/hooks/librarian-session-end.sh \
1420
+ test/bats/librarian-lesson-transform.bats
1421
+ git commit -m "feat(librarian): run the lesson stage at session end :link:"
1422
+ ```
1423
+
1424
+ ---
1425
+
1426
+ ### Task 6: Emit the stage's events
1427
+
1428
+ **Blocked on a cross-repo change.** `librarian.lesson.proposed` and `librarian.lesson.declined` must first be registered in `@onlooker-community/schema` (a separate published package) and the `devDependency` bumped here. Until then the emitter rejects both types in dev and CI and exits 1. Verify with:
1429
+
1430
+ ```bash
1431
+ printf '%s' '{"plugin":"librarian","session_id":"probe","event_type":"librarian.lesson.proposed","payload":{}}' \
1432
+ | node scripts/lib/onlooker-event.mjs emit
1433
+ ```
1434
+
1435
+ Expected once unblocked: a JSON event on stdout rather than a `/event_type must be equal to one of the allowed values` error.
1436
+
1437
+ **Files:**
1438
+ - Modify: `plugins/librarian/scripts/hooks/librarian-session-end.sh`
1439
+ - Test: `test/bats/librarian-lesson-transform.bats` (append)
1440
+
1441
+ **Interfaces:**
1442
+ - Consumes: `librarian_emit <event_type> <session_id> <payload_json>` from `librarian-emit.sh`.
1443
+ - Produces: two event types on the bus.
1444
+
1445
+ - [ ] **Step 1: Write the failing test**
1446
+
1447
+ ```bash
1448
+ @test "SessionEnd emits a schema-valid lesson.proposed event" {
1449
+ _transform_setup
1450
+
1451
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/librarian-session-end.sh"
1452
+ ARCHIVIST_DIR="${ONLOOKER_DIR}/archivist/${PROJECT_KEY}"
1453
+ mkdir -p "${ARCHIVIST_DIR}/decisions"
1454
+
1455
+ created_at=$(relative_iso_days_ago 1)
1456
+ jq -n --arg id "01KZ45MKAM734ZS7JK24D2DK0R" --arg at "$created_at" --arg k "$PROJECT_KEY" \
1457
+ '{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
1458
+ created_at: $at, updated_at: $at,
1459
+ summary: "Vitest 4.1.9 / Vite 5.x mismatch, decided to pin",
1460
+ detail: "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.",
1461
+ files: ["packages/db"]}' \
1462
+ > "${ARCHIVIST_DIR}/decisions/01KZ45MKAM734ZS7JK24D2DK0R.json"
1463
+
1464
+ input=$(jq -cn --arg cwd "$PROJECT_REPO" \
1465
+ '{cwd: $cwd, session_id: "sess-1", hook_event_name: "SessionEnd"}')
1466
+ run bash -c "printf '%s' '$input' | '$HOOK'"
1467
+ [ "$status" -eq 0 ]
1468
+
1469
+ grep '"event_type":"librarian.lesson.proposed"' "$ONLOOKER_EVENTS_LOG" \
1470
+ | jq -e '.payload.source_artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R"' >/dev/null
1471
+
1472
+ grep '"event_type":"librarian.lesson.proposed"' "$ONLOOKER_EVENTS_LOG" | tail -n 1 \
1473
+ | ONLOOKER_DIR="$ONLOOKER_DIR" node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
1474
+ }
1475
+ ```
1476
+
1477
+ - [ ] **Step 2: Run it and watch it fail**
1478
+
1479
+ Run: `bats test/bats/librarian-lesson-transform.bats`
1480
+ Expected: FAIL — no matching line in the event log.
1481
+
1482
+ - [ ] **Step 3: Emit from the stage loop**
1483
+
1484
+ Extend the `case` added in Task 5:
1485
+
1486
+ ```bash
1487
+ case "$LESSON_RESULT" in
1488
+ proposed:*)
1489
+ LESSON_PROPOSED=$((LESSON_PROPOSED + 1))
1490
+ librarian_emit "librarian.lesson.proposed" "$SESSION_ID" "$(jq -cn \
1491
+ --arg lesson_id "${LESSON_RESULT#proposed:}" \
1492
+ --arg src "$(printf '%s' "$LESSON_ARTIFACT" | jq -r '.id // ""')" \
1493
+ '{ lesson_id: $lesson_id, source_artifact_id: $src }')"
1494
+ ;;
1495
+ declined:*)
1496
+ LESSON_DECLINED=$((LESSON_DECLINED + 1))
1497
+ librarian_emit "librarian.lesson.declined" "$SESSION_ID" "$(jq -cn \
1498
+ --arg reason "${LESSON_RESULT#declined:}" \
1499
+ --arg src "$(printf '%s' "$LESSON_ARTIFACT" | jq -r '.id // ""')" \
1500
+ '{ reason: $reason, source_artifact_id: $src }')"
1501
+ ;;
1502
+ esac
1503
+ ```
1504
+
1505
+ `SESSION_ID` is already set at `librarian-session-end.sh:76` from the hook payload, defaulting to `"unknown"`, and is the same variable the existing `librarian_emit` calls use.
1506
+
1507
+ - [ ] **Step 4: Run the tests**
1508
+
1509
+ Run: `bats test/bats/librarian-lesson-transform.bats`
1510
+ Expected: all PASS.
1511
+
1512
+ - [ ] **Step 5: Full check**
1513
+
1514
+ Run: `npm run test:ci`
1515
+ Expected: exit 0.
1516
+
1517
+ - [ ] **Step 6: Commit**
1518
+
1519
+ ```bash
1520
+ git add plugins/librarian/scripts/hooks/librarian-session-end.sh \
1521
+ test/bats/librarian-lesson-transform.bats
1522
+ git commit -m "feat(librarian): put lesson proposals and declines on the event bus :satellite:"
1523
+ ```
1524
+
1525
+ ---
1526
+
1527
+ ## Self-review notes
1528
+
1529
+ **Spec coverage.** Every section of the spec maps to a task: placement and storage → Tasks 3 and 5; the three steps → Tasks 2 and 4; validation and the vendored sub-schemas → Tasks 1 and 2; the failure taxonomy → Task 4's tests, with all eight rows covered; idempotency → Task 3; testing → tests inside each task; events → Task 6.
1530
+
1531
+ **Deliberately deferred.** The spec's *Boundary changes* section notes that `4z8.2` gains the `version_independent` human path. That is not in this plan — it belongs to that issue. `4z8.4` reusing `librarian_lesson_append_declined` is satisfied by Task 3 creating it here.
1532
+
1533
+ **Known gap.** The CI drift guard in Task 1 is weaker than the event-schema equivalent, because `schema.onlooker.dev` currently serves no lesson schema. It pins provenance and structure but cannot detect an upstream contract change. File a follow-up to upgrade it once lesson schemas are published.
1534
+
1535
+ **Two corrections to the spec, folded in here and back into the spec itself.** The spec claimed the CI drift guard could fetch from `schema.onlooker.dev`; it cannot, and the note above replaces that. The spec also listed four config keys, but `claude -p` accepts no sampling flags, so `temperature` and `max_output_tokens` would be dead config — Task 4 ships two keys instead.
1536
+
1537
+ **Adjacent bug found, not fixed here.** `librarian-classifier.sh` reads `.librarian.classifier.temperature` and `.max_output_tokens` and passes them to `librarian_classifier_call`, which ignores both. The config reads as though it tunes the classifier and does not. Out of scope for this plan — worth its own issue.