@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,349 @@
1
+ #!/usr/bin/env bash
2
+ # Aggregate, gate, and record a jury verdict on a confirmed lesson.
3
+ #
4
+ # The agent orchestrates the jury; this file decides. Everything except
5
+ # subagent dispatch lives here so it can be tested.
6
+ #
7
+ # Librarian implements its own aggregate and gate rather than sourcing
8
+ # tribunal's. Reusing tribunal's published AGENT definitions is licensed by
9
+ # docs/adr/002-agent-definitions-are-shared-assets.md; sourcing its bash would
10
+ # be the hook-to-hook runtime coupling that ADR rules out.
11
+ #
12
+ # Exposes:
13
+ # librarian_lesson_aggregate <verdicts_json> [<rubric_json>]
14
+ # librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <threshold> [<rubric_json>]
15
+ # librarian_lesson_judge <key> <lesson_id> <verdicts_json>
16
+
17
+ # Aggregate the judges' scores. Returns 1 on an empty panel.
18
+ #
19
+ # With a rubric and per-criterion scores, this is a weighted mean: average the
20
+ # judges on each criterion, weight each criterion's mean, normalize by the
21
+ # weights actually used. Without them it is the plain mean it has always been.
22
+ #
23
+ # A criterion no judge scored contributes nothing and its weight leaves the
24
+ # denominator — absence is not a zero. Scoring it 0 instead would turn a judge
25
+ # that skipped a criterion into one that failed it.
26
+ #
27
+ # Usage: librarian_lesson_aggregate <verdicts_json> [<rubric_json>]
28
+ librarian_lesson_aggregate() {
29
+ local verdicts="${1:-[]}"
30
+ local rubric="${2:-}"
31
+ [ -z "$rubric" ] && rubric='{}'
32
+
33
+ local n
34
+ n=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || return 1
35
+ [[ -z "$n" || "$n" -eq 0 ]] && return 1
36
+
37
+ local weighted
38
+ weighted=$(printf '%s' "$verdicts" | jq -r --argjson rubric "$rubric" '
39
+ . as $v
40
+ | [ ($rubric.criteria // [])[]
41
+ | select((.name | type) == "string" and (.weight | type) == "number")
42
+ | . as $c
43
+ | ([ $v[]
44
+ | select((.criterion_scores | type) == "object")
45
+ | select(.criterion_scores | has($c.name))
46
+ | .criterion_scores[$c.name]
47
+ | select(type == "number") ]) as $scores
48
+ | select(($scores | length) > 0)
49
+ | { w: $c.weight, m: (($scores | add) / ($scores | length)) } ]
50
+ | (map(.w) | add) as $den
51
+ | if length == 0 or $den == null or $den <= 0 then empty
52
+ else (map(.w * .m) | add) / $den
53
+ end
54
+ ' 2>/dev/null)
55
+ if [ -n "$weighted" ]; then
56
+ printf '%s' "$weighted"
57
+ return 0
58
+ fi
59
+
60
+ printf '%s' "$verdicts" | jq -r '[.[].score] | add / length' 2>/dev/null || return 1
61
+ }
62
+
63
+ # Decide pass/block from the panel, the aggregate, and the rubric's floors.
64
+ #
65
+ # Echoes {"passed": bool, "reason": string} and, when a floor was violated, a
66
+ # "failed_criterion" naming it — on whichever blocking reason wins, not only on
67
+ # `criterion_floor`. Three conditions must hold: the jury clears its policy, the
68
+ # aggregate clears the threshold, and no criterion sits below its min_pass.
69
+ #
70
+ # The floor is what makes the public tier meaningfully stricter than org. Before
71
+ # it existed, lesson-promotion-public declared gate_policy `unanimous` for that
72
+ # purpose and it did nothing at all — see ecosystem-j74.
73
+ #
74
+ # Usage: librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <threshold> [<rubric_json>]
75
+ librarian_lesson_gate() {
76
+ local policy="${1:-majority}"
77
+ local verdicts="${2:-[]}"
78
+ local aggregate="${3:-0}"
79
+ local threshold="${4:-0.75}"
80
+ local rubric="${5:-}"
81
+ [ -z "$rubric" ] && rubric='{}'
82
+
83
+ local count passed_count
84
+ count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
85
+ passed_count=$(printf '%s' "$verdicts" | jq '[.[] | select(.passed == true)] | length' 2>/dev/null) || passed_count=0
86
+
87
+ # Per-criterion floors, computed up front. The precedence below is unchanged
88
+ # — jury, then aggregate, then floor — but a violated floor now names itself
89
+ # on whichever reason wins. `disclosure` carries weight 0.30, so the *worst*
90
+ # leaks drag the aggregate under the threshold and used to land as a bare
91
+ # `below_threshold`, while borderline ones got named: the diagnostic was
92
+ # absent exactly where it mattered most.
93
+ #
94
+ # The floor is the *lowest* score among the judges that scored it, not their
95
+ # mean. A floor means nobody may be below it; using the mean let one judge's
96
+ # finding be diluted by others who did not look, and made every floor weaker
97
+ # as the panel grew. Weighting still uses the mean — only the floor is a
98
+ # minimum.
99
+ #
100
+ # A criterion no judge scored is not a violation here: absence is not a zero.
101
+ # librarian_lesson_judge refuses that panel outright before it reaches this
102
+ # function, which is the stronger answer than silently skipping the floor.
103
+ local floor_failed
104
+ floor_failed=$(printf '%s' "$verdicts" | jq -r --argjson rubric "$rubric" '
105
+ . as $v
106
+ | [ ($rubric.criteria // [])[]
107
+ | select((.name | type) == "string" and (.min_pass | type) == "number")
108
+ | . as $c
109
+ | ([ $v[]
110
+ | select((.criterion_scores | type) == "object")
111
+ | select(.criterion_scores | has($c.name))
112
+ | .criterion_scores[$c.name]
113
+ | select(type == "number") ]) as $scores
114
+ | select(($scores | length) > 0)
115
+ | select(($scores | min) < $c.min_pass)
116
+ | $c.name ]
117
+ | first // empty
118
+ ' 2>/dev/null) || floor_failed=""
119
+
120
+ local floor_suffix=""
121
+ [[ -n "$floor_failed" ]] && floor_suffix=$(printf ',"failed_criterion":"%s"' "$floor_failed")
122
+
123
+ local jury_ok=1 jury_reason=""
124
+ case "$policy" in
125
+ unanimous)
126
+ if [[ "$count" -gt 0 && "$passed_count" -eq "$count" ]]; then
127
+ jury_ok=0
128
+ else
129
+ jury_reason="jury_not_unanimous"
130
+ fi
131
+ ;;
132
+ majority)
133
+ if [[ "$count" -gt 0 ]] && (( passed_count * 2 > count )); then
134
+ jury_ok=0
135
+ else
136
+ jury_reason="jury_not_majority"
137
+ fi
138
+ ;;
139
+ *)
140
+ printf '{"passed":false,"reason":"unknown_gate_policy"%s}' "$floor_suffix"
141
+ return 0
142
+ ;;
143
+ esac
144
+
145
+ if [[ "$jury_ok" -ne 0 ]]; then
146
+ printf '{"passed":false,"reason":"%s"%s}' "$jury_reason" "$floor_suffix"
147
+ return 0
148
+ fi
149
+
150
+ # awk for the float comparison: bash cannot compare decimals.
151
+ if ! awk -v s="$aggregate" -v t="$threshold" 'BEGIN { exit !(s >= t) }'; then
152
+ printf '{"passed":false,"reason":"below_threshold"%s}' "$floor_suffix"
153
+ return 0
154
+ fi
155
+
156
+ if [[ -n "$floor_failed" ]]; then
157
+ printf '{"passed":false,"reason":"criterion_floor","failed_criterion":"%s"}' "$floor_failed"
158
+ return 0
159
+ fi
160
+
161
+ printf '{"passed":true,"reason":"gate_passed"}'
162
+ return 0
163
+ }
164
+
165
+ # Names the floored criteria this panel failed to score, plus a coverage verdict.
166
+ #
167
+ # Echoes a JSON object: { unscored: [names], covered: <fraction 0..1> }
168
+ #
169
+ # Librarian refuses rather than degrades. Tribunal can afford to fall back to a
170
+ # plain mean because a blocked task retries; a lesson that publishes without its
171
+ # `disclosure` floor ever running is not recoverable. "The panel did not evaluate
172
+ # the thing that gates this tier" is not a verdict — it is UNJUDGED.
173
+ #
174
+ # Usage: librarian_lesson_coverage <verdicts_json> <rubric_json>
175
+ librarian_lesson_coverage() {
176
+ local verdicts="${1:-[]}"
177
+ # Brace-free default, matching the two functions above: `${2:-{\}}` keeps the
178
+ # backslash on the bash 3.2 macOS ships, yielding `{\}` — invalid JSON, so
179
+ # jq fails and every panel reads as zero coverage. See
180
+ # test/bats/emit-payload-default.bats.
181
+ local rubric="${2:-}"
182
+ [ -z "$rubric" ] && rubric='{}'
183
+ printf '%s' "$verdicts" | jq -c --argjson rubric "$rubric" '
184
+ . as $v
185
+ | ([ ($rubric.criteria // [])[]
186
+ | select((.weight | type) == "number") | .weight ] | add) as $total_w
187
+ | [ ($rubric.criteria // [])[]
188
+ | select((.name | type) == "string")
189
+ | . as $c
190
+ | { name: $c.name,
191
+ w: (if (.weight | type) == "number" then .weight else 0 end),
192
+ floored: ((.min_pass | type) == "number"),
193
+ n: ([ $v[]
194
+ | select((.criterion_scores | type) == "object")
195
+ | select(.criterion_scores | has($c.name))
196
+ | .criterion_scores[$c.name]
197
+ | select(type == "number" and . >= 0 and . <= 1) ] | length) } ]
198
+ | { unscored: [ .[] | select(.floored and .n == 0) | .name ],
199
+ covered: (if $total_w == null or $total_w <= 0 then 0
200
+ else (([ .[] | select(.n > 0) | .w ] | add) // 0) / $total_w end) }
201
+ ' 2>/dev/null || printf '{"unscored":[],"covered":0}'
202
+ }
203
+
204
+ # Judge one confirmed lesson and record the outcome.
205
+ #
206
+ # Return codes are the interface the CLI and skill depend on:
207
+ # 0 a verdict was recorded (status is now approved or rejected)
208
+ # 1 usage or state error; nothing written
209
+ # 2 UNJUDGED — the panel was unusable; nothing written, lesson stays
210
+ # confirmed, and the next run retries it
211
+ #
212
+ # 2 is not a failure. "Judged and failed" must stay distinct from "could not
213
+ # judge": the watermark has already advanced past this artifact, so treating a
214
+ # broken judge as a rejection would bury a good lesson permanently.
215
+ #
216
+ # Usage: librarian_lesson_judge <key> <lesson_id> <verdicts_json>
217
+ librarian_lesson_judge() {
218
+ local key="$1"
219
+ local lesson_id="$2"
220
+ local verdicts="${3:-[]}"
221
+ if [[ -z "$key" || -z "$lesson_id" ]]; then
222
+ printf 'usage: librarian_lesson_judge <key> <lesson_id> <verdicts_json>\n' >&2
223
+ return 1
224
+ fi
225
+
226
+ local path
227
+ path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
228
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
229
+
230
+ local current_status visibility
231
+ current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
232
+ visibility=$(jq -r '.visibility // ""' "$path" 2>/dev/null)
233
+
234
+ if [[ "$current_status" != "confirmed" ]]; then
235
+ printf 'Lesson %s is not confirmed; its status is: %s\n' "$lesson_id" "$current_status" >&2
236
+ return 1
237
+ fi
238
+
239
+ local rubric_id
240
+ rubric_id=$(librarian_lesson_rubric_id_for_visibility "$visibility") || {
241
+ printf 'Lesson %s has an unrecognized visibility: %s\n' "$lesson_id" "$visibility" >&2
242
+ return 1
243
+ }
244
+
245
+ local verdict now
246
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
247
+
248
+ if [[ -z "$rubric_id" ]]; then
249
+ # private: no jury, no model call, straight through.
250
+ verdict=$(jq -cn --arg t "$now" \
251
+ '{rubric_id: null, gate_policy: null, score_threshold: null,
252
+ aggregate_score: null, passed: true, reason: "private_no_jury",
253
+ judges: []}') || {
254
+ printf 'Lesson %s: the verdict could not be built.\n' "$lesson_id" >&2
255
+ return 1
256
+ }
257
+ else
258
+ local rubric threshold policy expected_judge_types
259
+ rubric=$(librarian_lesson_rubric_get "$rubric_id") || {
260
+ printf 'Lesson %s: rubric %s is not defined in config.\n' "$lesson_id" "$rubric_id" >&2
261
+ return 1
262
+ }
263
+ threshold=$(printf '%s' "$rubric" | jq -r '.score_threshold')
264
+ policy=$(printf '%s' "$rubric" | jq -r '.gate_policy')
265
+ expected_judge_types=$(printf '%s' "$rubric" | jq -c '.judge_types | sort')
266
+
267
+ # Every judge must have returned a usable verdict AND the panel's
268
+ # multiset of judge_type values must exactly match the rubric's
269
+ # judge_types, or this candidate is unjudged. Checking element types
270
+ # alone is not enough: a one-element panel is trivially unanimous, so
271
+ # a lone approving judge would promote a public lesson on its own,
272
+ # and two judges of the same type could silently stand in for a
273
+ # missing one. With a two-judge panel under either policy, losing (or
274
+ # duplicating) one verdict means the gate cannot be honestly decided.
275
+ local usable
276
+ usable=$(printf '%s' "$verdicts" | jq --argjson want "$expected_judge_types" '
277
+ if type != "array" or length == 0 then false
278
+ else (all(.[]; (.judge_type | type) == "string"
279
+ and (.score | type) == "number"
280
+ and (.passed | type) == "boolean")
281
+ and ([.[].judge_type] | sort) == $want)
282
+ end' 2>/dev/null) || usable="false"
283
+ [[ "$usable" != "true" ]] && return 2
284
+
285
+ # A floored criterion nobody scored, or a panel that covered too little
286
+ # of the rubric, is UNJUDGED — not a pass. The candidate stays
287
+ # `confirmed` and the next run retries it.
288
+ local coverage unscored covered min_coverage
289
+ min_coverage=$(librarian_config_get '.librarian.lesson_judging.min_criterion_coverage' 2>/dev/null)
290
+ case "$min_coverage" in
291
+ ''|null) min_coverage="0.6" ;;
292
+ esac
293
+ coverage=$(librarian_lesson_coverage "$verdicts" "$rubric")
294
+ unscored=$(printf '%s' "$coverage" | jq -r '.unscored | join(", ")')
295
+ covered=$(printf '%s' "$coverage" | jq -r '.covered')
296
+ if [[ -n "$unscored" ]]; then
297
+ printf 'Lesson %s: no judge scored these floored criteria, so the panel cannot be trusted: %s\n' \
298
+ "$lesson_id" "$unscored" >&2
299
+ return 2
300
+ fi
301
+ if ! awk -v c="$covered" -v m="$min_coverage" 'BEGIN { exit !(c >= m) }'; then
302
+ printf 'Lesson %s: the panel scored only %s of the rubric weight (minimum %s).\n' \
303
+ "$lesson_id" "$covered" "$min_coverage" >&2
304
+ return 2
305
+ fi
306
+
307
+ local aggregate gate
308
+ aggregate=$(librarian_lesson_aggregate "$verdicts" "$rubric") || return 2
309
+ gate=$(librarian_lesson_gate "$policy" "$verdicts" "$aggregate" "$threshold" "$rubric") || {
310
+ printf 'Lesson %s: the gate could not be decided.\n' "$lesson_id" >&2
311
+ return 1
312
+ }
313
+
314
+ verdict=$(jq -cn \
315
+ --arg r "$rubric_id" --arg p "$policy" \
316
+ --argjson th "$threshold" --argjson ag "$aggregate" \
317
+ --argjson g "$gate" --argjson j "$verdicts" \
318
+ '{rubric_id: $r, gate_policy: $p, score_threshold: $th,
319
+ aggregate_score: $ag, passed: $g.passed, reason: $g.reason,
320
+ judges: $j}
321
+ + (if $g.failed_criterion then {failed_criterion: $g.failed_criterion} else {} end)') || {
322
+ printf 'Lesson %s: the verdict could not be built.\n' "$lesson_id" >&2
323
+ return 1
324
+ }
325
+ fi
326
+
327
+ local new_status updated
328
+ if [[ "$(printf '%s' "$verdict" | jq -r '.passed')" == "true" ]]; then
329
+ new_status="approved"
330
+ else
331
+ new_status="rejected"
332
+ fi
333
+
334
+ updated=$(jq --arg s "$new_status" --arg t "$now" --argjson v "$verdict" \
335
+ '.status = $s | .judged_at = $t | .verdict = $v' "$path" 2>/dev/null) || {
336
+ printf 'Lesson %s: the verdict could not be recorded.\n' "$lesson_id" >&2
337
+ return 1
338
+ }
339
+ if [[ -z "$updated" || "$updated" == "null" ]]; then
340
+ printf 'Lesson %s: the verdict could not be recorded; the update produced no result.\n' "$lesson_id" >&2
341
+ return 1
342
+ fi
343
+ # Atomic, not `printf > "$path"`: a plain redirect truncates before
344
+ # writing, so an interrupted write here leaves the same zero-byte,
345
+ # permanently-stuck proposal that a3b fixed in confirm, unconfirm, and
346
+ # pass — "unrecognized status: " from every verb, and even unconfirm
347
+ # cannot recover it because list_pending hides it too.
348
+ librarian_lesson_write_atomic "$path" "$updated"
349
+ }
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env bash
2
+ # Terminal state for the lesson-promotion pipeline.
3
+ #
4
+ # A judged proposal becomes either a ZLesson-shaped pool entry awaiting sync,
5
+ # or a row in the declined ledger. Nothing crosses the network — the sync
6
+ # service that drains the pool does not exist yet.
7
+ #
8
+ # Requires librarian-lesson-storage.sh and librarian-author-key.sh.
9
+ #
10
+ # Exposes:
11
+ # librarian_lesson_promote <key> <lesson_id>
12
+
13
+ # Map a lesson's visibility to the contract's `source` enum.
14
+ #
15
+ # NOT a rename: ZSource is local|org|public while visibility is
16
+ # private|org|public. `private` maps to `local` — the tier that never leaves
17
+ # this machine maps to the source meaning "not from anywhere else". Emitting
18
+ # "private" would fail ingest.
19
+ _librarian_lesson_source_for_visibility() {
20
+ case "${1:-}" in
21
+ private) printf 'local' ;;
22
+ org) printf 'org' ;;
23
+ public) printf 'public' ;;
24
+ *) return 1 ;;
25
+ esac
26
+ return 0
27
+ }
28
+
29
+ # Promote one judged proposal to its terminal record.
30
+ #
31
+ # Returns 0 on success, including the already-promoted no-op. Returns 1 on
32
+ # refusal or failure. Every failure before the terminal record lands writes
33
+ # NOTHING. The one exception is a stamp failure AFTER the terminal record
34
+ # already landed — the proposal stays `approved` without `promoted_at`, and
35
+ # the stderr message says explicitly that the record was written so this
36
+ # state is never mistaken for "nothing written." Either way, standalone
37
+ # `lessons promote` is what reconciles it.
38
+ #
39
+ # Ordering is load-bearing: the terminal record lands BEFORE the stamp. A
40
+ # stamp followed by a failed write would leave the lesson marked done, present
41
+ # nowhere, and invisible to a reconcile that keys on the stamp's absence.
42
+ #
43
+ # Usage: librarian_lesson_promote <key> <lesson_id>
44
+ librarian_lesson_promote() {
45
+ local key="$1"
46
+ local lesson_id="$2"
47
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
48
+
49
+ local dir path
50
+ dir="$(librarian_lessons_dir "$key")"
51
+ path="${dir}/proposals/${lesson_id}.json"
52
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
53
+
54
+ # Already promoted: a no-op success, so a reconcile loop is safe to run
55
+ # over everything. Same precedent as unconfirm on a pending lesson.
56
+ if [[ "$(jq -r 'has("promoted_at")' "$path" 2>/dev/null)" == "true" ]]; then
57
+ return 0
58
+ fi
59
+
60
+ local current_status visibility
61
+ current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
62
+ visibility=$(jq -r '.visibility // ""' "$path" 2>/dev/null)
63
+
64
+ case "$current_status" in
65
+ approved|rejected) ;;
66
+ confirmed)
67
+ printf 'Lesson %s has not been judged yet; nothing to promote.\n' "$lesson_id" >&2
68
+ return 1
69
+ ;;
70
+ *)
71
+ printf 'Lesson %s cannot be promoted from status: %s\n' "$lesson_id" "$current_status" >&2
72
+ return 1
73
+ ;;
74
+ esac
75
+
76
+ local now
77
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
78
+
79
+ if [[ "$current_status" == "approved" ]]; then
80
+ local source author_key entry pool_path
81
+ source=$(_librarian_lesson_source_for_visibility "$visibility") || {
82
+ printf 'Lesson %s has an unrecognized visibility: %s\n' "$lesson_id" "$visibility" >&2
83
+ return 1
84
+ }
85
+ author_key=$(librarian_author_key "$visibility") || {
86
+ printf 'Lesson %s: cannot derive an author key; nothing written.\n' "$lesson_id" >&2
87
+ return 1
88
+ }
89
+
90
+ # Validate the fields the entry mapping reads. Without this, a corrupt
91
+ # proposal produces a well-formed-but-empty pool entry that passes the
92
+ # key-set check and fails at ingest — and a null judges array yields a
93
+ # consensus byte-identical to a legitimate private entry while carrying a
94
+ # syncing source.
95
+ local missing
96
+ missing=$(jq -r '
97
+ [ (if (.id | type) != "string" then "id" else empty end),
98
+ (if (.candidate.claim | type) != "string" then "candidate.claim" else empty end),
99
+ (if (.candidate.rationale | type) != "string" then "candidate.rationale" else empty end),
100
+ (if (.candidate.evidence | type) != "object" then "candidate.evidence" else empty end),
101
+ (if (.candidate.applies_to | type) != "object" then "candidate.applies_to" else empty end),
102
+ (if (.judged_at | type) != "string" then "judged_at" else empty end),
103
+ (if (.verdict.judges | type) != "array" then "verdict.judges" else empty end)
104
+ ] | join(", ")' "$path" 2>/dev/null) || missing="unreadable"
105
+ if [[ -n "$missing" ]]; then
106
+ printf 'Lesson %s is malformed; cannot promote (bad or missing: %s).\n' \
107
+ "$lesson_id" "$missing" >&2
108
+ return 1
109
+ fi
110
+
111
+ # Exactly ZLesson's key set — it is a strictObject, so an extra key
112
+ # fails ingest as surely as a missing one. A private entry gets
113
+ # judges: 0 and is deliberately not ingest-valid; it never syncs.
114
+ #
115
+ # Note what is NOT here and cannot be: `artifact_id`. Adding it would
116
+ # fail ingest, so a pool entry can never identify the artifact it came
117
+ # from. That is why librarian_lesson_seen scans ONLY proposals/, and
118
+ # why THE PROPOSAL FILE BELOW MUST NEVER BE PRUNED — it is the sole
119
+ # thing standing between an approved lesson and being re-transformed,
120
+ # re-confirmed by the human, and re-judged on the next scan. The
121
+ # proposal is kept after promotion for exactly this reason, not merely
122
+ # because it holds per-judge detail the pool entry drops.
123
+ # See ecosystem-d0m and the seen-side comment for the full decision.
124
+ entry=$(jq -cn \
125
+ --argjson p "$(cat "$path")" \
126
+ --arg ak "$author_key" \
127
+ --arg src "$source" \
128
+ --arg now "$now" \
129
+ '{
130
+ id: $p.id,
131
+ schema_version: 2,
132
+ claim: $p.candidate.claim,
133
+ rationale: $p.candidate.rationale,
134
+ evidence: $p.candidate.evidence,
135
+ applies_to: $p.candidate.applies_to,
136
+ visibility: $p.visibility,
137
+ consensus: {
138
+ judges: (($p.verdict.judges // []) | length),
139
+ agreed: ([($p.verdict.judges // [])[] | select(.passed == true)] | length),
140
+ decided_at: $p.judged_at
141
+ },
142
+ status: "active",
143
+ superseded_by: null,
144
+ source: $src,
145
+ author_key: $ak,
146
+ promoted_at: $now
147
+ }' 2>/dev/null) || {
148
+ printf 'Lesson %s: cannot build a pool entry.\n' "$lesson_id" >&2
149
+ return 1
150
+ }
151
+
152
+ # Self-heal, same as librarian_lesson_append_declined does
153
+ # internally for declined.jsonl. Empty directories don't survive
154
+ # git, or tar/rsync without -d, so approved/ can be legitimately
155
+ # absent even though proposals/ — non-empty, holding this very
156
+ # lesson — is right there. Placed HERE, immediately before the
157
+ # write, not at the top of the function: every refusal above (not
158
+ # found, already promoted, wrong status, bad visibility, a failing
159
+ # author_key, a failing entry build) must create nothing, matching
160
+ # this function's own doc comment and the spec's "every failure
161
+ # path before the terminal record lands writes NOTHING."
162
+ librarian_lesson_storage_init "$key" || return 1
163
+
164
+ pool_path="${dir}/approved/${lesson_id}.json"
165
+ if [[ ! -f "$pool_path" ]]; then
166
+ librarian_lesson_write_atomic "$pool_path" "$entry" || {
167
+ printf 'Lesson %s: cannot write the pool entry.\n' "$lesson_id" >&2
168
+ return 1
169
+ }
170
+ fi
171
+ else
172
+ local artifact_id reason verdict declined_path already_declined
173
+ artifact_id=$(jq -r '.artifact_id // ""' "$path" 2>/dev/null)
174
+ reason=$(jq -r '.verdict.reason // "rejected"' "$path" 2>/dev/null)
175
+ verdict=$(jq -c '.verdict // {}' "$path" 2>/dev/null)
176
+ declined_path="${dir}/declined.jsonl"
177
+
178
+ # Mirrors the approved branch's pool_path guard above: without it, a
179
+ # stamp that fails AFTER a successful declined-row write (proposals/
180
+ # turns read-only mid-promote, say) leaves an unstamped proposal whose
181
+ # standalone re-run — the exact path the ordering guarantee above
182
+ # exists to make safe — double-writes the same artifact into
183
+ # declined.jsonl, double-counting a rejected artifact in the
184
+ # rubric-tuning signal the ledger exists to carry.
185
+ #
186
+ # Same scan shape as librarian_lesson_seen: -R/fromjson? survives a
187
+ # truncated trailing line from a process killed mid-append, and
188
+ # `objects` stops a valid-but-non-object line from erroring the whole
189
+ # invocation.
190
+ already_declined=false
191
+ if [[ -f "$declined_path" ]] \
192
+ && jq -Re --arg a "$artifact_id" 'fromjson? | objects | select(.artifact_id == $a)' \
193
+ "$declined_path" >/dev/null 2>&1; then
194
+ already_declined=true
195
+ fi
196
+
197
+ if [[ "$already_declined" == false ]]; then
198
+ librarian_lesson_append_declined "$key" "$artifact_id" "$reason" "" "$verdict" || {
199
+ printf 'Lesson %s: cannot append to the declined ledger.\n' "$lesson_id" >&2
200
+ return 1
201
+ }
202
+ fi
203
+ fi
204
+
205
+ # Stamp LAST. See the ordering note above. The terminal record is already
206
+ # on disk by this point, so every failure below must say so on stderr —
207
+ # a bare `return 1` here is indistinguishable from a refusal that wrote
208
+ # nothing, when in fact `lessons promote <id>` is exactly what recovers
209
+ # this specific state.
210
+ local updated stamp_fail_msg
211
+ stamp_fail_msg=$(printf "Lesson %s: terminal record written but promoted_at could not be stamped; re-run 'lessons promote %s'." \
212
+ "$lesson_id" "$lesson_id")
213
+ updated=$(jq --arg t "$now" '.promoted_at = $t' "$path" 2>/dev/null) || {
214
+ printf '%s\n' "$stamp_fail_msg" >&2
215
+ return 1
216
+ }
217
+ if [[ -z "$updated" || "$updated" == "null" ]]; then
218
+ printf '%s\n' "$stamp_fail_msg" >&2
219
+ return 1
220
+ fi
221
+ librarian_lesson_write_atomic "$path" "$updated" || {
222
+ printf '%s\n' "$stamp_fail_msg" >&2
223
+ return 1
224
+ }
225
+ }