@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,312 @@
1
+ #!/usr/bin/env bash
2
+ # Lesson confirmation — the human intent filter.
3
+ #
4
+ # The human picks which candidates go to the jury and their visibility, before
5
+ # any expensive tokens are spent. Intent is cheap and only a human can supply
6
+ # it; quality is expensive and only the jury can judge it. Splitting them here
7
+ # means cost scales with intent rather than artifact volume.
8
+ #
9
+ # NOTHING in this file may invoke a model. That is what makes "no tokens spent
10
+ # on unselected candidates" a property of the code rather than a promise.
11
+ #
12
+ # Requires librarian-lesson-storage.sh and librarian-lesson-validate.sh.
13
+
14
+ librarian_lesson_passed_path() {
15
+ local key="$1"
16
+ printf '%s/passed.jsonl' "$(librarian_lessons_dir "$key")"
17
+ }
18
+
19
+ # Print proposals in the given status as a JSON array, oldest first. Prints []
20
+ # when none.
21
+ #
22
+ # A malformed or racing file must shrink the result, never erase it: a
23
+ # truncated write, a bare-value file, or a confirm/pass rewriting the file
24
+ # mid-read all read as "that entry is missing" here, not as an error that
25
+ # discards every proposal already gathered. `select(type == "object" and
26
+ # ...)` folds the parse guard and the status check into a single read of
27
+ # each file, the same shape librarian_lesson_seen uses for the same reason.
28
+ #
29
+ # Usage: librarian_lesson_list_by_status <key> <status>
30
+ librarian_lesson_list_by_status() {
31
+ local key="$1"
32
+ local status="$2"
33
+ [[ -z "$key" || -z "$status" ]] && { printf '[]'; return 0; }
34
+
35
+ local dir
36
+ dir="$(librarian_lessons_dir "$key")/proposals"
37
+ [[ -d "$dir" ]] || { printf '[]'; return 0; }
38
+
39
+ local f out entry merged
40
+ out='[]'
41
+ for f in "$dir"/*.json; do
42
+ [[ -f "$f" ]] || continue
43
+ entry=$(jq -c --arg s "$status" 'select(type == "object" and .status == $s)' "$f" 2>/dev/null)
44
+ [[ -z "$entry" ]] && continue
45
+ # Merge into a separate variable, not directly into $out: `out=$(cmd)
46
+ # || continue` still assigns $out to cmd's (possibly empty) stdout
47
+ # before the `||` is ever evaluated, so a failed merge would silently
48
+ # wipe everything already gathered even with `continue` guarding it.
49
+ merged=$(jq -c --argjson e "$entry" '. + [$e]' <<<"$out" 2>/dev/null) || continue
50
+ out="$merged"
51
+ done
52
+ printf '%s' "$(jq -c 'sort_by(.created_at)' <<<"$out")"
53
+ }
54
+
55
+ # Pending is the queue the review walk drives, so it keeps its own name.
56
+ #
57
+ # Usage: librarian_lesson_list_pending <key>
58
+ librarian_lesson_list_pending() {
59
+ librarian_lesson_list_by_status "${1:-}" "pending"
60
+ }
61
+
62
+ _librarian_lesson_valid_visibility() {
63
+ case "${1:-}" in
64
+ private | org | public) return 0 ;;
65
+ *) return 1 ;;
66
+ esac
67
+ }
68
+
69
+ # Confirm a candidate for the jury.
70
+ #
71
+ # Usage: librarian_lesson_confirm <key> <lesson_id> <visibility> [justification]
72
+ #
73
+ # With a justification, the candidate's scope is rewritten to
74
+ # version_independent. That branch is refused at private visibility: private
75
+ # lessons run no jury, so the justification would reach the pool with nothing
76
+ # checking it — the same hole the transform closes by refusing the branch
77
+ # outright. Requiring org or public means scope_accuracy actually tests it.
78
+ librarian_lesson_confirm() {
79
+ local key="$1"
80
+ local lesson_id="$2"
81
+ local visibility="${3:-}"
82
+ local justification="${4:-}"
83
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
84
+
85
+ _librarian_lesson_valid_visibility "$visibility" || {
86
+ printf 'visibility must be one of: private, org, public\n' >&2
87
+ return 1
88
+ }
89
+
90
+ if [[ -n "$justification" && "$visibility" == "private" ]]; then
91
+ 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
92
+ return 1
93
+ fi
94
+
95
+ local path
96
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
97
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
98
+
99
+ local proposal candidate current_status
100
+ proposal=$(jq '.' "$path" 2>/dev/null) || return 1
101
+ candidate=$(printf '%s' "$proposal" | jq -c '.candidate' 2>/dev/null) || return 1
102
+ current_status=$(printf '%s' "$proposal" | jq -r '.status // ""' 2>/dev/null)
103
+
104
+ # Snapshot the candidate before the rewrite, so unconfirm can put it back.
105
+ # Only when we actually rewrite: a plain confirm never touches .candidate,
106
+ # so a snapshot there would be dead weight that unconfirm has to reason
107
+ # about. Absence of the field means "nothing was mutated".
108
+ local candidate_before=""
109
+ if [[ -n "$justification" ]]; then
110
+ candidate_before="$candidate"
111
+ candidate=$(printf '%s' "$candidate" | jq -c \
112
+ --arg j "$justification" \
113
+ '.applies_to.scope = {kind: "version_independent", justification: $j}' 2>/dev/null) || return 1
114
+ fi
115
+
116
+ # Authoritative guard: check the RESULTING state, not just this call's
117
+ # input. The check above only catches a justification supplied on THIS
118
+ # invocation; a candidate that already carries version_independent scope
119
+ # (proposed that way, or confirmed non-private earlier and now being
120
+ # reconfirmed at private) would sail past it and write with no jury ever
121
+ # validating the justification. This check is the one that actually holds
122
+ # the invariant — the input check above is only a fast path that avoids
123
+ # reading the file for the common case.
124
+ if [[ "$visibility" == "private" ]] && printf '%s' "$candidate" \
125
+ | jq -e '.applies_to.scope.kind == "version_independent"' >/dev/null 2>&1; then
126
+ 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
127
+ return 1
128
+ fi
129
+
130
+ # Guard the transition, not just the write. Each write below is already
131
+ # atomic (one merged jq expression, one write), but an unguarded SEQUENCE
132
+ # is where a passed candidate could be flipped back to confirmed while
133
+ # passed.jsonl still asserts the human declined it — the durable record
134
+ # of intent disagreeing with the candidate it describes.
135
+ case "$current_status" in
136
+ pending) ;;
137
+ confirmed)
138
+ # Idempotent only when the repeat asks for exactly what is
139
+ # already recorded; anything else is a contradictory flip.
140
+ if printf '%s' "$proposal" | jq -e \
141
+ --arg v "$visibility" --argjson c "$candidate" \
142
+ '.visibility == $v and .candidate == $c' >/dev/null 2>&1; then
143
+ return 0
144
+ fi
145
+ printf 'Lesson %s is already confirmed; visibility or scope differs from the request.\n' \
146
+ "$lesson_id" >&2
147
+ return 1
148
+ ;;
149
+ passed)
150
+ printf 'Lesson %s was passed on; it cannot be confirmed.\n' "$lesson_id" >&2
151
+ return 1
152
+ ;;
153
+ *)
154
+ printf 'Lesson %s has an unrecognized status: %s\n' "$lesson_id" "$current_status" >&2
155
+ return 1
156
+ ;;
157
+ esac
158
+
159
+ librarian_lesson_validate_confirmed "$candidate" 2>/dev/null || {
160
+ printf 'Candidate does not validate; not confirmed.\n' >&2
161
+ return 1
162
+ }
163
+
164
+ local now updated
165
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
166
+ # `.candidate` is assigned, not merged into with `*`: `*` is a recursive
167
+ # merge, and merging the fresh candidate over the stored one leaves stale
168
+ # keys behind — a version_independent rewrite would keep the old
169
+ # versioned scope's `versions` key alongside the new `justification`,
170
+ # producing a candidate that fails its own validator's
171
+ # additionalProperties check on the way out the door.
172
+ if [[ -n "$candidate_before" ]]; then
173
+ updated=$(printf '%s' "$proposal" | jq \
174
+ --arg v "$visibility" --arg t "$now" \
175
+ --argjson c "$candidate" --argjson cb "$candidate_before" \
176
+ '. * {status: "confirmed", visibility: $v, confirmed_at: $t}
177
+ | .candidate = $c
178
+ | .candidate_before_confirm = $cb' 2>/dev/null) || return 1
179
+ else
180
+ # No rewrite happened, so there is nothing to snapshot — and any field
181
+ # already on the proposal is stale by definition. Deleting it holds the
182
+ # invariant unconfirm relies on ("absence means the candidate was never
183
+ # mutated") from both ends: without this, a proposal that arrives
184
+ # carrying the field keeps it through a plain confirm, and the next
185
+ # unconfirm silently replaces .candidate with a snapshot describing a
186
+ # rewrite this confirm never performed.
187
+ updated=$(printf '%s' "$proposal" | jq \
188
+ --arg v "$visibility" --arg t "$now" --argjson c "$candidate" \
189
+ '. * {status: "confirmed", visibility: $v, confirmed_at: $t}
190
+ | .candidate = $c
191
+ | del(.candidate_before_confirm)' 2>/dev/null) || return 1
192
+ fi
193
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
194
+ librarian_lesson_write_atomic "$path" "$updated"
195
+ }
196
+
197
+ # Take back a confirmation, before the jury has seen the lesson.
198
+ #
199
+ # `confirmed` is otherwise terminal: confirm refuses a differing repeat and
200
+ # pass refuses a confirmed lesson. Those guards are correct — they are what
201
+ # keeps passed.jsonl from contradicting the proposal it describes — but they
202
+ # left no way back from confirming at the wrong visibility, and `public` is
203
+ # the tier that leaves this machine.
204
+ #
205
+ # Proceeds ONLY from `confirmed`. Every other status is refused, which is also
206
+ # what makes this forward-safe: when the jury stage introduces a status of its
207
+ # own, this verb refuses it through the catch-all with no change here.
208
+ #
209
+ # Usage: librarian_lesson_unconfirm <key> <lesson_id>
210
+ librarian_lesson_unconfirm() {
211
+ local key="$1"
212
+ local lesson_id="$2"
213
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
214
+
215
+ local path
216
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
217
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
218
+
219
+ local current_status
220
+ current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
221
+
222
+ case "$current_status" in
223
+ confirmed) ;;
224
+ pending) return 0 ;;
225
+ passed)
226
+ # Passing is a different decision with its own durable record.
227
+ # Silently moving it back to pending would leave passed.jsonl
228
+ # asserting a decision the proposal contradicts.
229
+ printf 'Lesson %s was passed on, not confirmed; unconfirm does not undo that.\n' "$lesson_id" >&2
230
+ return 1
231
+ ;;
232
+ *)
233
+ printf 'Lesson %s has an unrecognized status: %s\n' "$lesson_id" "$current_status" >&2
234
+ return 1
235
+ ;;
236
+ esac
237
+
238
+ # Restore the pre-confirm candidate when one was snapshotted, and delete
239
+ # the snapshot either way. A stale snapshot left on a pending proposal is
240
+ # indistinguishable from a live one at the next confirm, and would
241
+ # silently revert a later legitimate rewrite.
242
+ local updated
243
+ updated=$(jq '
244
+ (if has("candidate_before_confirm") then .candidate = .candidate_before_confirm else . end)
245
+ | del(.candidate_before_confirm, .visibility, .confirmed_at)
246
+ | .status = "pending"
247
+ ' "$path" 2>/dev/null) || return 1
248
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
249
+ librarian_lesson_write_atomic "$path" "$updated"
250
+ }
251
+
252
+ # Decline to share a candidate.
253
+ #
254
+ # The file is KEPT. librarian_lesson_seen scans proposals/ by artifact_id, so
255
+ # leaving it in place is what stops the artifact being re-proposed on the next
256
+ # scan and re-paying for a transform whose answer the human already gave.
257
+ #
258
+ # The ledger is separate from declined.jsonl on purpose: that file records
259
+ # machine verdicts and feeds rubric tuning, and folding human intent into it
260
+ # would corrupt the signal it exists to carry.
261
+ #
262
+ # Usage: librarian_lesson_pass <key> <lesson_id> [reason]
263
+ librarian_lesson_pass() {
264
+ local key="$1"
265
+ local lesson_id="$2"
266
+ local reason="${3:-}"
267
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
268
+
269
+ local path
270
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
271
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
272
+
273
+ local current_status
274
+ current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
275
+
276
+ # Same reasoning as confirm: guard the transition. Passing twice must not
277
+ # append a second ledger line, and a confirmed candidate must not be
278
+ # silently un-confirmed — that would leave stale visibility/confirmed_at
279
+ # behind and send a contradictory signal to the stages that select on
280
+ # status downstream.
281
+ case "$current_status" in
282
+ pending) ;;
283
+ passed) return 0 ;;
284
+ confirmed)
285
+ printf 'Lesson %s is confirmed for the jury; it cannot be passed on now.\n' "$lesson_id" >&2
286
+ return 1
287
+ ;;
288
+ *)
289
+ printf 'Lesson %s has an unrecognized status: %s\n' "$lesson_id" "$current_status" >&2
290
+ return 1
291
+ ;;
292
+ esac
293
+
294
+ local artifact_id now updated
295
+ artifact_id=$(jq -r '.artifact_id // ""' "$path" 2>/dev/null)
296
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
297
+
298
+ updated=$(jq --arg t "$now" '. * {status: "passed", passed_at: $t}' "$path" 2>/dev/null) || return 1
299
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
300
+ librarian_lesson_write_atomic "$path" "$updated" || return 1
301
+
302
+ local line
303
+ line=$(jq -cn \
304
+ --arg lesson_id "$lesson_id" \
305
+ --arg artifact_id "$artifact_id" \
306
+ --arg reason "$reason" \
307
+ --arg at "$now" \
308
+ '{lesson_id: $lesson_id, artifact_id: $artifact_id,
309
+ reason: (if $reason == "" then null else $reason end), passed_at: $at}') || return 1
310
+
311
+ printf '%s\n' "$line" >> "$(librarian_lesson_passed_path "$key")"
312
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # Rubric selection for lesson judging.
3
+ #
4
+ # Two builtins live in config.json under librarian.lesson_judging.rubrics,
5
+ # mirroring tribunal's rubric.builtins shape so they stay legible to anyone who
6
+ # knows tribunal. Librarian loads them itself rather than sourcing tribunal's
7
+ # lib — see docs/adr/002-agent-definitions-are-shared-assets.md.
8
+ #
9
+ # The per-criterion weights and min_pass floors are LIVE as of ecosystem-pht:
10
+ # librarian_lesson_aggregate weights them and librarian_lesson_gate blocks on
11
+ # any criterion below its floor.
12
+ #
13
+ # `disclosure` at min_pass 0.9 is what makes the public tier stricter than org.
14
+ # It replaces gate_policy `unanimous`, which was intended as a stand-in for
15
+ # exactly this and turned out to be a no-op: at the configured two-judge panel,
16
+ # `unanimous` and `majority` agree on every possible pass count. See
17
+ # ecosystem-j74. Changing judge_types without re-reading that bead is how the
18
+ # hole reopens.
19
+ #
20
+ # Exposes:
21
+ # librarian_lesson_rubric_id_for_visibility <visibility>
22
+ # librarian_lesson_rubric_get <rubric_id>
23
+
24
+ # Map a confirmed lesson's visibility to the rubric that judges it.
25
+ #
26
+ # `private` maps to the empty string on purpose: that tier runs no jury at all,
27
+ # which is what makes cost scale with intent rather than artifact volume.
28
+ #
29
+ # Usage: librarian_lesson_rubric_id_for_visibility <visibility>
30
+ librarian_lesson_rubric_id_for_visibility() {
31
+ case "${1:-}" in
32
+ private) printf '' ;;
33
+ org) printf 'lesson-promotion' ;;
34
+ public) printf 'lesson-promotion-public' ;;
35
+ *) return 1 ;;
36
+ esac
37
+ return 0
38
+ }
39
+
40
+ # Echo one rubric as compact JSON. Returns 1 and echoes nothing if unknown.
41
+ #
42
+ # Usage: librarian_lesson_rubric_get <rubric_id>
43
+ librarian_lesson_rubric_get() {
44
+ local rubric_id="${1:-}"
45
+ [[ -z "$rubric_id" ]] && return 1
46
+
47
+ local rubrics found
48
+ rubrics=$(librarian_config_get '.librarian.lesson_judging.rubrics')
49
+ [[ -z "$rubrics" || "$rubrics" == "null" ]] && return 1
50
+
51
+ found=$(printf '%s' "$rubrics" | jq -c --arg id "$rubric_id" \
52
+ 'map(select(.id == $id)) | first // empty' 2>/dev/null) || return 1
53
+ [[ -z "$found" || "$found" == "null" ]] && return 1
54
+
55
+ printf '%s' "$found"
56
+ }
@@ -25,6 +25,23 @@ librarian_lesson_storage_init() {
25
25
  mkdir -p "$dir/proposals" "$dir/approved" 2>/dev/null
26
26
  }
27
27
 
28
+ # Write a file atomically: temp in the same directory, then mv.
29
+ #
30
+ # `printf > "$path"` truncates before writing, so an interrupted write leaves
31
+ # a zero-byte file. For a proposal that is permanently stuck: every verb
32
+ # refuses it with "unrecognized status: " and list_pending hides it, so even
33
+ # unconfirm — the recovery verb — cannot bring it back.
34
+ #
35
+ # Usage: librarian_lesson_write_atomic <path> <content>
36
+ librarian_lesson_write_atomic() {
37
+ local path="$1"
38
+ local content="$2"
39
+ local tmp
40
+ tmp=$(mktemp "${path}.XXXXXX" 2>/dev/null) || return 1
41
+ printf '%s\n' "$content" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
42
+ mv "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
43
+ }
44
+
28
45
  # Write one candidate. Prints the ULID on success.
29
46
  # Usage: librarian_lesson_write_proposal <key> <candidate_json> <artifact_id>
30
47
  librarian_lesson_write_proposal() {
@@ -61,29 +78,51 @@ librarian_lesson_write_proposal() {
61
78
  # bury a good artifact permanently, because the watermark has already moved
62
79
  # past it and declined entries are never re-read.
63
80
  #
64
- # Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail]
81
+ # `verdict` is emitted with --argjson so it lands as a nested object, not a
82
+ # serialized string: consumers read .verdict.judges[].score directly. Rows
83
+ # written by the transform have no verdict and simply lack the key — a format
84
+ # failure has no jury.
85
+ #
86
+ # Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail] [verdict_json]
65
87
  librarian_lesson_append_declined() {
66
88
  local key="$1"
67
89
  local artifact_id="$2"
68
90
  local reason="$3"
69
91
  local detail="${4:-}"
92
+ local verdict="${5:-}"
70
93
  [[ -z "$key" || -z "$artifact_id" || -z "$reason" ]] && return 1
71
94
 
72
95
  librarian_lesson_storage_init "$key" || return 1
73
96
 
74
97
  local now line
75
98
  now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
76
- line=$(jq -cn \
77
- --arg artifact_id "$artifact_id" \
78
- --arg reason "$reason" \
79
- --arg detail "$detail" \
80
- --arg at "$now" \
81
- '{
82
- artifact_id: $artifact_id,
83
- reason: $reason,
84
- detail: (if $detail == "" then null else $detail end),
85
- declined_at: $at
86
- }') || return 1
99
+ if [[ -n "$verdict" ]]; then
100
+ line=$(jq -cn \
101
+ --arg artifact_id "$artifact_id" \
102
+ --arg reason "$reason" \
103
+ --arg detail "$detail" \
104
+ --arg at "$now" \
105
+ --argjson verdict "$verdict" \
106
+ '{
107
+ artifact_id: $artifact_id,
108
+ reason: $reason,
109
+ detail: (if $detail == "" then null else $detail end),
110
+ declined_at: $at,
111
+ verdict: $verdict
112
+ }') || return 1
113
+ else
114
+ line=$(jq -cn \
115
+ --arg artifact_id "$artifact_id" \
116
+ --arg reason "$reason" \
117
+ --arg detail "$detail" \
118
+ --arg at "$now" \
119
+ '{
120
+ artifact_id: $artifact_id,
121
+ reason: $reason,
122
+ detail: (if $detail == "" then null else $detail end),
123
+ declined_at: $at
124
+ }') || return 1
125
+ fi
87
126
 
88
127
  printf '%s\n' "$line" >> "$(librarian_lessons_dir "$key")/declined.jsonl"
89
128
  }
@@ -123,8 +162,38 @@ librarian_lesson_seen() {
123
162
  return 0
124
163
  fi
125
164
 
165
+ # ===================================================================
166
+ # proposals/ IS THE SOLE DEDUP SOURCE FOR AN APPROVED LESSON.
167
+ # PRUNING proposals/ SILENTLY BREAKS DEDUP. See ecosystem-d0m.
168
+ # ===================================================================
169
+ #
170
+ # A declined artifact is covered by declined.jsonl above — a terminal
171
+ # record keyed by artifact_id that outlives its proposal. An APPROVED one
172
+ # has no equivalent: the pool entry is ZLesson, a strictObject whose key
173
+ # set has no artifact_id and cannot gain one (an extra key fails ingest —
174
+ # see librarian-lesson-promote.sh's key-set comment). So nothing promote
175
+ # writes can answer "was this artifact already handled?"
176
+ #
177
+ # This loop used to scan approved/*.json too. That branch could never
178
+ # match, and the test covering it fabricated an {artifact_id: ...} file
179
+ # promote cannot produce — dead code with a green test in front of it,
180
+ # which is why it survived. Dropped rather than left as a decorative
181
+ # safety net that reads like coverage.
182
+ #
183
+ # The consequence, accepted deliberately: prune a promoted lesson's
184
+ # proposal and its artifact reads as unseen, so it is re-transformed
185
+ # (Haiku), re-confirmed BY THE HUMAN AGAIN, and re-judged (Opus),
186
+ # producing a duplicate pool entry for a lesson already promoted.
187
+ #
188
+ # The alternative was an artifact_id -> lesson id index (or a per-entry
189
+ # sidecar) to make the pool self-identifying. Neither buys anything until
190
+ # someone actually wants to prune, and both add a surface that can drift
191
+ # from the directory — so the requirement is stated instead of engineered
192
+ # around. Build the index FIRST if you ever need to prune; the tripwire
193
+ # test is "an approved lesson is NOT seen once its proposal is gone" in
194
+ # test/bats/librarian-lesson-promote.bats.
126
195
  local f
127
- for f in "$dir"/proposals/*.json "$dir"/approved/*.json; do
196
+ for f in "$dir"/proposals/*.json; do
128
197
  [[ -f "$f" ]] || continue
129
198
  if jq -e --arg a "$artifact_id" '.artifact_id == $a' "$f" >/dev/null 2>&1; then
130
199
  return 0
@@ -12,7 +12,12 @@
12
12
  #
13
13
  # Config inputs (read via librarian_config_get from librarian_lesson_call):
14
14
  # librarian.lesson_transform.model Anthropic model id
15
- # librarian.lesson_transform.timeout_seconds Hard wall-clock ceiling
15
+ # librarian.lesson_transform.timeout_seconds Per-call wall-clock ceiling
16
+ #
17
+ # timeout_seconds bounds one call, not a scan. The aggregate ceiling across a
18
+ # whole SessionEnd is librarian.lesson_transform.total_budget_ms, enforced by
19
+ # the caller in librarian-session-end.sh — a per-call timeout alone leaves
20
+ # KEPT_COUNT of them unbounded end to end (ecosystem-qwi).
16
21
 
17
22
  # Fallback when config hasn't been loaded or leaves the key unset.
18
23
  _LIBRARIAN_LESSON_DEFAULT_TIMEOUT_SECONDS=20