@onlooker-community/ecosystem 0.34.0 → 0.43.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 (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -0,0 +1,1273 @@
1
+ # Lesson Confirmation 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:** Let a human pick which lesson candidates go to the jury, and their visibility, before any expensive tokens are spent.
6
+
7
+ **Architecture:** Four bash units with clean seams — a second validator that permits the `version_independent` branch a human may assert, a state-transition lib over the proposal files stage 5 already writes, a nested `librarian_cli lessons <verb>` dispatch, and the two user-facing surfaces (SessionStart counter, `/librarian lessons` skill route). No model calls anywhere in this stage; no event emission, because the types are unregistered.
8
+
9
+ **Tech Stack:** bash, `jq`, `bats`, `node:test` (agreement test only).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Bash only. `jq` for JSON. No Python in shipped code.
16
+ - Always `${ONLOOKER_DIR:-$HOME/.onlooker}` — never a literal `~/.onlooker`.
17
+ - ULIDs via `librarian_ulid`, never UUIDs.
18
+ - Repo shell style is **TAB-indented**. `shellcheck -S error -x` must be clean.
19
+ - **Never inline braces in a parameter-expansion default.** `${N:-{\}}` and `${N:-{}}` are both wrong. Default in a separate statement: `local p="${3:-}"` then `[ -z "$p" ] && p='{}'`. Guarded by `test/bats/emit-payload-default.bats`.
20
+ - bats runs under macOS bash 3.2, where a failing **non-final** `[[ ]]` does not fail the test. Use `[ ]`, or append `|| return 1`. Break each new assertion once to confirm it discriminates.
21
+ - **This stage must never invoke a model.** No `claude`, no network.
22
+ - Visibility values are exactly `private`, `org`, `public`.
23
+ - `version_independent` requires visibility `org` or `public`.
24
+ - American English. Commit style `<type>(<scope>): <subject> :emoji:` with a why-focused body.
25
+
26
+ ## File Structure
27
+
28
+ | File | Responsibility |
29
+ |---|---|
30
+ | `plugins/librarian/scripts/lib/librarian-lesson-validate.sh` | Modified: extract the shared structural clause; add `librarian_lesson_validate_confirmed` |
31
+ | `plugins/librarian/scripts/lib/librarian-lesson-review.sh` | Create: state transitions (`confirm` / `pass`), `passed.jsonl` append, pending listing |
32
+ | `plugins/librarian/scripts/lib/librarian-cli.sh` | Modified: nested `lessons` dispatch + six verbs |
33
+ | `plugins/librarian/scripts/hooks/librarian-session-start.sh` | Modified: second one-line count |
34
+ | `plugins/librarian/skills/librarian/SKILL.md` | Modified: route `/librarian lessons` |
35
+ | `test/bats/librarian-lesson-review.bats` | Create: all bats coverage for this stage |
36
+ | `test/node/lesson-validate-agreement.test.mjs` | Modified: cover the confirmed validator |
37
+
38
+ **Why the validator is refactored rather than copied.** `librarian_lesson_validate_candidate` is ~50 lines of `jq` mirroring the vendored sub-schemas. Copying it to add one branch is how the two mechanisms drifted apart the first time — that drift shipped, and cost a fix round to find. Extracting the shared clause means a schema change is fixed in one place. The refactor is protected by `test/node/lesson-validate-agreement.test.mjs`, which drives the real bash function against ajv.
39
+
40
+ ---
41
+
42
+ ### Task 1: The confirmed-candidate validator
43
+
44
+ **Files:**
45
+ - Modify: `plugins/librarian/scripts/lib/librarian-lesson-validate.sh`
46
+ - Modify: `test/node/lesson-validate-agreement.test.mjs`
47
+ - Test: `test/bats/librarian-lesson-review.bats` (create)
48
+
49
+ **Interfaces:**
50
+ - Consumes: `librarian_lesson_valid_range <string>` (exists).
51
+ - Produces: `librarian_lesson_validate_confirmed <candidate_json>` → exit 0 valid, 1 invalid (reason `schema_invalid` on stderr). Accepts either scope branch; `version_independent` requires a non-empty `justification`.
52
+
53
+ - [ ] **Step 1: Write the failing tests**
54
+
55
+ Create `test/bats/librarian-lesson-review.bats`:
56
+
57
+ ```bash
58
+ #!/usr/bin/env bats
59
+ #
60
+ # Lesson confirmation: the human intent filter between the transform and the
61
+ # jury. Assertions use [ ] or `|| return 1` so every one gates under bash 3.2.
62
+
63
+ setup() {
64
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
65
+ setup_test_env
66
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
67
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
68
+ # shellcheck disable=SC1091
69
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
70
+ }
71
+
72
+ _evidence() {
73
+ printf '%s' '{"artifact_ids":["01KZ45MKAM734ZS7JK24D2DK0R"],"session_ids":["s1"],"project_key":"6a7678979e31","observed_at":"2026-08-03T15:59:48Z","resolution":"Pin vitest to 3.x."}'
74
+ }
75
+
76
+ # Usage: _candidate <scope_json>
77
+ _candidate() {
78
+ jq -cn --argjson ev "$(_evidence)" --argjson scope "$1" \
79
+ '{claim: "c", rationale: "r", evidence: $ev,
80
+ applies_to: {stack: ["vite"], scope: $scope, file_patterns: [], task_kinds: []}}'
81
+ }
82
+
83
+ _versioned() { printf '%s' '{"kind":"versioned","versions":{"vite":"<6"}}'; }
84
+ _indep() { printf '%s' '{"kind":"version_independent","justification":"git aborts checkout on a dirty tree regardless of version."}'; }
85
+
86
+ @test "confirmed validator accepts a versioned candidate" {
87
+ run librarian_lesson_validate_confirmed "$(_candidate "$(_versioned)")"
88
+ [ "$status" -eq 0 ]
89
+ }
90
+
91
+ @test "confirmed validator accepts version_independent with a justification" {
92
+ run librarian_lesson_validate_confirmed "$(_candidate "$(_indep)")"
93
+ [ "$status" -eq 0 ]
94
+ }
95
+
96
+ @test "confirmed validator rejects version_independent with an empty justification" {
97
+ run librarian_lesson_validate_confirmed \
98
+ "$(_candidate '{"kind":"version_independent","justification":""}')"
99
+ [ "$status" -eq 1 ]
100
+ }
101
+
102
+ @test "confirmed validator rejects version_independent with no justification key" {
103
+ run librarian_lesson_validate_confirmed \
104
+ "$(_candidate '{"kind":"version_independent"}')"
105
+ [ "$status" -eq 1 ]
106
+ }
107
+
108
+ @test "confirmed validator rejects an unknown scope kind" {
109
+ run librarian_lesson_validate_confirmed \
110
+ "$(_candidate '{"kind":"whenever","justification":"x"}')"
111
+ [ "$status" -eq 1 ]
112
+ }
113
+
114
+ @test "confirmed validator still enforces the range pattern on versioned scope" {
115
+ run librarian_lesson_validate_confirmed \
116
+ "$(_candidate '{"kind":"versioned","versions":{"vite":"^5.4.21"}}')"
117
+ [ "$status" -eq 1 ]
118
+ }
119
+
120
+ @test "confirmed validator still enforces provenance" {
121
+ candidate=$(_candidate "$(_versioned)" | jq -c '.evidence.session_ids = [""]')
122
+ run librarian_lesson_validate_confirmed "$candidate"
123
+ [ "$status" -eq 1 ]
124
+ }
125
+
126
+ @test "confirmed validator still enforces the versions-subset-of-stack rule" {
127
+ candidate=$(_candidate '{"kind":"versioned","versions":{"vite":"<6","vitest":">=4"}}')
128
+ run librarian_lesson_validate_confirmed "$candidate"
129
+ [ "$status" -eq 1 ]
130
+ }
131
+
132
+ @test "the transform validator still refuses version_independent" {
133
+ run librarian_lesson_validate_candidate "$(_candidate "$(_indep)")"
134
+ [ "$status" -eq 1 ]
135
+ }
136
+ ```
137
+
138
+ - [ ] **Step 2: Run and watch them fail**
139
+
140
+ Run: `bats test/bats/librarian-lesson-review.bats`
141
+ Expected: FAIL — `librarian_lesson_validate_confirmed: command not found`.
142
+
143
+ - [ ] **Step 3: Extract the shared structural clause**
144
+
145
+ In `librarian-lesson-validate.sh`, above `librarian_lesson_validate_candidate`, add:
146
+
147
+ ```bash
148
+ # Everything both validators check, excluding the scope branch. Kept in one
149
+ # place because it mirrors the vendored sub-schemas line for line; a second
150
+ # copy is how the jq rules and the schema drifted apart once already.
151
+ #
152
+ # Composed into a jq program by each validator, which appends its own scope
153
+ # clause.
154
+ #
155
+ # The mirrors below are NOT decorative, and both reasons were learned the
156
+ # hard way — do not delete them as redundant with the schema:
157
+ #
158
+ # The `keys - [...]` checks mirror `additionalProperties: false` and the
159
+ # `all(type == "string" and length > 0)` checks mirror the array items'
160
+ # `minLength: 1`. Without them, a model that "helpfully" adds an extra
161
+ # field produces a proposal that passes here but fails ajv against the
162
+ # contract it claims to satisfy — and lessons are shared with other
163
+ # people, so that lands on someone else's machine.
164
+ #
165
+ # The ULID, RFC3339 and non-empty-string patterns on evidence exist
166
+ # because a provenance-less artifact (session_id/created_at stitched in
167
+ # as "") must fail HERE. If it passes, the proposal is written and
168
+ # librarian_lesson_seen marks the artifact handled forever — buried
169
+ # permanently, which is the failure the whole taxonomy exists to prevent.
170
+ _LIBRARIAN_LESSON_STRUCTURAL='
171
+ (.claim | type) == "string" and (.claim | length) > 0
172
+ and (.rationale | type) == "string" and (.rationale | length) > 0
173
+ and (.evidence | type) == "object"
174
+ and ((.evidence | keys) - ["artifact_ids", "session_ids", "project_key", "observed_at", "resolution"] | length) == 0
175
+ and (.evidence.artifact_ids | type) == "array" and (.evidence.artifact_ids | length) > 0
176
+ and (.evidence.artifact_ids | all(type == "string" and test("^[0-9A-HJKMNP-TV-Z]{26}$")))
177
+ and (.evidence.session_ids | type) == "array" and (.evidence.session_ids | length) > 0
178
+ and (.evidence.session_ids | all(type == "string" and length > 0))
179
+ and (.evidence.project_key | type) == "string"
180
+ and (.evidence.project_key | test("^[0-9a-f]{12}$"))
181
+ and (.evidence.observed_at | type) == "string"
182
+ 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))$"))
183
+ and (.evidence.resolution | type) == "string" and (.evidence.resolution | length) > 0
184
+ and (.applies_to | type) == "object"
185
+ and ((.applies_to | keys) - ["stack", "scope", "file_patterns", "task_kinds"] | length) == 0
186
+ and (.applies_to.stack | type) == "array" and (.applies_to.stack | length) > 0
187
+ and (.applies_to.stack | all(type == "string" and length > 0))
188
+ and (.applies_to.file_patterns | type) == "array"
189
+ and (.applies_to.file_patterns | all(type == "string" and length > 0))
190
+ and (.applies_to.task_kinds | type) == "array"
191
+ and (.applies_to.task_kinds | all(type == "string" and length > 0))
192
+ '
193
+
194
+ # The versioned branch, shared by both validators.
195
+ _LIBRARIAN_LESSON_SCOPE_VERSIONED='
196
+ .applies_to.scope.kind == "versioned"
197
+ and ((.applies_to.scope | keys) - ["kind", "versions"] | length) == 0
198
+ and (.applies_to.scope.versions | type) == "object"
199
+ and (.applies_to.scope.versions | length) > 0
200
+ '
201
+
202
+ # Checks that only apply to a versioned candidate: the cross-field rule JSON
203
+ # Schema cannot express, and the range pattern on each value.
204
+ #
205
+ # NUL-delimited, not newline-delimited: a range value with an embedded newline
206
+ # would otherwise split into two lines that can each pass individually even
207
+ # though the single value they came from is not a valid range. Do not skip
208
+ # empty reads either — jq never emits one for a non-empty object of strings,
209
+ # so an empty read means the range itself is empty, which is invalid.
210
+ _librarian_lesson_check_versions() {
211
+ local candidate="$1"
212
+
213
+ printf '%s' "$candidate" | jq -e '
214
+ (.applies_to.scope.versions | keys) - .applies_to.stack | length == 0
215
+ ' >/dev/null 2>&1 || return 1
216
+
217
+ local range
218
+ while IFS= read -r -d '' range; do
219
+ librarian_lesson_valid_range "$range" || return 1
220
+ done < <(printf '%s' "$candidate" | jq --raw-output0 '.applies_to.scope.versions[]' 2>/dev/null)
221
+
222
+ return 0
223
+ }
224
+ ```
225
+
226
+ - [ ] **Step 4: Rewrite the transform validator to use them**
227
+
228
+ Replace the body of `librarian_lesson_validate_candidate` with:
229
+
230
+ ```bash
231
+ librarian_lesson_validate_candidate() {
232
+ local candidate="${1:-}"
233
+ [[ -z "$candidate" ]] && { printf 'schema_invalid\n' >&2; return 1; }
234
+
235
+ # versioned ONLY. This is the guarantee that stops the transform minting
236
+ # lessons that never expire: private lessons run no jury, so nothing
237
+ # downstream would catch a bad version_independent claim. A human may
238
+ # assert that branch — see librarian_lesson_validate_confirmed — because
239
+ # the constraint in the review path forces it to a judged visibility.
240
+ if ! printf '%s' "$candidate" | jq -e \
241
+ "${_LIBRARIAN_LESSON_STRUCTURAL} and ${_LIBRARIAN_LESSON_SCOPE_VERSIONED}" \
242
+ >/dev/null 2>&1; then
243
+ printf 'schema_invalid\n' >&2
244
+ return 1
245
+ fi
246
+
247
+ _librarian_lesson_check_versions "$candidate" || {
248
+ printf 'schema_invalid\n' >&2
249
+ return 1
250
+ }
251
+
252
+ return 0
253
+ }
254
+ ```
255
+
256
+ - [ ] **Step 5: Add the confirmed validator**
257
+
258
+ Immediately after it:
259
+
260
+ ```bash
261
+ # Validate a candidate a human has confirmed.
262
+ #
263
+ # Identical to librarian_lesson_validate_candidate except that it also permits
264
+ # the version_independent branch, which requires a non-empty justification.
265
+ #
266
+ # The two are NOT redundant and the difference is not stylistic. They encode
267
+ # different trust: this one bounds what a human may assert AND a jury will then
268
+ # check, because the review path refuses version_independent at private
269
+ # visibility. The transform's validator bounds what a model may assert
270
+ # unsupervised, where nothing downstream would catch a bad claim. Deleting
271
+ # either collapses that distinction.
272
+ #
273
+ # Usage: librarian_lesson_validate_confirmed <candidate_json>
274
+ librarian_lesson_validate_confirmed() {
275
+ local candidate="${1:-}"
276
+ [[ -z "$candidate" ]] && { printf 'schema_invalid\n' >&2; return 1; }
277
+
278
+ local scope_clause='
279
+ (
280
+ ('"${_LIBRARIAN_LESSON_SCOPE_VERSIONED}"')
281
+ or (
282
+ .applies_to.scope.kind == "version_independent"
283
+ and ((.applies_to.scope | keys) - ["kind", "justification"] | length) == 0
284
+ and (.applies_to.scope.justification | type) == "string"
285
+ and (.applies_to.scope.justification | length) > 0
286
+ )
287
+ )
288
+ '
289
+
290
+ if ! printf '%s' "$candidate" | jq -e \
291
+ "${_LIBRARIAN_LESSON_STRUCTURAL} and ${scope_clause}" \
292
+ >/dev/null 2>&1; then
293
+ printf 'schema_invalid\n' >&2
294
+ return 1
295
+ fi
296
+
297
+ # Range and subset rules apply only to the versioned branch.
298
+ if printf '%s' "$candidate" | jq -e '.applies_to.scope.kind == "versioned"' >/dev/null 2>&1; then
299
+ _librarian_lesson_check_versions "$candidate" || {
300
+ printf 'schema_invalid\n' >&2
301
+ return 1
302
+ }
303
+ fi
304
+
305
+ return 0
306
+ }
307
+ ```
308
+
309
+ - [ ] **Step 6: Run the tests**
310
+
311
+ Run: `bats test/bats/librarian-lesson-review.bats`
312
+ Expected: all 9 PASS.
313
+
314
+ - [ ] **Step 7: Prove the refactor did not change the transform validator**
315
+
316
+ Run: `bats test/bats/librarian-lesson-transform.bats && node --test test/node/lesson-validate-agreement.test.mjs`
317
+ Expected: 47/47 bats PASS; agreement test PASS. The agreement test drives the real bash function against ajv, so it is the guard that the extraction preserved behavior.
318
+
319
+ - [ ] **Step 8: Extend the agreement test to the confirmed validator**
320
+
321
+ In `test/node/lesson-validate-agreement.test.mjs`, add cases asserting `librarian_lesson_validate_confirmed` agrees with ajv on a `version_independent` candidate. The vendored `lesson-applies-to.subschema.json` already carries that branch with `justification` `minLength: 1`, so ajv is the reference for both:
322
+
323
+ The file's `jqAccepts()` currently hardcodes `librarian_lesson_validate_candidate`. Parameterize it, keeping the existing call sites working:
324
+
325
+ ```javascript
326
+ function jqAccepts(candidate, fn = 'librarian_lesson_validate_candidate') {
327
+ const result = spawnSync(
328
+ 'bash',
329
+ ['-c', `source '${VALIDATE_LIB}' && ${fn} "$CANDIDATE_JSON"`],
330
+ { env: { ...process.env, CANDIDATE_JSON: JSON.stringify(candidate) }, encoding: 'utf8' },
331
+ );
332
+ return result.status === 0;
333
+ }
334
+ ```
335
+
336
+ Then add, reusing the file's existing `baseCandidate()` and `schemaAccepts()`:
337
+
338
+ ```javascript
339
+ describe('confirmed validator', () => {
340
+ it('agrees with the schema on a well-formed version_independent candidate', () => {
341
+ const candidate = baseCandidate();
342
+ candidate.applies_to.scope = {
343
+ kind: 'version_independent',
344
+ justification: 'git aborts checkout on a dirty tree regardless of version.',
345
+ };
346
+ assert.equal(jqAccepts(candidate, 'librarian_lesson_validate_confirmed'), true);
347
+ assert.equal(schemaAccepts(candidate), true);
348
+ });
349
+
350
+ it('agrees with the schema in rejecting an empty justification', () => {
351
+ const candidate = baseCandidate();
352
+ candidate.applies_to.scope = { kind: 'version_independent', justification: '' };
353
+ assert.equal(jqAccepts(candidate, 'librarian_lesson_validate_confirmed'), false);
354
+ assert.equal(schemaAccepts(candidate), false);
355
+ });
356
+
357
+ it('still refuses version_independent through the transform validator', () => {
358
+ const candidate = baseCandidate();
359
+ candidate.applies_to.scope = {
360
+ kind: 'version_independent',
361
+ justification: 'git aborts checkout on a dirty tree regardless of version.',
362
+ };
363
+ // The schema permits this branch; the transform's gate deliberately does not.
364
+ assert.equal(schemaAccepts(candidate), true);
365
+ assert.equal(jqAccepts(candidate), false);
366
+ });
367
+ });
368
+ ```
369
+
370
+ That third case is the one worth having: it pins the deliberate divergence, so nobody later "fixes" the transform validator into agreeing with the schema and silently reopens the never-expiring-lesson hole.
371
+
372
+ - [ ] **Step 9: Verify and commit**
373
+
374
+ Run: `node --test test/node/lesson-validate-agreement.test.mjs && shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-validate.sh && npm run lint:check`
375
+ Expected: all pass, shellcheck silent, lint exit 0.
376
+
377
+ ```bash
378
+ git add plugins/librarian/scripts/lib/librarian-lesson-validate.sh \
379
+ test/bats/librarian-lesson-review.bats \
380
+ test/node/lesson-validate-agreement.test.mjs
381
+ git commit -m "feat(librarian): validate what a human may assert, separately :scales:"
382
+ ```
383
+
384
+ ---
385
+
386
+ ### Task 2: State transitions and the passed ledger
387
+
388
+ **Files:**
389
+ - Create: `plugins/librarian/scripts/lib/librarian-lesson-review.sh`
390
+ - Test: `test/bats/librarian-lesson-review.bats` (append)
391
+
392
+ **Interfaces:**
393
+ - Consumes: `librarian_lessons_dir <key>`, `librarian_lesson_storage_init <key>` (from stage 5); `librarian_lesson_validate_confirmed` (Task 1).
394
+ - Produces:
395
+ - `librarian_lesson_list_pending <key>` → JSON array of pending proposals, `[]` when none
396
+ - `librarian_lesson_confirm <key> <lesson_id> <visibility> [justification]` → exit 0 on success; sets `status: "confirmed"` and `visibility`; with a justification, rewrites scope to `version_independent`
397
+ - `librarian_lesson_pass <key> <lesson_id> [reason]` → exit 0; sets `status: "passed"` and appends to `passed.jsonl`
398
+ - `librarian_lesson_passed_path <key>` → prints the ledger path
399
+
400
+ - [ ] **Step 1: Write the failing tests**
401
+
402
+ Append to `test/bats/librarian-lesson-review.bats`:
403
+
404
+ ```bash
405
+ _review_setup() {
406
+ # shellcheck disable=SC1091
407
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
408
+ # shellcheck disable=SC1091
409
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
410
+ # shellcheck disable=SC1091
411
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
412
+ # shellcheck disable=SC1091
413
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
414
+ # shellcheck disable=SC1091
415
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
416
+
417
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
418
+ mkdir -p "$PROJECT_REPO"
419
+ git -C "$PROJECT_REPO" init -q
420
+ git -C "$PROJECT_REPO" config user.email t@example.com
421
+ git -C "$PROJECT_REPO" config user.name "Test"
422
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/lesson-review.git
423
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
424
+ [ -n "$PROJECT_KEY" ]
425
+ LESSONS_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}/lessons"
426
+ librarian_lesson_storage_init "$PROJECT_KEY"
427
+ }
428
+
429
+ # Seeds one pending proposal and prints its id.
430
+ _seed_pending() {
431
+ librarian_lesson_write_proposal "$PROJECT_KEY" \
432
+ "$(_candidate "$(_versioned)")" "01KZ45MKAM734ZS7JK24D2DK0R"
433
+ }
434
+
435
+ @test "list_pending returns an empty array when the queue is empty" {
436
+ _review_setup
437
+ run librarian_lesson_list_pending "$PROJECT_KEY"
438
+ [ "$status" -eq 0 ]
439
+ [ "$output" = "[]" ]
440
+ }
441
+
442
+ @test "list_pending returns a seeded pending proposal" {
443
+ _review_setup
444
+ id=$(_seed_pending)
445
+ run librarian_lesson_list_pending "$PROJECT_KEY"
446
+ [ "$status" -eq 0 ]
447
+ printf '%s' "$output" | jq -e --arg id "$id" 'length == 1 and .[0].id == $id' >/dev/null
448
+ }
449
+
450
+ @test "confirm records status and visibility on the proposal" {
451
+ _review_setup
452
+ id=$(_seed_pending)
453
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
454
+ [ "$status" -eq 0 ]
455
+ jq -e '.status == "confirmed" and .visibility == "org"' \
456
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
457
+ }
458
+
459
+ @test "confirm refuses an empty visibility and leaves the proposal pending" {
460
+ _review_setup
461
+ id=$(_seed_pending)
462
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" ""
463
+ [ "$status" -ne 0 ]
464
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
465
+ }
466
+
467
+ @test "confirm refuses an unknown visibility" {
468
+ _review_setup
469
+ id=$(_seed_pending)
470
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "everyone"
471
+ [ "$status" -ne 0 ]
472
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
473
+ }
474
+
475
+ @test "confirm with a justification rewrites scope to version_independent" {
476
+ _review_setup
477
+ id=$(_seed_pending)
478
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
479
+ [ "$status" -eq 0 ]
480
+ jq -e '.candidate.applies_to.scope.kind == "version_independent"
481
+ and (.candidate.applies_to.scope.justification | length) > 0' \
482
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
483
+ }
484
+
485
+ @test "confirm refuses version_independent at private visibility" {
486
+ _review_setup
487
+ id=$(_seed_pending)
488
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "private" "stable across versions."
489
+ [ "$status" -ne 0 ]
490
+ jq -e '.status == "pending" and .candidate.applies_to.scope.kind == "versioned"' \
491
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
492
+ }
493
+
494
+ @test "confirm refuses a candidate that fails the confirmed validator" {
495
+ _review_setup
496
+ id=$(_seed_pending)
497
+ # Corrupt the stored candidate so validation must reject it.
498
+ tmp="${BATS_TEST_TMPDIR}/bad.json"
499
+ jq '.candidate.evidence.resolution = ""' "${LESSONS_DIR}/proposals/${id}.json" > "$tmp"
500
+ mv "$tmp" "${LESSONS_DIR}/proposals/${id}.json"
501
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
502
+ [ "$status" -ne 0 ]
503
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
504
+ }
505
+
506
+ @test "pass marks the proposal and appends one ledger line" {
507
+ _review_setup
508
+ id=$(_seed_pending)
509
+ run librarian_lesson_pass "$PROJECT_KEY" "$id" "not worth sharing"
510
+ [ "$status" -eq 0 ]
511
+ jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
512
+ [ "$(wc -l < "${LESSONS_DIR}/passed.jsonl")" -eq 1 ]
513
+ tail -n 1 "${LESSONS_DIR}/passed.jsonl" \
514
+ | jq -e --arg id "$id" '.lesson_id == $id and .reason == "not worth sharing"' >/dev/null
515
+ }
516
+
517
+ @test "a passed proposal keeps its file so the artifact stays seen" {
518
+ _review_setup
519
+ id=$(_seed_pending)
520
+ librarian_lesson_pass "$PROJECT_KEY" "$id"
521
+ [ -f "${LESSONS_DIR}/proposals/${id}.json" ]
522
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
523
+ [ "$status" -eq 0 ]
524
+ }
525
+
526
+ @test "this stage never writes to declined.jsonl" {
527
+ _review_setup
528
+ id=$(_seed_pending)
529
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
530
+ id2=$(_seed_pending)
531
+ librarian_lesson_pass "$PROJECT_KEY" "$id2"
532
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
533
+ }
534
+
535
+ @test "confirming never invokes a model" {
536
+ _review_setup
537
+ # A claude on PATH that fails loudly if called at all.
538
+ stub="${BATS_TEST_TMPDIR}/bin"
539
+ mkdir -p "$stub"
540
+ printf '#!/usr/bin/env bash\necho "MODEL WAS INVOKED" >&2\nexit 42\n' > "${stub}/claude"
541
+ chmod +x "${stub}/claude"
542
+ PATH="${stub}:${PATH}"
543
+
544
+ id=$(_seed_pending)
545
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
546
+ [ "$status" -eq 0 ]
547
+ [[ "$output" != *"MODEL WAS INVOKED"* ]] || return 1
548
+ jq -e '.status == "confirmed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
549
+ }
550
+
551
+ @test "list_pending excludes confirmed and passed proposals" {
552
+ _review_setup
553
+ a=$(_seed_pending); b=$(_seed_pending); c=$(_seed_pending)
554
+ librarian_lesson_confirm "$PROJECT_KEY" "$a" "org"
555
+ librarian_lesson_pass "$PROJECT_KEY" "$b"
556
+ run librarian_lesson_list_pending "$PROJECT_KEY"
557
+ printf '%s' "$output" | jq -e --arg c "$c" 'length == 1 and .[0].id == $c' >/dev/null
558
+ }
559
+ ```
560
+
561
+ - [ ] **Step 2: Run and watch them fail**
562
+
563
+ Run: `bats test/bats/librarian-lesson-review.bats`
564
+ Expected: the new tests FAIL with `librarian_lesson_list_pending: command not found`. Task 1's nine still pass.
565
+
566
+ - [ ] **Step 3: Implement the library**
567
+
568
+ Create `plugins/librarian/scripts/lib/librarian-lesson-review.sh`:
569
+
570
+ ```bash
571
+ #!/usr/bin/env bash
572
+ # Lesson confirmation — the human intent filter.
573
+ #
574
+ # The human picks which candidates go to the jury and their visibility, before
575
+ # any expensive tokens are spent. Intent is cheap and only a human can supply
576
+ # it; quality is expensive and only the jury can judge it. Splitting them here
577
+ # means cost scales with intent rather than artifact volume.
578
+ #
579
+ # NOTHING in this file may invoke a model. That is what makes "no tokens spent
580
+ # on unselected candidates" a property of the code rather than a promise.
581
+ #
582
+ # Requires librarian-lesson-storage.sh and librarian-lesson-validate.sh.
583
+
584
+ librarian_lesson_passed_path() {
585
+ local key="$1"
586
+ printf '%s/passed.jsonl' "$(librarian_lessons_dir "$key")"
587
+ }
588
+
589
+ # Print pending proposals as a JSON array, oldest first. Prints [] when none.
590
+ # Usage: librarian_lesson_list_pending <key>
591
+ librarian_lesson_list_pending() {
592
+ local key="$1"
593
+ [[ -z "$key" ]] && { printf '[]'; return 0; }
594
+
595
+ local dir
596
+ dir="$(librarian_lessons_dir "$key")/proposals"
597
+ [[ -d "$dir" ]] || { printf '[]'; return 0; }
598
+
599
+ local f out
600
+ out='[]'
601
+ for f in "$dir"/*.json; do
602
+ [[ -f "$f" ]] || continue
603
+ jq -e '.status == "pending"' "$f" >/dev/null 2>&1 || continue
604
+ # Skip a file we cannot accumulate rather than abandoning the listing.
605
+ # A concurrent confirm/pass can rewrite the file between the status
606
+ # check above and this read; discarding everything gathered so far
607
+ # would report an empty queue, which reads as "nothing to review"
608
+ # rather than as an error. Same failure class librarian_lesson_seen
609
+ # guards against in the sibling file.
610
+ #
611
+ # `out=$(cmd) || continue` does NOT work here: the assignment happens
612
+ # and clobbers $out to empty stdout BEFORE `||` is evaluated, so the
613
+ # accumulator is already wiped by the time `continue` runs. Merge into
614
+ # a temp and promote only on success.
615
+ local merged
616
+ if merged=$(jq -c --slurpfile p "$f" '. + $p' <<<"$out" 2>/dev/null); then
617
+ out="$merged"
618
+ fi
619
+ done
620
+ printf '%s' "$(jq -c 'sort_by(.created_at)' <<<"$out")"
621
+ }
622
+
623
+ _librarian_lesson_valid_visibility() {
624
+ case "${1:-}" in
625
+ private | org | public) return 0 ;;
626
+ *) return 1 ;;
627
+ esac
628
+ }
629
+
630
+ # Confirm a candidate for the jury.
631
+ #
632
+ # Usage: librarian_lesson_confirm <key> <lesson_id> <visibility> [justification]
633
+ #
634
+ # With a justification, the candidate's scope is rewritten to
635
+ # version_independent. That branch is refused at private visibility: private
636
+ # lessons run no jury, so the justification would reach the pool with nothing
637
+ # checking it — the same hole the transform closes by refusing the branch
638
+ # outright. Requiring org or public means scope_accuracy actually tests it.
639
+ librarian_lesson_confirm() {
640
+ local key="$1"
641
+ local lesson_id="$2"
642
+ local visibility="${3:-}"
643
+ local justification="${4:-}"
644
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
645
+
646
+ _librarian_lesson_valid_visibility "$visibility" || {
647
+ printf 'visibility must be one of: private, org, public\n' >&2
648
+ return 1
649
+ }
650
+
651
+ if [[ -n "$justification" && "$visibility" == "private" ]]; then
652
+ printf 'version_independent requires org or public visibility: a private lesson runs no jury, so its justification would go unchecked and the lesson would never expire\n' >&2
653
+ return 1
654
+ fi
655
+
656
+ local path
657
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
658
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
659
+
660
+ local proposal candidate current
661
+ proposal=$(jq '.' "$path" 2>/dev/null) || return 1
662
+ candidate=$(printf '%s' "$proposal" | jq -c '.candidate' 2>/dev/null) || return 1
663
+ current=$(printf '%s' "$proposal" | jq -r '.status // ""' 2>/dev/null)
664
+
665
+ # Guard the transition, not just the write. Each write is atomic on its
666
+ # own, but an unguarded SEQUENCE lets passed.jsonl end up contradicting
667
+ # the candidate it describes: pass then confirm would flip status back to
668
+ # confirmed while the ledger still asserts the human declined it. The
669
+ # ledger is the durable record of intent, so it must never disagree.
670
+ case "$current" in
671
+ pending) ;;
672
+ confirmed)
673
+ # Idempotent only when nothing about the decision changed.
674
+ local prev_vis prev_scope
675
+ prev_vis=$(printf '%s' "$proposal" | jq -r '.visibility // ""')
676
+ prev_scope=$(printf '%s' "$proposal" | jq -c '.candidate.applies_to.scope')
677
+ if [[ "$prev_vis" == "$visibility" && -z "$justification" ]] \
678
+ || [[ "$prev_vis" == "$visibility" && "$prev_scope" == *'"version_independent"'* ]]; then
679
+ return 0
680
+ fi
681
+ printf 'Lesson %s is already confirmed at %s visibility; pass on it first to change that.\n' \
682
+ "$lesson_id" "$prev_vis" >&2
683
+ return 1
684
+ ;;
685
+ passed)
686
+ printf 'Lesson %s was passed on; it cannot be confirmed without reopening it.\n' "$lesson_id" >&2
687
+ return 1
688
+ ;;
689
+ *)
690
+ printf 'Lesson %s has an unrecognized status: %s\n' "$lesson_id" "$current" >&2
691
+ return 1
692
+ ;;
693
+ esac
694
+
695
+ if [[ -n "$justification" ]]; then
696
+ candidate=$(printf '%s' "$candidate" | jq -c \
697
+ --arg j "$justification" \
698
+ '.applies_to.scope = {kind: "version_independent", justification: $j}' 2>/dev/null) || return 1
699
+ fi
700
+
701
+ librarian_lesson_validate_confirmed "$candidate" 2>/dev/null || {
702
+ printf 'Candidate does not validate; not confirmed.\n' >&2
703
+ return 1
704
+ }
705
+
706
+ local now updated
707
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
708
+ # Assign .candidate rather than folding it into the `*` merge. jq's `*` is
709
+ # RECURSIVE: `. * {candidate: $c}` merges the new candidate into the stored
710
+ # one instead of replacing it. When a confirm rewrites scope to
711
+ # version_independent, the fresh scope is {kind, justification} but merging
712
+ # it over the stored {kind, versions} leaves all three keys — a candidate
713
+ # that fails librarian_lesson_validate_confirmed, since that branch permits
714
+ # only kind and justification. It would then be handed to the jury stage,
715
+ # which reads exactly this field.
716
+ updated=$(printf '%s' "$proposal" | jq \
717
+ --arg v "$visibility" --arg t "$now" --argjson c "$candidate" \
718
+ '. * {status: "confirmed", visibility: $v, confirmed_at: $t} | .candidate = $c' 2>/dev/null) || return 1
719
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
720
+ printf '%s\n' "$updated" > "$path"
721
+ }
722
+
723
+ # Decline to share a candidate.
724
+ #
725
+ # The file is KEPT. librarian_lesson_seen scans proposals/ by artifact_id, so
726
+ # leaving it in place is what stops the artifact being re-proposed on the next
727
+ # scan and re-paying for a transform whose answer the human already gave.
728
+ #
729
+ # The ledger is separate from declined.jsonl on purpose: that file records
730
+ # machine verdicts and feeds rubric tuning, and folding human intent into it
731
+ # would corrupt the signal it exists to carry.
732
+ #
733
+ # Usage: librarian_lesson_pass <key> <lesson_id> [reason]
734
+ librarian_lesson_pass() {
735
+ local key="$1"
736
+ local lesson_id="$2"
737
+ local reason="${3:-}"
738
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
739
+
740
+ local path
741
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
742
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
743
+
744
+ local current
745
+ current=$(jq -r '.status // ""' "$path" 2>/dev/null)
746
+
747
+ # Same reasoning as confirm: guard the transition. Passing twice must not
748
+ # append a second ledger line, and a confirmed candidate must not be
749
+ # silently un-confirmed — that would leave stale visibility/confirmed_at
750
+ # on the record and send a contradictory signal to the jury stage, which
751
+ # selects on status.
752
+ case "$current" in
753
+ pending) ;;
754
+ passed) return 0 ;;
755
+ confirmed)
756
+ printf 'Lesson %s is already confirmed for the jury; it cannot be passed on now.\n' "$lesson_id" >&2
757
+ return 1
758
+ ;;
759
+ *)
760
+ printf 'Lesson %s has an unrecognized status: %s\n' "$lesson_id" "$current" >&2
761
+ return 1
762
+ ;;
763
+ esac
764
+
765
+ local artifact_id now updated
766
+ artifact_id=$(jq -r '.artifact_id // ""' "$path" 2>/dev/null)
767
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
768
+
769
+ updated=$(jq --arg t "$now" '. * {status: "passed", passed_at: $t}' "$path" 2>/dev/null) || return 1
770
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
771
+ printf '%s\n' "$updated" > "$path"
772
+
773
+ local line
774
+ line=$(jq -cn \
775
+ --arg lesson_id "$lesson_id" \
776
+ --arg artifact_id "$artifact_id" \
777
+ --arg reason "$reason" \
778
+ --arg at "$now" \
779
+ '{lesson_id: $lesson_id, artifact_id: $artifact_id,
780
+ reason: (if $reason == "" then null else $reason end), passed_at: $at}') || return 1
781
+
782
+ printf '%s\n' "$line" >> "$(librarian_lesson_passed_path "$key")"
783
+ }
784
+ ```
785
+
786
+ - [ ] **Step 4: Run the tests**
787
+
788
+ Run: `bats test/bats/librarian-lesson-review.bats`
789
+ Expected: all PASS (9 from Task 1 + 13 new).
790
+
791
+ - [ ] **Step 5: Fault-inject the three guarantees**
792
+
793
+ Each of these is a guarantee, not a behavior, so confirm each test discriminates. For each: make the change, run the named test, confirm it FAILS, revert, confirm it passes.
794
+
795
+ 1. Delete the `_librarian_lesson_valid_visibility` call → "confirm refuses an unknown visibility" must fail.
796
+ 2. Delete the `private` + justification check → "confirm refuses version_independent at private visibility" must fail.
797
+ 3. In `librarian_lesson_pass`, remove the `printf ... >> passed.jsonl` line → "pass marks the proposal and appends one ledger line" must fail.
798
+
799
+ Report the result of each in the task report.
800
+
801
+ - [ ] **Step 6: Verify and commit**
802
+
803
+ Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-review.sh && bats test/bats/librarian-lesson-transform.bats && npm run lint:check`
804
+ Expected: shellcheck silent, transform suite still green, lint exit 0.
805
+
806
+ ```bash
807
+ git add plugins/librarian/scripts/lib/librarian-lesson-review.sh \
808
+ test/bats/librarian-lesson-review.bats
809
+ git commit -m "feat(librarian): let a human confirm lessons and record what they pass on :raised_hand:"
810
+ ```
811
+
812
+ ---
813
+
814
+ ### Task 3: CLI verbs
815
+
816
+ **Files:**
817
+ - Modify: `plugins/librarian/scripts/lib/librarian-cli.sh`
818
+ - Test: `test/bats/librarian-lesson-review.bats` (append)
819
+
820
+ **Interfaces:**
821
+ - Consumes: `librarian_lesson_list_pending`, `librarian_lesson_confirm`, `librarian_lesson_pass` (Task 2); `_librarian_cli_project_key <cwd>` (exists).
822
+ - Produces: `librarian_cli lessons <list|show|confirm|pass|defer|status> [args]`.
823
+
824
+ - [ ] **Step 1: Write the failing tests**
825
+
826
+ Append:
827
+
828
+ ```bash
829
+ _cli_setup() {
830
+ _review_setup
831
+ # shellcheck disable=SC1091
832
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
833
+ # shellcheck disable=SC1091
834
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
835
+ # shellcheck disable=SC1091
836
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
837
+ }
838
+
839
+ @test "lessons status reports zero on an empty queue" {
840
+ _cli_setup
841
+ run librarian_cli lessons status "$PROJECT_REPO"
842
+ [ "$status" -eq 0 ]
843
+ [[ "$output" == *"0"* ]] || return 1
844
+ }
845
+
846
+ @test "lessons list shows a pending lesson" {
847
+ _cli_setup
848
+ id=$(_seed_pending)
849
+ run librarian_cli lessons list "$PROJECT_REPO"
850
+ [ "$status" -eq 0 ]
851
+ [[ "$output" == *"$id"* ]] || return 1
852
+ }
853
+
854
+ @test "lessons show prints the claim" {
855
+ _cli_setup
856
+ id=$(_seed_pending)
857
+ run librarian_cli lessons show "$id" "$PROJECT_REPO"
858
+ [ "$status" -eq 0 ]
859
+ [[ "$output" == *"c"* ]] || return 1
860
+ }
861
+
862
+ @test "lessons confirm requires a visibility argument" {
863
+ _cli_setup
864
+ id=$(_seed_pending)
865
+ run librarian_cli lessons confirm "$id"
866
+ [ "$status" -ne 0 ]
867
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
868
+ }
869
+
870
+ @test "lessons confirm sets status and visibility" {
871
+ _cli_setup
872
+ id=$(_seed_pending)
873
+ run librarian_cli lessons confirm "$id" public "" "$PROJECT_REPO"
874
+ [ "$status" -eq 0 ]
875
+ jq -e '.status == "confirmed" and .visibility == "public"' \
876
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
877
+ }
878
+
879
+ @test "lessons pass marks it passed" {
880
+ _cli_setup
881
+ id=$(_seed_pending)
882
+ run librarian_cli lessons pass "$id" "" "$PROJECT_REPO"
883
+ [ "$status" -eq 0 ]
884
+ jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
885
+ }
886
+
887
+ @test "lessons defer leaves it pending" {
888
+ _cli_setup
889
+ id=$(_seed_pending)
890
+ run librarian_cli lessons defer "$id" "$PROJECT_REPO"
891
+ [ "$status" -eq 0 ]
892
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
893
+ }
894
+
895
+ @test "an unknown lessons verb is rejected" {
896
+ _cli_setup
897
+ run librarian_cli lessons frobnicate
898
+ [ "$status" -ne 0 ]
899
+ }
900
+
901
+ @test "memory verbs are unaffected by the lessons namespace" {
902
+ _cli_setup
903
+ run librarian_cli status "$PROJECT_REPO"
904
+ [ "$status" -eq 0 ]
905
+ }
906
+ ```
907
+
908
+ - [ ] **Step 2: Run and watch them fail**
909
+
910
+ Run: `bats test/bats/librarian-lesson-review.bats`
911
+ Expected: new tests FAIL — `librarian_cli` reports `unknown action: lessons`.
912
+
913
+ - [ ] **Step 3: Source the review lib from the CLI**
914
+
915
+ At the top of `librarian-cli.sh`, alongside its existing sources, add:
916
+
917
+ ```bash
918
+ # shellcheck source=./librarian-lesson-storage.sh
919
+ source "${BASH_SOURCE[0]%/*}/librarian-lesson-storage.sh"
920
+ # shellcheck source=./librarian-lesson-validate.sh
921
+ source "${BASH_SOURCE[0]%/*}/librarian-lesson-validate.sh"
922
+ # shellcheck source=./librarian-lesson-review.sh
923
+ source "${BASH_SOURCE[0]%/*}/librarian-lesson-review.sh"
924
+ ```
925
+
926
+ If the file already sources siblings by a different idiom, match that idiom instead — read the top of the file first.
927
+
928
+ - [ ] **Step 4: Add the verbs and nested dispatch**
929
+
930
+ Add before `librarian_cli()`:
931
+
932
+ ```bash
933
+ # ----------------------------------------------------------------------------
934
+ # Lesson confirmation surface
935
+ #
936
+ # Namespaced under `lessons` and kept apart from the memory verbs on purpose.
937
+ # Accepting a memory writes a file on this machine; confirming a lesson commits
938
+ # it toward leaving this machine, irreversibly once synced. Those two decisions
939
+ # should not sit one keystroke apart.
940
+ # ----------------------------------------------------------------------------
941
+
942
+ librarian_cli_lessons_list() {
943
+ local cwd="${1:-}"
944
+ local key pending
945
+ key=$(_librarian_cli_project_key "$cwd")
946
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
947
+ pending=$(librarian_lesson_list_pending "$key")
948
+
949
+ if [[ "$(printf '%s' "$pending" | jq 'length')" -eq 0 ]]; then
950
+ printf 'No pending lessons.\n'
951
+ return 0
952
+ fi
953
+ printf '%s' "$pending" | jq -r '.[] | "\(.id) \(.candidate.claim)"'
954
+ }
955
+
956
+ librarian_cli_lessons_show() {
957
+ local lesson_id="${1:-}"
958
+ local cwd="${2:-}"
959
+ [[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons show <lesson_id>\n'; return 1; }
960
+
961
+ local key path
962
+ key=$(_librarian_cli_project_key "$cwd")
963
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
964
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
965
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id"; return 1; }
966
+
967
+ jq -r '
968
+ "id: \(.id)",
969
+ "status: \(.status)",
970
+ "artifact: \(.artifact_id)",
971
+ "claim: \(.candidate.claim)",
972
+ "rationale: \(.candidate.rationale)",
973
+ "resolution: \(.candidate.evidence.resolution)",
974
+ "stack: \(.candidate.applies_to.stack | join(", "))",
975
+ "scope: \(.candidate.applies_to.scope | tojson)"
976
+ ' "$path"
977
+ }
978
+
979
+ librarian_cli_lessons_confirm() {
980
+ local lesson_id="${1:-}"
981
+ local visibility="${2:-}"
982
+ shift 2 2>/dev/null || true
983
+
984
+ # Justification is a NAMED flag, not a positional. With positionals, a
985
+ # caller supplying cwd while omitting the justification binds the path
986
+ # into it — and librarian_lesson_confirm treats any non-empty
987
+ # justification as "rewrite scope to version_independent", so a working
988
+ # directory would silently flip the scope on the one verb that commits a
989
+ # lesson toward leaving this machine. Position cannot misparse a flag.
990
+ local justification="" cwd=""
991
+ while [ $# -gt 0 ]; do
992
+ case "$1" in
993
+ --justification)
994
+ justification="${2:-}"
995
+ shift 2 2>/dev/null || shift
996
+ ;;
997
+ --*)
998
+ # Reject an unrecognized flag rather than absorbing it as a
999
+ # cwd candidate. A typo like --justifcation would otherwise be
1000
+ # swallowed, the lesson would confirm with an empty
1001
+ # justification and exit 0 with no diagnostic, and the retry
1002
+ # with the correct spelling would then be refused by the
1003
+ # status guard — leaving a wrong state the CLI has no verb to
1004
+ # undo. Nothing legitimate here begins with --.
1005
+ printf 'unknown option: %s\n' "$1" >&2
1006
+ return 1
1007
+ ;;
1008
+ *)
1009
+ cwd="$1"
1010
+ shift
1011
+ ;;
1012
+ esac
1013
+ done
1014
+
1015
+ # A justification that is only whitespace is not a justification. Refuse
1016
+ # it here rather than in the validator: the vendored schema's minLength
1017
+ # of 1 counts whitespace, our validator mirrors that faithfully, and the
1018
+ # agreement test exists to keep the two aligned. Guard the input path.
1019
+ case "$justification" in
1020
+ *[![:space:]]*) ;;
1021
+ "") ;;
1022
+ *)
1023
+ printf 'A justification cannot be only whitespace.\n' >&2
1024
+ return 1
1025
+ ;;
1026
+ esac
1027
+
1028
+ [[ -z "$lesson_id" || -z "$visibility" ]] && {
1029
+ printf 'usage: librarian_cli lessons confirm <lesson_id> <private|org|public> [--justification TEXT] [cwd]\n'
1030
+ return 1
1031
+ }
1032
+
1033
+ local key
1034
+ key=$(_librarian_cli_project_key "$cwd")
1035
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
1036
+
1037
+ librarian_lesson_confirm "$key" "$lesson_id" "$visibility" "$justification" || return 1
1038
+ printf 'Confirmed %s at %s visibility.\n' "$lesson_id" "$visibility"
1039
+ }
1040
+
1041
+ librarian_cli_lessons_pass() {
1042
+ local lesson_id="${1:-}"
1043
+ local reason="${2:-}"
1044
+ local cwd="${3:-}"
1045
+ [[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons pass <lesson_id> [reason]\n'; return 1; }
1046
+
1047
+ local key
1048
+ key=$(_librarian_cli_project_key "$cwd")
1049
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
1050
+
1051
+ librarian_lesson_pass "$key" "$lesson_id" "$reason" || return 1
1052
+ printf 'Passed on %s.\n' "$lesson_id"
1053
+ }
1054
+
1055
+ librarian_cli_lessons_defer() {
1056
+ local lesson_id="${1:-}"
1057
+ [[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons defer <lesson_id>\n'; return 1; }
1058
+ printf 'Deferred %s; it stays in the queue.\n' "$lesson_id"
1059
+ }
1060
+
1061
+ librarian_cli_lessons_status() {
1062
+ local cwd="${1:-}"
1063
+ local key pending
1064
+ key=$(_librarian_cli_project_key "$cwd")
1065
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
1066
+ pending=$(librarian_lesson_list_pending "$key")
1067
+ printf 'lessons pending: %s\n' "$(printf '%s' "$pending" | jq 'length')"
1068
+ }
1069
+
1070
+ librarian_cli_lessons() {
1071
+ local verb="${1:-list}"
1072
+ shift || true
1073
+ case "$verb" in
1074
+ list) librarian_cli_lessons_list "$@" ;;
1075
+ show) librarian_cli_lessons_show "$@" ;;
1076
+ confirm) librarian_cli_lessons_confirm "$@" ;;
1077
+ pass) librarian_cli_lessons_pass "$@" ;;
1078
+ defer) librarian_cli_lessons_defer "$@" ;;
1079
+ status) librarian_cli_lessons_status "$@" ;;
1080
+ *) printf 'unknown lessons action: %s\n' "$verb"; return 2 ;;
1081
+ esac
1082
+ }
1083
+ ```
1084
+
1085
+ Then add one line to the existing `librarian_cli` dispatch, before the `*)` catch-all:
1086
+
1087
+ ```bash
1088
+ lessons) librarian_cli_lessons "$@" ;;
1089
+ ```
1090
+
1091
+ - [ ] **Step 5: Run the tests**
1092
+
1093
+ Run: `bats test/bats/librarian-lesson-review.bats`
1094
+ Expected: all PASS.
1095
+
1096
+ - [ ] **Step 6: Verify and commit**
1097
+
1098
+ Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-cli.sh && bats test/bats/librarian-cli.bats && npm run lint:check`
1099
+ Expected: shellcheck silent, the existing CLI suite still green, lint exit 0.
1100
+
1101
+ ```bash
1102
+ git add plugins/librarian/scripts/lib/librarian-cli.sh test/bats/librarian-lesson-review.bats
1103
+ git commit -m "feat(librarian): namespace the lesson verbs away from memory accepts :card_index_dividers:"
1104
+ ```
1105
+
1106
+ ---
1107
+
1108
+ ### Task 4: The two user-facing surfaces
1109
+
1110
+ **Files:**
1111
+ - Modify: `plugins/librarian/scripts/hooks/librarian-session-start.sh`
1112
+ - Modify: `plugins/librarian/skills/librarian/SKILL.md`
1113
+ - Test: `test/bats/librarian-session-start.bats` (append)
1114
+
1115
+ **Interfaces:**
1116
+ - Consumes: `librarian_lesson_list_pending <key>` (Task 2), `librarian_cli lessons <verb>` (Task 3).
1117
+ - Produces: no new functions.
1118
+
1119
+ - [ ] **Step 1: Write the failing test**
1120
+
1121
+ Read `test/bats/librarian-session-start.bats` first and reuse its existing setup helper rather than writing a new one. Append:
1122
+
1123
+ ```bash
1124
+ @test "session-start surfaces a pending lesson count as its own line" {
1125
+ # Reuse this file's existing project/hook setup, then seed one pending
1126
+ # lesson through the storage lib.
1127
+ # shellcheck disable=SC1091
1128
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
1129
+ librarian_lesson_storage_init "$PROJECT_KEY"
1130
+ librarian_lesson_write_proposal "$PROJECT_KEY" \
1131
+ "$(jq -cn '{claim: "c", rationale: "r"}')" "01KZ45MKAM734ZS7JK24D2DK0R" >/dev/null
1132
+
1133
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
1134
+ [ "$status" -eq 0 ]
1135
+ [[ "$output" == *"lesson"* ]] || return 1
1136
+ }
1137
+ ```
1138
+
1139
+ - [ ] **Step 2: Run and watch it fail**
1140
+
1141
+ Run: `bats test/bats/librarian-session-start.bats`
1142
+ Expected: FAIL — no "lesson" in the injected context.
1143
+
1144
+ - [ ] **Step 3: Add the count to the surfacer**
1145
+
1146
+ In `librarian-session-start.sh`, alongside the existing pending-proposal count, source the lesson storage and review libs and add a second line to `additionalContext`. Keep it a pointer, not bodies — the file's own header explains why: session-start context is precious, and the queue belongs in the review skill.
1147
+
1148
+ ```bash
1149
+ # shellcheck source=../lib/librarian-lesson-storage.sh
1150
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
1151
+ # shellcheck source=../lib/librarian-lesson-validate.sh
1152
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
1153
+ # shellcheck source=../lib/librarian-lesson-review.sh
1154
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
1155
+ ```
1156
+
1157
+ **There is a trap in the existing control flow — read this before editing.** The hook computes `PENDING` (memory proposals) and then early-exits:
1158
+
1159
+ ```bash
1160
+ if [[ "$PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
1161
+ _emit ""
1162
+ exit 0
1163
+ fi
1164
+ ```
1165
+
1166
+ Adding the lesson line *after* that branch means lessons are never surfaced when there are no memory proposals — which is the common case, since the two queues fill independently. Compute the lesson count **before** the branch and include it in the condition:
1167
+
1168
+ ```bash
1169
+ LESSON_PENDING=$(librarian_lesson_list_pending "$PROJECT_KEY" | jq 'length' 2>/dev/null) || LESSON_PENDING=0
1170
+ [[ -z "$LESSON_PENDING" || "$LESSON_PENDING" == "null" ]] && LESSON_PENDING=0
1171
+
1172
+ if [[ "$PENDING" -eq 0 && "$LESSON_PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
1173
+ _emit ""
1174
+ exit 0
1175
+ fi
1176
+ ```
1177
+
1178
+ Then build the lesson line and append it to whatever context string the hook passes to `_emit`, separated by a newline:
1179
+
1180
+ ```bash
1181
+ LESSON_LINE=""
1182
+ if [[ "$LESSON_PENDING" -gt 0 ]]; then
1183
+ LESSON_LINE=$(printf '%s lesson candidate(s) awaiting confirmation — run /librarian lessons' "$LESSON_PENDING")
1184
+ fi
1185
+ ```
1186
+
1187
+ At the `_emit` call, join the two lines, skipping either when empty so a single-queue session gets one clean line rather than a stray blank:
1188
+
1189
+ ```bash
1190
+ CONTEXT="$MEMORY_LINE"
1191
+ if [[ -n "$LESSON_LINE" ]]; then
1192
+ if [[ -n "$CONTEXT" ]]; then
1193
+ CONTEXT="${CONTEXT}"$'\n'"${LESSON_LINE}"
1194
+ else
1195
+ CONTEXT="$LESSON_LINE"
1196
+ fi
1197
+ fi
1198
+ _emit "$CONTEXT"
1199
+ ```
1200
+
1201
+ `MEMORY_LINE` is whatever variable the hook currently passes to `_emit` — read the end of the file and use its real name rather than renaming it.
1202
+
1203
+ Add a second test covering the trap, since it is the failure a reviewer would most likely miss:
1204
+
1205
+ ```bash
1206
+ @test "session-start surfaces lessons even with zero memory proposals" {
1207
+ # shellcheck disable=SC1091
1208
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
1209
+ librarian_lesson_storage_init "$PROJECT_KEY"
1210
+ librarian_lesson_write_proposal "$PROJECT_KEY" \
1211
+ "$(jq -cn '{claim: "c", rationale: "r"}')" "01KZ45MKAM734ZS7JK24D2DK0R" >/dev/null
1212
+
1213
+ run bash -c "printf '%s' '$(_input)' | '$HOOK'"
1214
+ [ "$status" -eq 0 ]
1215
+ [[ "$output" == *"lesson"* ]] || return 1
1216
+ }
1217
+ ```
1218
+
1219
+ - [ ] **Step 4: Run the tests**
1220
+
1221
+ Run: `bats test/bats/librarian-session-start.bats`
1222
+ Expected: all PASS, including the pre-existing ones.
1223
+
1224
+ - [ ] **Step 5: Route the skill**
1225
+
1226
+ **First, fix the sourcing gap — the verbs do not work in production without it.** `librarian-cli.sh` deliberately sources nothing itself; its callers source its dependencies. The skill currently sources five libs (`librarian-config.sh`, `librarian-project-key.sh`, `librarian-storage.sh`, `librarian-emit.sh`, `librarian-cli.sh`) and none of the three lesson libs, so `librarian_cli lessons list` fails with `librarian_lesson_list_pending: command not found`. Verified by sourcing exactly what the skill lists and calling the verb.
1227
+
1228
+ Add these three to the skill's source block, before `librarian-cli.sh`:
1229
+
1230
+ ```bash
1231
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-storage.sh"
1232
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-validate.sh"
1233
+ source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-review.sh"
1234
+ ```
1235
+
1236
+ Order matters: `librarian-lesson-review.sh` calls into both of the others, and `librarian-lesson-storage.sh` needs `librarian-storage.sh` (already sourced above it).
1237
+
1238
+ The bats suites pass without this because their setup sources every lib directly — which is exactly why this gap is invisible to per-task tests and has to be closed here.
1239
+
1240
+ Then, in `plugins/librarian/skills/librarian/SKILL.md`, extend the "Parse the request" section with the `lessons` route, and add a section describing the walk. Keep the existing memory routes untouched.
1241
+
1242
+ ```markdown
1243
+ - `lessons`, `lessons review` → **walk the lesson queue** (see below)
1244
+ - `lessons list` / `lessons status` → print and stop
1245
+ ```
1246
+
1247
+ Add a section documenting: the walk shows `claim`, `rationale`, `evidence.resolution`, `applies_to.stack`, `scope.versions`, and the source artifact id; the three outcomes are `confirm <id> <visibility>`, `pass <id> [reason]`, `defer <id>`; confirming **requires** a visibility; and `version_independent` requires `org` or `public` because a private lesson runs no jury, so its justification would go unchecked.
1248
+
1249
+ Also update the skill's frontmatter `description` so `/librarian lessons` is discoverable.
1250
+
1251
+ - [ ] **Step 6: Verify and commit**
1252
+
1253
+ Run: `npm run test:ci`
1254
+ Expected: exit 0.
1255
+
1256
+ ```bash
1257
+ git add plugins/librarian/scripts/hooks/librarian-session-start.sh \
1258
+ plugins/librarian/skills/librarian/SKILL.md \
1259
+ test/bats/librarian-session-start.bats
1260
+ git commit -m "feat(librarian): surface pending lessons and route the review skill :bellhop_bell:"
1261
+ ```
1262
+
1263
+ ---
1264
+
1265
+ ## Self-review notes
1266
+
1267
+ **Spec coverage.** Surface and verbs → Task 3; state model and both ledgers → Task 2; the three constraints → Task 2 (enforced) and Tasks 2–3 (tested); two validators → Task 1; surfacing → Task 4; events → deliberately absent, see below; testing → inside each task; boundary to `4z8.3` → the `status: "confirmed"` + `visibility` fields written in Task 2.
1268
+
1269
+ **Deliberately absent.** No event emission. `librarian.lesson.*` is unregistered in `@onlooker-community/schema` 2.11.0 — verified — and with a validator present the emitter exits 1 on an unknown `event_type`. `4z8.3` reads the proposal files, not the bus, so nothing is blocked by waiting. One follow-up should wire all four types (`proposed`, `declined`, `confirmed`, `passed`) once the package publishes them.
1270
+
1271
+ **Refactor risk, and what covers it.** Task 1 restructures a validator that shipped in `librarian-v0.7.1` and cost a fix round to harden. The guard is `test/node/lesson-validate-agreement.test.mjs`, which drives the real bash function against ajv over a corpus — Step 7 runs it before the new behavior is added, specifically to prove the extraction changed nothing.
1272
+
1273
+ **Known gap.** `librarian_cli_lessons_defer` is a no-op that prints. It exists so the walk has three symmetric outcomes and the user can say "not now" without the skill inventing behavior. If a deferral ever needs to suppress re-surfacing for N sessions, that is a new field, not a change to this verb.