@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,1232 @@
1
+ # Lesson Judging 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:** Give librarian a human-invoked jury that judges confirmed lesson candidates against a visibility-scoped rubric and records an `approved` or `rejected` verdict on each proposal.
6
+
7
+ **Architecture:** The agent orchestrates and bash decides. A new `/librarian lessons judge` route gathers confirmed candidates, dispatches tribunal's judge subagents per candidate, and hands the raw verdicts to bash, which selects the rubric by visibility, aggregates, gates, and writes the result. Everything except subagent dispatch lives in testable shell.
8
+
9
+ **Tech Stack:** bash (macOS bash 3.2 compatible), `jq`, bats, `awk` for float comparison.
10
+
11
+ ## Deviation from the spec, and why
12
+
13
+ The spec says the skill "parses their verdicts, aggregates and gates" and gives the
14
+ lib only the write (`librarian_lesson_record_verdict <key> <lesson_id> <verdict_json>`).
15
+
16
+ **This plan moves aggregation and gating into bash instead**, and the lib entry
17
+ point becomes `librarian_lesson_judge <key> <lesson_id> <verdicts_json>`.
18
+
19
+ The reason is the spec's own Testing section. It requires asserting that "a public
20
+ candidate that one judge blocks is rejected even when the aggregate clears
21
+ `score_threshold`" — that is gate behavior. Gate logic living in SKILL.md prose
22
+ cannot be exercised by bats at all, so the spec's central test would be
23
+ unwritable. The division of labor the spec argues for (agent orchestrates, bash
24
+ does the rest) is preserved; the seam simply sits one step later.
25
+
26
+ ## Global Constraints
27
+
28
+ - Runtime artifacts go under `$ONLOOKER_DIR`; never a hardcoded `~/.onlooker`.
29
+ - Project key via `librarian_project_key <repo-root>` from `librarian-project-key.sh`. Never recompute the SHA.
30
+ - **No event emission anywhere in this stage.** `@onlooker-community/schema` 2.11.0 registers only `meridian.lesson.curated`; the emitter exits 1 on an unknown `event_type`.
31
+ - **Do not source any file under `plugins/tribunal/`.** The ADR in Task 5 licenses reusing tribunal's *agent definitions* by name, not its bash. Librarian implements its own aggregate and gate.
32
+ - Bash 3.2 compatible: no associative arrays, no `${var^^}`, no `mapfile`.
33
+ - bats runs under macOS system bash 3.2, where a failing **non-final** `[[ ]]` does NOT fail the test. Every non-final `[[ ]]` assertion needs `|| return 1`. Single-bracket `[ ]` gates on its own.
34
+ - **Assert on messages, not just exit codes.** Two vacuous tests already shipped on the `si6` branch because a non-zero exit was produced by an unrelated guard. If a test asserts a refusal, pin the refusal's text.
35
+ - Every new bats assertion must be broken once to confirm it discriminates. Do fault injection in a **fresh `git worktree`**, never the shared working tree.
36
+ - American English in all comments, messages, and commits.
37
+ - Commit via the `/commit` skill contract: `<type>(<scope>): <subject> :emoji:`, subject ≤72 chars including the emoji, why-focused body.
38
+
39
+ ## File Structure
40
+
41
+ | File | Responsibility |
42
+ |---|---|
43
+ | `plugins/librarian/config.json` | **Modify.** Add `librarian.lesson_judging.rubrics` — the two builtins. |
44
+ | `plugins/librarian/scripts/lib/librarian-lesson-rubric.sh` | **Create.** Load a rubric by id; map a visibility to its rubric id. |
45
+ | `plugins/librarian/scripts/lib/librarian-lesson-judge.sh` | **Create.** Aggregate verdicts, apply the gate, write the result. The heart of the stage. |
46
+ | `plugins/librarian/scripts/lib/librarian-cli.sh` | **Modify.** Add the `judge` verb and `list --confirmed --json`. |
47
+ | `plugins/librarian/skills/librarian/SKILL.md` | **Modify.** Add the `/librarian lessons judge` route. |
48
+ | `plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md` | **Create.** Record the cross-plugin reading. |
49
+ | `test/bats/librarian-lesson-judge.bats` | **Create.** Rubric, aggregate, gate, and record tests. |
50
+ | `test/bats/librarian-lesson-review.bats` | **Modify.** Assert `unconfirm` refuses `approved` and `rejected`. |
51
+
52
+ ---
53
+
54
+ ### Task 1: The two rubrics and their loader
55
+
56
+ **Files:**
57
+ - Modify: `plugins/librarian/config.json`
58
+ - Create: `plugins/librarian/scripts/lib/librarian-lesson-rubric.sh`
59
+ - Test: `test/bats/librarian-lesson-judge.bats`
60
+
61
+ **Interfaces:**
62
+ - Consumes: `librarian_config_load <repo_root>` and `librarian_config_get <jq-path>` from `librarian-config.sh`.
63
+ - Produces:
64
+ - `librarian_lesson_rubric_id_for_visibility <visibility>` → echoes `lesson-promotion` for `org`, `lesson-promotion-public` for `public`, empty string for `private`; returns 1 for anything else.
65
+ - `librarian_lesson_rubric_get <rubric_id>` → echoes the rubric object as compact JSON; returns 1 and echoes nothing if unknown.
66
+
67
+ - [ ] **Step 1: Write the failing tests**
68
+
69
+ Create `test/bats/librarian-lesson-judge.bats`:
70
+
71
+ ```bash
72
+ #!/usr/bin/env bats
73
+
74
+ setup() {
75
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
76
+ setup_test_env
77
+
78
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
79
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
80
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
81
+
82
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
83
+ mkdir -p "$PROJECT_REPO"
84
+ git -C "$PROJECT_REPO" init -q
85
+ git -C "$PROJECT_REPO" config user.email t@example.com
86
+ git -C "$PROJECT_REPO" config user.name "Test"
87
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/fixture.git
88
+
89
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
90
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
91
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-rubric.sh"
92
+ librarian_config_load "$PROJECT_REPO"
93
+
94
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
95
+ }
96
+
97
+ @test "org visibility selects the lesson-promotion rubric" {
98
+ run librarian_lesson_rubric_id_for_visibility "org"
99
+ [ "$status" -eq 0 ]
100
+ [ "$output" = "lesson-promotion" ]
101
+ }
102
+
103
+ @test "public visibility selects the public rubric" {
104
+ run librarian_lesson_rubric_id_for_visibility "public"
105
+ [ "$status" -eq 0 ]
106
+ [ "$output" = "lesson-promotion-public" ]
107
+ }
108
+
109
+ @test "private visibility selects no rubric" {
110
+ run librarian_lesson_rubric_id_for_visibility "private"
111
+ [ "$status" -eq 0 ]
112
+ [ "$output" = "" ]
113
+ }
114
+
115
+ @test "an unknown visibility is refused" {
116
+ run librarian_lesson_rubric_id_for_visibility "everyone"
117
+ [ "$status" -ne 0 ]
118
+ }
119
+
120
+ @test "the org rubric gates on majority and the public rubric on unanimous" {
121
+ local org public
122
+ org=$(librarian_lesson_rubric_get "lesson-promotion")
123
+ public=$(librarian_lesson_rubric_get "lesson-promotion-public")
124
+ [ "$(printf '%s' "$org" | jq -r '.gate_policy')" = "majority" ]
125
+ [ "$(printf '%s' "$public" | jq -r '.gate_policy')" = "unanimous" ]
126
+ }
127
+
128
+ @test "both rubrics carry a 0.75 score threshold and two judge types" {
129
+ local r
130
+ for r in lesson-promotion lesson-promotion-public; do
131
+ local got
132
+ got=$(librarian_lesson_rubric_get "$r")
133
+ [ "$(printf '%s' "$got" | jq -r '.score_threshold')" = "0.75" ]
134
+ [ "$(printf '%s' "$got" | jq -c '.judge_types')" = '["standard","adversarial"]' ]
135
+ done
136
+ }
137
+
138
+ @test "neither rubric carries a max_iterations knob" {
139
+ # There is no Actor in this pipeline, so a retry setting would be a knob
140
+ # that cannot do anything. See the spec's "There is no Actor" section.
141
+ local r
142
+ for r in lesson-promotion lesson-promotion-public; do
143
+ local got
144
+ got=$(librarian_lesson_rubric_get "$r")
145
+ [ "$(printf '%s' "$got" | jq 'has("max_iterations")')" = "false" ]
146
+ done
147
+ }
148
+
149
+ @test "each rubric's criterion weights sum to exactly 1.00" {
150
+ # Tribunal validates each weight in [0,1] but never their total. An
151
+ # unnormalized set would silently mis-score the moment ecosystem-pht
152
+ # implements real weighted_mean.
153
+ local r
154
+ for r in lesson-promotion lesson-promotion-public; do
155
+ local sum
156
+ sum=$(librarian_lesson_rubric_get "$r" | jq '[.criteria[].weight] | add | . * 100 | round')
157
+ [ "$sum" -eq 100 ]
158
+ done
159
+ }
160
+
161
+ @test "only the public rubric carries the disclosure criterion" {
162
+ local org public
163
+ org=$(librarian_lesson_rubric_get "lesson-promotion" | jq -c '[.criteria[].name]')
164
+ public=$(librarian_lesson_rubric_get "lesson-promotion-public" | jq -c '[.criteria[].name]')
165
+ [ "$org" = '["grounding","scope_accuracy","generality"]' ]
166
+ [ "$public" = '["grounding","scope_accuracy","generality","disclosure"]' ]
167
+ }
168
+
169
+ @test "disclosure carries the highest floor of any criterion" {
170
+ local floor
171
+ floor=$(librarian_lesson_rubric_get "lesson-promotion-public" \
172
+ | jq '.criteria[] | select(.name == "disclosure") | .min_pass')
173
+ [ "$floor" = "0.9" ]
174
+ }
175
+
176
+ @test "an unknown rubric id is refused and echoes nothing" {
177
+ run librarian_lesson_rubric_get "no-such-rubric"
178
+ [ "$status" -ne 0 ]
179
+ [ "$output" = "" ]
180
+ }
181
+ ```
182
+
183
+ - [ ] **Step 2: Run the tests to verify they fail**
184
+
185
+ Run: `bats test/bats/librarian-lesson-judge.bats`
186
+ Expected: every test FAILS — `librarian-lesson-rubric.sh` does not exist, so `source` in `setup()` errors.
187
+
188
+ - [ ] **Step 3: Add the rubrics to config.json**
189
+
190
+ Add a `lesson_judging` key inside the existing `librarian` object in
191
+ `plugins/librarian/config.json`, as a sibling of `lesson_transform`.
192
+
193
+ **Use these weights verbatim. Do not recompute them.** The public set is the org
194
+ set scaled to 70% with disclosure taking the remaining 30%, rounded *to preserve
195
+ the sum*: `0.35 × 0.7 = 0.245` is written `0.24`, because rounding it to `0.25`
196
+ totals 1.01.
197
+
198
+ ```json
199
+ "lesson_judging": {
200
+ "rubrics": [
201
+ {
202
+ "id": "lesson-promotion",
203
+ "criteria": [
204
+ { "name": "grounding", "weight": 0.45, "min_pass": 0.7 },
205
+ { "name": "scope_accuracy", "weight": 0.35, "min_pass": 0.7 },
206
+ { "name": "generality", "weight": 0.20, "min_pass": 0.6 }
207
+ ],
208
+ "score_threshold": 0.75,
209
+ "judge_types": ["standard", "adversarial"],
210
+ "gate_policy": "majority"
211
+ },
212
+ {
213
+ "id": "lesson-promotion-public",
214
+ "criteria": [
215
+ { "name": "grounding", "weight": 0.32, "min_pass": 0.7 },
216
+ { "name": "scope_accuracy", "weight": 0.24, "min_pass": 0.7 },
217
+ { "name": "generality", "weight": 0.14, "min_pass": 0.6 },
218
+ { "name": "disclosure", "weight": 0.30, "min_pass": 0.9 }
219
+ ],
220
+ "score_threshold": 0.75,
221
+ "judge_types": ["standard", "adversarial"],
222
+ "gate_policy": "unanimous"
223
+ }
224
+ ]
225
+ }
226
+ ```
227
+
228
+ - [ ] **Step 4: Write the rubric loader**
229
+
230
+ Create `plugins/librarian/scripts/lib/librarian-lesson-rubric.sh`:
231
+
232
+ ```bash
233
+ #!/usr/bin/env bash
234
+ # Rubric selection for lesson judging.
235
+ #
236
+ # Two builtins live in config.json under librarian.lesson_judging.rubrics,
237
+ # mirroring tribunal's rubric.builtins shape so they stay legible to anyone who
238
+ # knows tribunal. Librarian loads them itself rather than sourcing tribunal's
239
+ # lib — see docs/adr/002-agent-definitions-are-shared-assets.md.
240
+ #
241
+ # The per-criterion weights and min_pass floors are DECLARED BUT INERT today:
242
+ # tribunal_aggregate discards the rubric it is handed, and no per-criterion
243
+ # score reaches any gate. They are the honest statement of intent and go live
244
+ # unchanged when ecosystem-pht lands. Nothing here may depend on them.
245
+ #
246
+ # Exposes:
247
+ # librarian_lesson_rubric_id_for_visibility <visibility>
248
+ # librarian_lesson_rubric_get <rubric_id>
249
+
250
+ # Map a confirmed lesson's visibility to the rubric that judges it.
251
+ #
252
+ # `private` maps to the empty string on purpose: that tier runs no jury at all,
253
+ # which is what makes cost scale with intent rather than artifact volume.
254
+ #
255
+ # Usage: librarian_lesson_rubric_id_for_visibility <visibility>
256
+ librarian_lesson_rubric_id_for_visibility() {
257
+ case "${1:-}" in
258
+ private) printf '' ;;
259
+ org) printf 'lesson-promotion' ;;
260
+ public) printf 'lesson-promotion-public' ;;
261
+ *) return 1 ;;
262
+ esac
263
+ return 0
264
+ }
265
+
266
+ # Echo one rubric as compact JSON. Returns 1 and echoes nothing if unknown.
267
+ #
268
+ # Usage: librarian_lesson_rubric_get <rubric_id>
269
+ librarian_lesson_rubric_get() {
270
+ local rubric_id="${1:-}"
271
+ [[ -z "$rubric_id" ]] && return 1
272
+
273
+ local rubrics found
274
+ rubrics=$(librarian_config_get '.librarian.lesson_judging.rubrics')
275
+ [[ -z "$rubrics" || "$rubrics" == "null" ]] && return 1
276
+
277
+ found=$(printf '%s' "$rubrics" | jq -c --arg id "$rubric_id" \
278
+ 'map(select(.id == $id)) | first // empty' 2>/dev/null) || return 1
279
+ [[ -z "$found" || "$found" == "null" ]] && return 1
280
+
281
+ printf '%s' "$found"
282
+ }
283
+ ```
284
+
285
+ - [ ] **Step 5: Run the tests to verify they pass**
286
+
287
+ Run: `bats test/bats/librarian-lesson-judge.bats`
288
+ Expected: PASS, 11/11.
289
+
290
+ If `librarian_config_get` returns the rubrics array as a string rather than
291
+ JSON, check how `config_get` in `scripts/lib/config-loader.sh` handles array
292
+ values and adjust the `jq` invocation — do not work around it by re-reading
293
+ `config.json` directly, which would skip the settings overlay layers.
294
+
295
+ - [ ] **Step 6: Prove the weight-sum test discriminates**
296
+
297
+ In a throwaway worktree only:
298
+
299
+ ```bash
300
+ git worktree add /tmp/lj-verify HEAD
301
+ # In /tmp/lj-verify, change the public rubric's scope_accuracy weight 0.24 -> 0.25
302
+ bats test/bats/librarian-lesson-judge.bats
303
+ # Expected: "each rubric's criterion weights sum to exactly 1.00" FAILS
304
+ git worktree remove --force /tmp/lj-verify
305
+ ```
306
+
307
+ - [ ] **Step 7: Run lint and commit**
308
+
309
+ ```bash
310
+ npm run lint:check
311
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-rubric.sh
312
+ git add plugins/librarian/config.json \
313
+ plugins/librarian/scripts/lib/librarian-lesson-rubric.sh \
314
+ test/bats/librarian-lesson-judge.bats
315
+ ```
316
+
317
+ Commit subject: `feat(librarian): add the two lesson-promotion rubrics :scales:`
318
+
319
+ ---
320
+
321
+ ### Task 2: Aggregate, gate, and record the verdict
322
+
323
+ **Files:**
324
+ - Create: `plugins/librarian/scripts/lib/librarian-lesson-judge.sh`
325
+ - Test: `test/bats/librarian-lesson-judge.bats` (append)
326
+
327
+ **Interfaces:**
328
+ - Consumes: `librarian_lesson_rubric_id_for_visibility`, `librarian_lesson_rubric_get` (Task 1); `librarian_lessons_dir <key>` from `librarian-lesson-storage.sh`.
329
+ - Produces:
330
+ - `librarian_lesson_aggregate <verdicts_json>` → echoes the mean of `.score`, or empty and returns 1 on an empty array.
331
+ - `librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <score_threshold>` → echoes `{"passed":bool,"reason":string}`.
332
+ - `librarian_lesson_judge <key> <lesson_id> <verdicts_json>` → returns **0** when a verdict was recorded, **1** on a usage/state error, **2** when the candidate is unjudged and nothing was written.
333
+
334
+ **The three return codes are the interface Task 3 and Task 4 depend on.** `2` is
335
+ not a failure — it means "could not judge," which the spec requires be kept
336
+ distinct from "judged and failed."
337
+
338
+ - [ ] **Step 1: Write the failing tests**
339
+
340
+ Append to `test/bats/librarian-lesson-judge.bats`. Add these to `setup()` after
341
+ the existing `source` lines:
342
+
343
+ ```bash
344
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
345
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-judge.sh"
346
+
347
+ # A claude stub that fails loudly. Any path asserted to spend no tokens
348
+ # must not invoke it. Same technique that proved stage 5's unavailable
349
+ # path and stage 6's no-model guarantee.
350
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
351
+ mkdir -p "$STUB_BIN"
352
+ cat > "${STUB_BIN}/claude" <<'STUB'
353
+ #!/usr/bin/env bash
354
+ echo "claude was invoked but this path must spend no tokens" >&2
355
+ exit 99
356
+ STUB
357
+ chmod +x "${STUB_BIN}/claude"
358
+ export PATH="${STUB_BIN}:${PATH}"
359
+ ```
360
+
361
+ And this helper, which seeds a confirmed proposal:
362
+
363
+ ```bash
364
+ _seed_confirmed() {
365
+ local id="$1" visibility="$2"
366
+ local dir="$(librarian_lessons_dir "$PROJECT_KEY")/proposals"
367
+ mkdir -p "$dir"
368
+ jq -n --arg id "$id" --arg v "$visibility" \
369
+ --arg t "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
370
+ '{ id: $id, artifact_id: "art-\($id)", status: "confirmed",
371
+ visibility: $v, confirmed_at: $t,
372
+ candidate: { claim: "Prefer jq -c for compact output",
373
+ rationale: "Readable diffs",
374
+ evidence: { resolution: "Applied and verified" },
375
+ applies_to: { stack: ["bash"],
376
+ scope: { kind: "versioned", versions: ">=3.2" } } } }' \
377
+ > "${dir}/${id}.json"
378
+ }
379
+
380
+ _status_of() {
381
+ jq -r '.status' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/${1}.json"
382
+ }
383
+
384
+ # Two judges, both passing, mean 0.85 — clears the 0.75 threshold.
385
+ _verdicts_pass() {
386
+ printf '%s' '[{"judge_type":"standard","score":0.9,"passed":true,"confidence":0.9,"feedback_summary":"Well grounded."},{"judge_type":"adversarial","score":0.8,"passed":true,"confidence":0.8,"feedback_summary":"Holds up."}]'
387
+ }
388
+
389
+ # Split panel: mean 0.85 still clears the threshold, but one judge blocks.
390
+ _verdicts_split() {
391
+ printf '%s' '[{"judge_type":"standard","score":0.95,"passed":true,"confidence":0.9,"feedback_summary":"Strong."},{"judge_type":"adversarial","score":0.75,"passed":false,"confidence":0.8,"feedback_summary":"Scope claim is not supported."}]'
392
+ }
393
+ ```
394
+
395
+ Now the tests:
396
+
397
+ ```bash
398
+ @test "aggregate averages the judges' scores" {
399
+ run librarian_lesson_aggregate "$(_verdicts_pass)"
400
+ [ "$status" -eq 0 ]
401
+ # 0.9 + 0.8 = 1.7 / 2 = 0.85
402
+ [ "$(printf '%s' "$output" | awk '{printf "%.2f", $1}')" = "0.85" ]
403
+ }
404
+
405
+ @test "aggregate refuses an empty panel" {
406
+ run librarian_lesson_aggregate '[]'
407
+ [ "$status" -ne 0 ]
408
+ }
409
+
410
+ @test "a unanimous gate blocks when one judge blocks" {
411
+ run librarian_lesson_gate "unanimous" "$(_verdicts_split)" "0.85" "0.75"
412
+ [ "$status" -eq 0 ]
413
+ [ "$(printf '%s' "$output" | jq -r '.passed')" = "false" ]
414
+ [ "$(printf '%s' "$output" | jq -r '.reason')" = "jury_not_unanimous" ]
415
+ }
416
+
417
+ @test "majority and unanimous diverge on a two-of-three panel" {
418
+ # Two judges with one dissenter does NOT clear majority either — it needs
419
+ # strictly more than half. A three-judge panel is the smallest one where
420
+ # the two policies actually disagree, which is what this pins.
421
+ local three='[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.9,"passed":true},{"judge_type":"standard","score":0.75,"passed":false}]'
422
+ run librarian_lesson_gate "majority" "$three" "0.867" "0.75"
423
+ [ "$status" -eq 0 ]
424
+ [ "$(printf '%s' "$output" | jq -r '.passed')" = "true" ]
425
+
426
+ run librarian_lesson_gate "unanimous" "$three" "0.867" "0.75"
427
+ [ "$(printf '%s' "$output" | jq -r '.passed')" = "false" ]
428
+ [ "$(printf '%s' "$output" | jq -r '.reason')" = "jury_not_unanimous" ]
429
+ }
430
+
431
+ @test "a below-threshold aggregate blocks even when every judge passed" {
432
+ local low='[{"judge_type":"standard","score":0.6,"passed":true},{"judge_type":"adversarial","score":0.6,"passed":true}]'
433
+ run librarian_lesson_gate "majority" "$low" "0.6" "0.75"
434
+ [ "$(printf '%s' "$output" | jq -r '.passed')" = "false" ]
435
+ [ "$(printf '%s' "$output" | jq -r '.reason')" = "below_threshold" ]
436
+ }
437
+
438
+ @test "a private candidate is approved with no model call and no judges" {
439
+ _seed_confirmed "priv01" "private"
440
+ run librarian_lesson_judge "$PROJECT_KEY" "priv01" '[]'
441
+ [ "$status" -eq 0 ]
442
+ [ "$(_status_of priv01)" = "approved" ]
443
+
444
+ local v
445
+ v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/priv01.json")
446
+ [ "$(printf '%s' "$v" | jq -r '.reason')" = "private_no_jury" ]
447
+ [ "$(printf '%s' "$v" | jq -c '.judges')" = "[]" ]
448
+ [ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "null" ]
449
+ }
450
+
451
+ @test "an org candidate the jury passes is approved under the org rubric" {
452
+ _seed_confirmed "org01" "org"
453
+ run librarian_lesson_judge "$PROJECT_KEY" "org01" "$(_verdicts_pass)"
454
+ [ "$status" -eq 0 ]
455
+ [ "$(_status_of org01)" = "approved" ]
456
+
457
+ local v
458
+ v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/org01.json")
459
+ [ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "lesson-promotion" ]
460
+ [ "$(printf '%s' "$v" | jq -r '.gate_policy')" = "majority" ]
461
+ [ "$(printf '%s' "$v" | jq '.judges | length')" -eq 2 ]
462
+ }
463
+
464
+ @test "a public candidate one judge blocks is rejected though the aggregate clears" {
465
+ # The whole reason public differs from org. The mean here is 0.85, well
466
+ # above the 0.75 threshold; only the unanimous policy stops it.
467
+ _seed_confirmed "pub01" "public"
468
+ run librarian_lesson_judge "$PROJECT_KEY" "pub01" "$(_verdicts_split)"
469
+ [ "$status" -eq 0 ]
470
+ [ "$(_status_of pub01)" = "rejected" ]
471
+
472
+ local v
473
+ v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/pub01.json")
474
+ [ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "lesson-promotion-public" ]
475
+ [ "$(printf '%s' "$v" | jq -r '.gate_policy')" = "unanimous" ]
476
+ [ "$(printf '%s' "$v" | jq -r '.reason')" = "jury_not_unanimous" ]
477
+ }
478
+
479
+ @test "the same split panel is rejected at org, but for the majority reason" {
480
+ # The verdicts are identical to the public case above. 1 of 2 passing
481
+ # clears neither policy, so both tiers reject — what this pins is that the
482
+ # RUBRIC actually differs by visibility, via the recorded reason and id.
483
+ # Without this, the public test alone would pass even if both visibilities
484
+ # resolved to the same rubric.
485
+ _seed_confirmed "org02" "org"
486
+ run librarian_lesson_judge "$PROJECT_KEY" "org02" "$(_verdicts_split)"
487
+ [ "$status" -eq 0 ]
488
+ [ "$(_status_of org02)" = "rejected" ]
489
+
490
+ local v
491
+ v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/org02.json")
492
+ [ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "lesson-promotion" ]
493
+ [ "$(printf '%s' "$v" | jq -r '.gate_policy')" = "majority" ]
494
+ [ "$(printf '%s' "$v" | jq -r '.reason')" = "jury_not_majority" ]
495
+ }
496
+
497
+ @test "a three-judge panel with one dissenter separates the two tiers" {
498
+ # The case where the tiers genuinely produce different OUTCOMES, not just
499
+ # different reasons. This is the public tier's whole justification.
500
+ local three='[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.9,"passed":true},{"judge_type":"standard","score":0.8,"passed":false}]'
501
+
502
+ _seed_confirmed "tier01" "org"
503
+ run librarian_lesson_judge "$PROJECT_KEY" "tier01" "$three"
504
+ [ "$status" -eq 0 ]
505
+ [ "$(_status_of tier01)" = "approved" ]
506
+
507
+ _seed_confirmed "tier02" "public"
508
+ run librarian_lesson_judge "$PROJECT_KEY" "tier02" "$three"
509
+ [ "$status" -eq 0 ]
510
+ [ "$(_status_of tier02)" = "rejected" ]
511
+ }
512
+
513
+ @test "a rejected proposal keeps its file" {
514
+ # librarian_lesson_seen scans proposals/ by artifact_id; deleting the file
515
+ # would let the same artifact re-propose and re-pay tokens next scan.
516
+ _seed_confirmed "rej01" "public"
517
+ run librarian_lesson_judge "$PROJECT_KEY" "rej01" "$(_verdicts_split)"
518
+ [ -f "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/rej01.json" ]
519
+ }
520
+
521
+ @test "a malformed verdict leaves the candidate confirmed and writes nothing" {
522
+ _seed_confirmed "bad01" "org"
523
+ local before
524
+ before=$(cat "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/bad01.json")
525
+
526
+ run librarian_lesson_judge "$PROJECT_KEY" "bad01" '[{"judge_type":"standard","score":"not-a-number","passed":true}]'
527
+ [ "$status" -eq 2 ]
528
+ [ "$(_status_of bad01)" = "confirmed" ]
529
+ [ "$(cat "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/bad01.json")" = "$before" ]
530
+ }
531
+
532
+ @test "unparseable verdict JSON is unjudged, not rejected" {
533
+ _seed_confirmed "bad02" "org"
534
+ run librarian_lesson_judge "$PROJECT_KEY" "bad02" 'this is not json'
535
+ [ "$status" -eq 2 ]
536
+ [ "$(_status_of bad02)" = "confirmed" ]
537
+ }
538
+
539
+ @test "an empty panel on a non-private candidate is unjudged" {
540
+ _seed_confirmed "bad03" "public"
541
+ run librarian_lesson_judge "$PROJECT_KEY" "bad03" '[]'
542
+ [ "$status" -eq 2 ]
543
+ [ "$(_status_of bad03)" = "confirmed" ]
544
+ }
545
+
546
+ @test "judging proceeds only from confirmed" {
547
+ _seed_confirmed "st01" "org"
548
+ local path="$(librarian_lessons_dir "$PROJECT_KEY")/proposals/st01.json"
549
+ local tmp="${BATS_TEST_TMPDIR}/st01.json"
550
+ jq '.status = "pending"' "$path" > "$tmp" && mv "$tmp" "$path"
551
+
552
+ run librarian_lesson_judge "$PROJECT_KEY" "st01" "$(_verdicts_pass)"
553
+ [ "$status" -eq 1 ]
554
+ [[ "$output" == *"pending"* ]] || return 1
555
+ [ "$(_status_of st01)" = "pending" ]
556
+ }
557
+
558
+ @test "re-judging an already-approved candidate is refused, naming the status" {
559
+ _seed_confirmed "st02" "org"
560
+ run librarian_lesson_judge "$PROJECT_KEY" "st02" "$(_verdicts_pass)"
561
+ [ "$status" -eq 0 ]
562
+
563
+ run librarian_lesson_judge "$PROJECT_KEY" "st02" "$(_verdicts_pass)"
564
+ [ "$status" -eq 1 ]
565
+ [[ "$output" == *"approved"* ]] || return 1
566
+ }
567
+
568
+ @test "a missing lesson is refused" {
569
+ run librarian_lesson_judge "$PROJECT_KEY" "nope01" "$(_verdicts_pass)"
570
+ [ "$status" -eq 1 ]
571
+ [[ "$output" == *"not found"* ]] || return 1
572
+ }
573
+ ```
574
+
575
+ - [ ] **Step 2: Run the tests to verify they fail**
576
+
577
+ Run: `bats test/bats/librarian-lesson-judge.bats`
578
+ Expected: the new tests FAIL — `librarian-lesson-judge.sh` does not exist.
579
+
580
+ - [ ] **Step 3: Write the judge lib**
581
+
582
+ Create `plugins/librarian/scripts/lib/librarian-lesson-judge.sh`:
583
+
584
+ ```bash
585
+ #!/usr/bin/env bash
586
+ # Aggregate, gate, and record a jury verdict on a confirmed lesson.
587
+ #
588
+ # The agent orchestrates the jury; this file decides. Everything except
589
+ # subagent dispatch lives here so it can be tested.
590
+ #
591
+ # Librarian implements its own aggregate and gate rather than sourcing
592
+ # tribunal's. Reusing tribunal's published AGENT definitions is licensed by
593
+ # docs/adr/002-agent-definitions-are-shared-assets.md; sourcing its bash would
594
+ # be the hook-to-hook runtime coupling that ADR rules out.
595
+ #
596
+ # Exposes:
597
+ # librarian_lesson_aggregate <verdicts_json>
598
+ # librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <threshold>
599
+ # librarian_lesson_judge <key> <lesson_id> <verdicts_json>
600
+
601
+ # Mean of the judges' scores. Returns 1 on an empty panel.
602
+ #
603
+ # A plain mean, deliberately: per-criterion scores never reach this layer, so
604
+ # there is nothing to weight. See ecosystem-pht.
605
+ #
606
+ # Usage: librarian_lesson_aggregate <verdicts_json>
607
+ librarian_lesson_aggregate() {
608
+ local verdicts="${1:-[]}"
609
+ local n
610
+ n=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || return 1
611
+ [[ -z "$n" || "$n" -eq 0 ]] && return 1
612
+ printf '%s' "$verdicts" | jq -r '[.[].score] | add / length' 2>/dev/null || return 1
613
+ }
614
+
615
+ # Decide pass/block from the panel and the aggregate.
616
+ #
617
+ # Echoes {"passed": bool, "reason": string}. Both conditions must hold: the
618
+ # jury must clear its policy AND the aggregate must clear the threshold.
619
+ #
620
+ # Usage: librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <threshold>
621
+ librarian_lesson_gate() {
622
+ local policy="${1:-majority}"
623
+ local verdicts="${2:-[]}"
624
+ local aggregate="${3:-0}"
625
+ local threshold="${4:-0.75}"
626
+
627
+ local count passed_count
628
+ count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
629
+ passed_count=$(printf '%s' "$verdicts" | jq '[.[] | select(.passed == true)] | length' 2>/dev/null) || passed_count=0
630
+
631
+ local jury_ok=1 jury_reason=""
632
+ case "$policy" in
633
+ unanimous)
634
+ if [[ "$count" -gt 0 && "$passed_count" -eq "$count" ]]; then
635
+ jury_ok=0
636
+ else
637
+ jury_reason="jury_not_unanimous"
638
+ fi
639
+ ;;
640
+ majority)
641
+ if [[ "$count" -gt 0 ]] && (( passed_count * 2 > count )); then
642
+ jury_ok=0
643
+ else
644
+ jury_reason="jury_not_majority"
645
+ fi
646
+ ;;
647
+ *)
648
+ printf '{"passed":false,"reason":"unknown_gate_policy"}'
649
+ return 0
650
+ ;;
651
+ esac
652
+
653
+ if [[ "$jury_ok" -ne 0 ]]; then
654
+ printf '{"passed":false,"reason":"%s"}' "$jury_reason"
655
+ return 0
656
+ fi
657
+
658
+ # awk for the float comparison: bash cannot compare decimals.
659
+ if awk -v s="$aggregate" -v t="$threshold" 'BEGIN { exit !(s >= t) }'; then
660
+ printf '{"passed":true,"reason":"gate_passed"}'
661
+ else
662
+ printf '{"passed":false,"reason":"below_threshold"}'
663
+ fi
664
+ return 0
665
+ }
666
+
667
+ # Judge one confirmed lesson and record the outcome.
668
+ #
669
+ # Return codes are the interface the CLI and skill depend on:
670
+ # 0 a verdict was recorded (status is now approved or rejected)
671
+ # 1 usage or state error; nothing written
672
+ # 2 UNJUDGED — the panel was unusable; nothing written, lesson stays
673
+ # confirmed, and the next run retries it
674
+ #
675
+ # 2 is not a failure. "Judged and failed" must stay distinct from "could not
676
+ # judge": the watermark has already advanced past this artifact, so treating a
677
+ # broken judge as a rejection would bury a good lesson permanently.
678
+ #
679
+ # Usage: librarian_lesson_judge <key> <lesson_id> <verdicts_json>
680
+ librarian_lesson_judge() {
681
+ local key="$1"
682
+ local lesson_id="$2"
683
+ local verdicts="${3:-[]}"
684
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
685
+
686
+ local path
687
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
688
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
689
+
690
+ local current_status visibility
691
+ current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
692
+ visibility=$(jq -r '.visibility // ""' "$path" 2>/dev/null)
693
+
694
+ if [[ "$current_status" != "confirmed" ]]; then
695
+ printf 'Lesson %s is not confirmed; its status is: %s\n' "$lesson_id" "$current_status" >&2
696
+ return 1
697
+ fi
698
+
699
+ local rubric_id
700
+ rubric_id=$(librarian_lesson_rubric_id_for_visibility "$visibility") || {
701
+ printf 'Lesson %s has an unrecognized visibility: %s\n' "$lesson_id" "$visibility" >&2
702
+ return 1
703
+ }
704
+
705
+ local verdict now
706
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
707
+
708
+ if [[ -z "$rubric_id" ]]; then
709
+ # private: no jury, no model call, straight through.
710
+ verdict=$(jq -cn --arg t "$now" \
711
+ '{rubric_id: null, gate_policy: null, score_threshold: null,
712
+ aggregate_score: null, passed: true, reason: "private_no_jury",
713
+ judges: []}') || return 1
714
+ else
715
+ # Every judge must have returned a usable verdict, or this candidate
716
+ # is unjudged. With a two-judge panel under either policy, losing one
717
+ # verdict means the gate cannot be decided at all.
718
+ local usable
719
+ usable=$(printf '%s' "$verdicts" | jq '
720
+ if type != "array" or length == 0 then false
721
+ else all(.[]; (.judge_type | type) == "string"
722
+ and (.score | type) == "number"
723
+ and (.passed | type) == "boolean")
724
+ end' 2>/dev/null) || usable="false"
725
+ [[ "$usable" != "true" ]] && return 2
726
+
727
+ local rubric threshold policy aggregate gate
728
+ rubric=$(librarian_lesson_rubric_get "$rubric_id") || return 1
729
+ threshold=$(printf '%s' "$rubric" | jq -r '.score_threshold')
730
+ policy=$(printf '%s' "$rubric" | jq -r '.gate_policy')
731
+
732
+ aggregate=$(librarian_lesson_aggregate "$verdicts") || return 2
733
+ gate=$(librarian_lesson_gate "$policy" "$verdicts" "$aggregate" "$threshold") || return 1
734
+
735
+ verdict=$(jq -cn \
736
+ --arg r "$rubric_id" --arg p "$policy" \
737
+ --argjson th "$threshold" --argjson ag "$aggregate" \
738
+ --argjson g "$gate" --argjson j "$verdicts" \
739
+ '{rubric_id: $r, gate_policy: $p, score_threshold: $th,
740
+ aggregate_score: $ag, passed: $g.passed, reason: $g.reason,
741
+ judges: $j}') || return 1
742
+ fi
743
+
744
+ local new_status updated
745
+ if [[ "$(printf '%s' "$verdict" | jq -r '.passed')" == "true" ]]; then
746
+ new_status="approved"
747
+ else
748
+ new_status="rejected"
749
+ fi
750
+
751
+ updated=$(jq --arg s "$new_status" --arg t "$now" --argjson v "$verdict" \
752
+ '.status = $s | .judged_at = $t | .verdict = $v' "$path" 2>/dev/null) || return 1
753
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
754
+ printf '%s\n' "$updated" > "$path"
755
+ }
756
+ ```
757
+
758
+ - [ ] **Step 4: Run the tests to verify they pass**
759
+
760
+ Run: `bats test/bats/librarian-lesson-judge.bats`
761
+ Expected: PASS, all tests.
762
+
763
+ - [ ] **Step 5: Prove the tier-separating test discriminates**
764
+
765
+ This is the test the whole public tier exists for. In a throwaway worktree,
766
+ change the public rubric's `gate_policy` from `unanimous` to `majority`:
767
+
768
+ ```bash
769
+ git worktree add /tmp/lj-verify2 HEAD
770
+ # In /tmp/lj-verify2 only: plugins/librarian/config.json,
771
+ # "lesson-promotion-public" -> "gate_policy": "majority"
772
+ bats test/bats/librarian-lesson-judge.bats
773
+ git worktree remove --force /tmp/lj-verify2
774
+ ```
775
+
776
+ Expected: **"a three-judge panel with one dissenter separates the two tiers"
777
+ FAILS** — `tier02` would be approved. That test is the one that proves the
778
+ outcomes diverge; the two-judge cases reject under either policy and so cannot
779
+ detect this on their own.
780
+
781
+ Report which tests failed under the injection. If only the `.reason`
782
+ assertions moved and no `.status` did, say so plainly rather than reporting the
783
+ guard as proven.
784
+
785
+ - [ ] **Step 6: Run lint and commit**
786
+
787
+ ```bash
788
+ npm run lint:check
789
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-judge.sh
790
+ git add plugins/librarian/scripts/lib/librarian-lesson-judge.sh \
791
+ test/bats/librarian-lesson-judge.bats
792
+ ```
793
+
794
+ Commit subject: `feat(librarian): judge a confirmed lesson and record it :gavel:`
795
+
796
+ ---
797
+
798
+ ### Task 3: The CLI surface
799
+
800
+ **Files:**
801
+ - Modify: `plugins/librarian/scripts/lib/librarian-cli.sh`
802
+ - Test: `test/bats/librarian-lesson-judge.bats` (append)
803
+
804
+ **Interfaces:**
805
+ - Consumes: `librarian_lesson_judge <key> <lesson_id> <verdicts_json>` with its 0/1/2 return codes (Task 2); `librarian_lesson_list_by_status <key> <status>` (already shipped).
806
+ - Produces: `librarian_cli lessons judge <id> <verdicts-json> [cwd]` and `librarian_cli lessons list --confirmed --json`.
807
+
808
+ `--json` is a small addition beyond the spec. The skill needs each candidate's
809
+ `visibility` to pick a rubric and to count publics for the pre-spend prompt;
810
+ without it the skill would parse a two-column text format and then call `show`
811
+ once per candidate. One machine-readable call replaces N+1 fragile ones.
812
+
813
+ - [ ] **Step 1: Write the failing tests**
814
+
815
+ Append to `test/bats/librarian-lesson-judge.bats`. Add to `setup()`:
816
+
817
+ ```bash
818
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
819
+ ```
820
+
821
+ ```bash
822
+ @test "lessons judge records a verdict through the CLI" {
823
+ _seed_confirmed "cli01" "org"
824
+ run librarian_cli lessons judge "cli01" "$(_verdicts_pass)" "$PROJECT_REPO"
825
+ [ "$status" -eq 0 ]
826
+ [ "$(_status_of cli01)" = "approved" ]
827
+ [[ "$output" == *"approved"* ]] || return 1
828
+ }
829
+
830
+ @test "lessons judge reports an unjudged candidate distinctly from a rejection" {
831
+ _seed_confirmed "cli02" "org"
832
+ run librarian_cli lessons judge "cli02" '[{"judge_type":"standard","score":"nope","passed":true}]' "$PROJECT_REPO"
833
+ [ "$status" -eq 2 ]
834
+ [[ "$output" == *"could not be judged"* ]] || return 1
835
+ [ "$(_status_of cli02)" = "confirmed" ]
836
+ }
837
+
838
+ @test "lessons judge requires a lesson id" {
839
+ run librarian_cli lessons judge
840
+ [ "$status" -ne 0 ]
841
+ [[ "$output" == *"usage:"* && "$output" == *"judge"* ]] || return 1
842
+ }
843
+
844
+ @test "lessons judge requires verdicts" {
845
+ run librarian_cli lessons judge "cli03"
846
+ [ "$status" -ne 0 ]
847
+ [[ "$output" == *"usage:"* && "$output" == *"judge"* ]] || return 1
848
+ }
849
+
850
+ @test "lessons judge rejects an unknown flag" {
851
+ _seed_confirmed "cli04" "org"
852
+ run librarian_cli lessons judge "cli04" "$(_verdicts_pass)" --force
853
+ [ "$status" -ne 0 ]
854
+ [[ "$output" == *"unknown option"* && "$output" == *"--force"* ]] || return 1
855
+ }
856
+
857
+ @test "lessons list --confirmed --json emits rows carrying visibility" {
858
+ _seed_confirmed "js01" "public"
859
+ _seed_confirmed "js02" "org"
860
+ run librarian_cli lessons list --confirmed --json "$PROJECT_REPO"
861
+ [ "$status" -eq 0 ]
862
+ [ "$(printf '%s' "$output" | jq 'length')" -eq 2 ]
863
+ [ "$(printf '%s' "$output" | jq -r '[.[] | select(.visibility == "public")] | length')" -eq 1 ]
864
+ [ "$(printf '%s' "$output" | jq -r '.[0] | has("id")')" = "true" ]
865
+ }
866
+
867
+ @test "lessons list --json on an empty set emits an empty array, not prose" {
868
+ # The skill parses this; a human-readable empty-state message would break it.
869
+ run librarian_cli lessons list --confirmed --json "$PROJECT_REPO"
870
+ [ "$status" -eq 0 ]
871
+ [ "$output" = "[]" ]
872
+ }
873
+
874
+ @test "bare lessons list is unchanged by the --json addition" {
875
+ run librarian_cli lessons list "$PROJECT_REPO"
876
+ [ "$status" -eq 0 ]
877
+ [ "$output" = "No pending lessons." ]
878
+ }
879
+ ```
880
+
881
+ - [ ] **Step 2: Run the tests to verify they fail**
882
+
883
+ Run: `bats test/bats/librarian-lesson-judge.bats`
884
+ Expected: the eight new tests FAIL — `judge` is an unknown lessons action, and `--json` is refused as an unknown option.
885
+
886
+ - [ ] **Step 3: Add `--json` to the list verb**
887
+
888
+ In `librarian_cli_lessons_list`, add a `json=0` local, a `--json) json=1; shift ;;`
889
+ arm to the existing `while` loop before the `--*` catch-all, and branch the
890
+ output:
891
+
892
+ ```bash
893
+ if [[ "$json" -eq 1 ]]; then
894
+ printf '%s' "$rows"
895
+ return 0
896
+ fi
897
+ ```
898
+
899
+ Place that **before** the existing empty-check, so an empty set emits `[]`
900
+ rather than the prose empty-state the skill cannot parse.
901
+
902
+ - [ ] **Step 4: Add the judge verb**
903
+
904
+ Add to `librarian-cli.sh`, following the shape of the sibling verbs:
905
+
906
+ ```bash
907
+ # Usage: librarian_cli_lessons_judge <lesson_id> <verdicts-json> [cwd]
908
+ #
909
+ # Verdicts come from the judge subagents the skill dispatched. Exit 2 means the
910
+ # candidate could not be judged and stays confirmed — distinct from a rejection,
911
+ # which is a real verdict and exits 0.
912
+ librarian_cli_lessons_judge() {
913
+ local lesson_id="" verdicts="" cwd=""
914
+ local positional=0
915
+ while [[ $# -gt 0 ]]; do
916
+ case "$1" in
917
+ --*)
918
+ printf 'unknown option: %s\n' "$1" >&2
919
+ return 1
920
+ ;;
921
+ *)
922
+ case "$positional" in
923
+ 0) lesson_id="$1" ;;
924
+ 1) verdicts="$1" ;;
925
+ *) cwd="$1" ;;
926
+ esac
927
+ positional=$((positional + 1))
928
+ shift
929
+ ;;
930
+ esac
931
+ done
932
+
933
+ if [[ -z "$lesson_id" || -z "$verdicts" ]]; then
934
+ printf 'usage: librarian_cli lessons judge <lesson_id> <verdicts-json> [cwd]\n'
935
+ return 1
936
+ fi
937
+
938
+ local key
939
+ key=$(_librarian_cli_project_key "$cwd")
940
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
941
+
942
+ librarian_lesson_judge "$key" "$lesson_id" "$verdicts"
943
+ local rc=$?
944
+ case "$rc" in
945
+ 0)
946
+ printf 'Lesson %s is now %s.\n' "$lesson_id" \
947
+ "$(jq -r '.status' "$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json")"
948
+ ;;
949
+ 2)
950
+ printf 'Lesson %s could not be judged; it stays confirmed. Re-run to retry.\n' "$lesson_id"
951
+ ;;
952
+ esac
953
+ return $rc
954
+ }
955
+ ```
956
+
957
+ Register it in `librarian_cli_lessons`'s `case`, after `unconfirm`:
958
+
959
+ ```bash
960
+ judge) librarian_cli_lessons_judge "$@" ;;
961
+ ```
962
+
963
+ Source `librarian-lesson-judge.sh` and `librarian-lesson-rubric.sh` alongside
964
+ the existing lesson lib sources in `librarian-cli.sh`.
965
+
966
+ - [ ] **Step 5: Run the tests to verify they pass**
967
+
968
+ Run: `bats test/bats/librarian-lesson-judge.bats && bats test/bats/librarian-cli.bats && bats test/bats/librarian-lesson-review.bats`
969
+ Expected: all PASS. Read each exit code directly — never through a pipe, which reports the pipe's last command.
970
+
971
+ - [ ] **Step 6: Prove the flag guard and the empty-array test discriminate**
972
+
973
+ In a throwaway worktree, separately: remove the `--*` arm from
974
+ `librarian_cli_lessons_judge` and confirm "lessons judge rejects an unknown
975
+ flag" fails; then move the `--json` early return *after* the empty-check and
976
+ confirm "lessons list --json on an empty set emits an empty array" fails.
977
+
978
+ - [ ] **Step 7: Run lint and commit**
979
+
980
+ ```bash
981
+ npm run lint:check
982
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-cli.sh
983
+ git add plugins/librarian/scripts/lib/librarian-cli.sh test/bats/librarian-lesson-judge.bats
984
+ ```
985
+
986
+ Commit subject: `feat(librarian): surface judging on the lessons CLI :gavel:`
987
+
988
+ ---
989
+
990
+ ### Task 4: Assert the seam si6 left for this stage
991
+
992
+ **Files:**
993
+ - Test: `test/bats/librarian-lesson-review.bats` (append)
994
+
995
+ **Interfaces:**
996
+ - Consumes: `librarian_lesson_judge` (Task 2), `librarian_lesson_unconfirm` (already shipped).
997
+ - Produces: nothing. This task adds tests only.
998
+
999
+ `si6` promised that `unconfirm` would refuse whatever status this stage
1000
+ introduced, automatically, through its catch-all — with no change to `unconfirm`
1001
+ and no coordination between the stages. **Do not modify `librarian_lesson_unconfirm`.**
1002
+ This task collects on that promise by asserting it.
1003
+
1004
+ - [ ] **Step 1: Write the tests**
1005
+
1006
+ Append to `test/bats/librarian-lesson-review.bats`. Reuse that file's existing
1007
+ setup and seeding helpers; source `librarian-lesson-judge.sh` and
1008
+ `librarian-lesson-rubric.sh` in its `setup()` if they are not already.
1009
+
1010
+ The existing helpers in that file are `_review_setup` (call it first, as every
1011
+ test there does) and `_seed_pending`, which writes a `versioned` candidate and
1012
+ echoes its id.
1013
+
1014
+ ```bash
1015
+ @test "unconfirm refuses an approved lesson, naming the status" {
1016
+ _review_setup
1017
+ local id
1018
+ id=$(_seed_pending)
1019
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
1020
+ librarian_lesson_judge "$PROJECT_KEY" "$id" \
1021
+ '[{"judge_type":"standard","score":0.9,"passed":true},{"judge_type":"adversarial","score":0.8,"passed":true}]'
1022
+
1023
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
1024
+ [ "$status" -ne 0 ]
1025
+ [[ "$output" == *"unrecognized status"* && "$output" == *"approved"* ]] || return 1
1026
+ }
1027
+
1028
+ @test "unconfirm refuses a rejected lesson, naming the status" {
1029
+ _review_setup
1030
+ local id
1031
+ id=$(_seed_pending)
1032
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
1033
+ librarian_lesson_judge "$PROJECT_KEY" "$id" \
1034
+ '[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.75,"passed":false}]'
1035
+
1036
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
1037
+ [ "$status" -ne 0 ]
1038
+ [[ "$output" == *"unrecognized status"* && "$output" == *"rejected"* ]] || return 1
1039
+ }
1040
+ ```
1041
+
1042
+ `_seed_pending` writes a `versioned` candidate, so confirming at `public` needs
1043
+ no `--justification` — that flag is only required to convert scope to
1044
+ `version_independent`.
1045
+
1046
+ - [ ] **Step 2: Run the tests**
1047
+
1048
+ Run: `bats test/bats/librarian-lesson-review.bats`
1049
+ Expected: PASS. These pass on the first try — that is the point. `unconfirm`'s
1050
+ catch-all already handles both statuses.
1051
+
1052
+ - [ ] **Step 3: Confirm the tests are not vacuous**
1053
+
1054
+ Both assert a non-zero exit, which several guards in this file can produce.
1055
+ The message pin (`unrecognized status` plus the status name) is what makes them
1056
+ discriminate. Verify in a throwaway worktree: change `unconfirm`'s catch-all to
1057
+ `*) return 1 ;;` with no message, and confirm both tests fail.
1058
+
1059
+ - [ ] **Step 4: Commit**
1060
+
1061
+ ```bash
1062
+ git add test/bats/librarian-lesson-review.bats
1063
+ ```
1064
+
1065
+ Commit subject: `test(librarian): collect on si6's forward-safety promise :handshake:`
1066
+
1067
+ ---
1068
+
1069
+ ### Task 5: The skill route and the ADR
1070
+
1071
+ **Files:**
1072
+ - Modify: `plugins/librarian/skills/librarian/SKILL.md`
1073
+ - Create: `plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md`
1074
+
1075
+ **Interfaces:**
1076
+ - Consumes: `librarian_cli lessons list --confirmed --json`, `librarian_cli lessons show <id>`, `librarian_cli lessons judge <id> <verdicts-json>` (Task 3).
1077
+ - Produces: nothing programmatic.
1078
+
1079
+ - [ ] **Step 1: Write the ADR**
1080
+
1081
+ Create `plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md`:
1082
+
1083
+ ```markdown
1084
+ # ADR-002: Agent definitions are shared assets; hooks are not
1085
+
1086
+ ## Status
1087
+
1088
+ Accepted.
1089
+
1090
+ ## Context
1091
+
1092
+ CLAUDE.md states: "Plugins communicate by emitting events to the JSONL log —
1093
+ they do not call each other directly. All plugins depend on the ecosystem
1094
+ substrate; no plugin depends on another plugin directly."
1095
+
1096
+ Lesson judging (`ecosystem-4z8.3`) needs a jury. Tribunal ships three judge
1097
+ agent definitions and the rubric vocabulary. The stage couples librarian and
1098
+ tribunal in some direction no matter how it is arranged: either librarian
1099
+ reaches for tribunal's judges, or tribunal reaches into librarian's
1100
+ project-keyed proposal files and writes verdicts back into them.
1101
+
1102
+ ## Decision
1103
+
1104
+ The invariant forbids **runtime** coupling — one plugin's hook or library
1105
+ calling another's, which makes one plugin's failure another's. It does not
1106
+ forbid reusing a **published agent definition** by name.
1107
+
1108
+ Librarian therefore owns the `judge` verb, both rubrics, the aggregate, and the
1109
+ gate decision, and dispatches `tribunal-judge-standard` and
1110
+ `tribunal-judge-adversarial` by name.
1111
+
1112
+ Librarian does **not** source any file under `plugins/tribunal/`. It implements
1113
+ its own aggregate and gate — roughly twenty lines — rather than calling
1114
+ `tribunal_aggregate` or `tribunal_gate_decide`.
1115
+
1116
+ ## Rationale
1117
+
1118
+ An agent definition is declarative: a markdown file with frontmatter and a
1119
+ prompt. It has no runtime surface, cannot fail at call time in a way that
1120
+ propagates, and is resolved by the harness rather than by librarian. Depending
1121
+ on one is closer to depending on a published schema than to calling another
1122
+ plugin's code.
1123
+
1124
+ Sourcing tribunal's bash would be the real coupling: a change to
1125
+ `tribunal_gate_decide`'s signature would break librarian silently, and
1126
+ tribunal's own tests would not catch it.
1127
+
1128
+ Keeping the lifecycle in librarian also keeps `ecosystem-4z8.4`'s pool and
1129
+ ledger in one plugin instead of splitting them across two.
1130
+
1131
+ ## Consequences
1132
+
1133
+ A judge agent renamed or removed in tribunal breaks lesson judging at dispatch
1134
+ time. That is a visible, loud failure at the moment a human invokes the verb —
1135
+ not a silent one — and the "could not judge" path already handles it: the
1136
+ candidate stays `confirmed` and nothing is written.
1137
+
1138
+ Librarian's gate logic can drift from tribunal's. Accepted deliberately: they
1139
+ answer different questions. Tribunal gates an Actor's output with retry;
1140
+ librarian gates a fixed artifact with none.
1141
+ ```
1142
+
1143
+ - [ ] **Step 2: Add the judge route to SKILL.md**
1144
+
1145
+ Add to the frontmatter `description`, in the sentence that lists the lesson
1146
+ routes, so `judge` is discoverable: mention `/librarian lessons judge`.
1147
+
1148
+ Add this section after the existing `lessons` walk:
1149
+
1150
+ ```markdown
1151
+ For `lessons judge`, run the jury over confirmed candidates:
1152
+
1153
+ 1. Call `librarian_cli lessons list --confirmed --json`. Each row carries `id`,
1154
+ `visibility`, and the full `candidate`. If the array is empty, tell the user
1155
+ there is nothing awaiting judgment and stop.
1156
+ 2. **Report the batch before spending anything.** Say how many candidates are
1157
+ confirmed and how many are `public`, and ask whether to proceed. This is the
1158
+ most expensive step in the pipeline. If the user declines, stop — nothing is
1159
+ written and every candidate stays `confirmed`.
1160
+ 3. For each candidate, in order:
1161
+ - **If `visibility` is `private`, dispatch no judges at all.** Call
1162
+ `librarian_cli lessons judge <id> '[]'` and move on. Private lessons run no
1163
+ jury; that is what makes cost scale with intent rather than artifact volume.
1164
+ - Otherwise spawn **both** `tribunal-judge-standard` and
1165
+ `tribunal-judge-adversarial` with the Task tool. Give each the candidate's
1166
+ `claim`, `rationale`, `evidence.resolution`, and `applies_to`, plus the
1167
+ rubric criteria for its visibility: for `org`, grounding / scope_accuracy /
1168
+ generality; for `public`, those three plus **disclosure** — does the text
1169
+ leak a credential, internal hostname, customer name, or proprietary detail?
1170
+ - Each judge returns a JSON object with `score`, `passed`, `judge_type`, and
1171
+ `feedback_summary`. Collect both into a JSON array **verbatim** — never
1172
+ summarize or reconstruct a judge's verdict.
1173
+ - Call `librarian_cli lessons judge <id> '<verdicts-json>'`. Record before
1174
+ moving to the next candidate, so an interrupted run costs at most one
1175
+ re-judgment.
1176
+ 4. **If either judge fails to return parseable JSON, do not invent a verdict and
1177
+ do not drop that judge.** Pass what you have to the CLI; it will exit 2, leave
1178
+ the candidate `confirmed`, and report that it could not be judged. Collect
1179
+ those ids and list them at the end so the user knows to re-run. A broken judge
1180
+ must never become a rejection — the artifact's watermark has already moved,
1181
+ so a false rejection buries a good lesson permanently.
1182
+ 5. Finish by reporting counts: approved, rejected, and could-not-judge.
1183
+
1184
+ `scope_accuracy` is the criterion that matters most on a `version_independent`
1185
+ candidate. The schema guarantees such a lesson **carries** a justification; this
1186
+ criterion asks whether it is **true**.
1187
+ ```
1188
+
1189
+ - [ ] **Step 3: Verify the docs lint and cross-references pass**
1190
+
1191
+ ```bash
1192
+ npm run lint:check
1193
+ npm run test:schema
1194
+ ```
1195
+
1196
+ Expected: both exit 0. `check-references.mjs` validates that documents referenced
1197
+ from other documents exist; a mistyped ADR path fails there.
1198
+
1199
+ - [ ] **Step 4: Run the full suite**
1200
+
1201
+ ```bash
1202
+ npm run test:ci
1203
+ ```
1204
+
1205
+ Expected: exit 0. Read the exit code directly, not through a pipe.
1206
+
1207
+ - [ ] **Step 5: Commit**
1208
+
1209
+ ```bash
1210
+ git add plugins/librarian/skills/librarian/SKILL.md \
1211
+ plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md
1212
+ ```
1213
+
1214
+ Commit subject: `feat(librarian): walk the jury from the lessons skill :balance_scale:`
1215
+
1216
+ ---
1217
+
1218
+ ## Spec coverage
1219
+
1220
+ Every case in the spec's Testing section maps to a task:
1221
+
1222
+ | Spec test | Task |
1223
+ |---|---|
1224
+ | `private` reaches `approved` with no model call | 2 |
1225
+ | `org` uses `lesson-promotion` + majority | 1, 2 |
1226
+ | `public` uses `lesson-promotion-public` + unanimous | 1, 2 |
1227
+ | a public candidate one judge blocks is rejected though the aggregate clears | 2 |
1228
+ | one judge returning unparseable output leaves it `confirmed`, id reported | 2, 3 |
1229
+ | a below-threshold candidate is rejected and keeps its file | 2 |
1230
+ | `unconfirm` refuses `approved` and `rejected`, naming the status | 4 |
1231
+ | re-running skips candidates that are not `confirmed` | 2 |
1232
+ | both rubrics' weights sum to 1.00 | 1 |