@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.
- package/.claude/skills/writing-tests/SKILL.md +27 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +7 -7
- package/AGENTS.md +116 -0
- package/CHANGELOG.md +175 -0
- package/CLAUDE.md +5 -0
- package/docs/lesson-promotion-pipeline.md +6 -6
- package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
- package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
- package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
- package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
- package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
- package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
- package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
- package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
- package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
- package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
- package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
- package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
- package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
- package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
- package/package.json +2 -2
- package/plugins/assayer/.claude-plugin/plugin.json +1 -1
- package/plugins/assayer/CHANGELOG.md +7 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
- package/plugins/cartographer/CHANGELOG.md +54 -0
- package/plugins/cartographer/README.md +56 -1
- package/plugins/cartographer/config.json +7 -1
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
- package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
- package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
- package/plugins/cartographer/scripts/run-audit.sh +136 -53
- package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
- package/plugins/curator/.claude-plugin/plugin.json +1 -1
- package/plugins/curator/CHANGELOG.md +7 -0
- package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
- package/plugins/historian/.claude-plugin/plugin.json +1 -1
- package/plugins/historian/CHANGELOG.md +7 -0
- package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +71 -0
- package/plugins/librarian/config.json +30 -1
- package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
- package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
- package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
- package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
- package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
- package/plugins/librarian/skills/librarian/SKILL.md +98 -1
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +63 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
- package/plugins/tribunal/config.json +1 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
- package/scripts/lib/prompt-rules.sh +6 -1
- package/test/bats/archivist-inject.bats +1 -1
- package/test/bats/assayer-extract.bats +2 -2
- package/test/bats/bursar-session-start.bats +3 -3
- package/test/bats/cartographer-config.bats +43 -0
- package/test/bats/cartographer-events.bats +267 -0
- package/test/bats/cartographer-filter.bats +169 -0
- package/test/bats/cartographer-lock.bats +3 -3
- package/test/bats/cartographer-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/compass-sanitizer.bats +11 -11
- package/test/bats/compass-transcript.bats +2 -2
- package/test/bats/config.bats +15 -15
- package/test/bats/curator-session-start.bats +10 -3
- package/test/bats/emit-payload-default.bats +52 -0
- package/test/bats/governor-ledger.bats +1 -1
- package/test/bats/historian-prompt-submit.bats +1 -1
- package/test/bats/inspector-post-write-hook.bats +4 -4
- package/test/bats/librarian-author-key.bats +477 -0
- package/test/bats/librarian-cli.bats +16 -16
- package/test/bats/librarian-lesson-judge.bats +884 -0
- package/test/bats/librarian-lesson-promote.bats +552 -0
- package/test/bats/librarian-lesson-review.bats +900 -0
- package/test/bats/librarian-lesson-transform.bats +17 -3
- package/test/bats/librarian-session-end.bats +161 -0
- package/test/bats/librarian-session-start.bats +36 -2
- package/test/bats/lineage-config.bats +1 -1
- package/test/bats/lineage-redact.bats +5 -5
- package/test/bats/session-tracker.bats +4 -4
- package/test/bats/tribunal-aggregate.bats +354 -1
- package/test/bats/tribunal-gate.bats +631 -0
- package/test/bats/tribunal-judge-agents.bats +119 -0
- package/test/bats/tribunal-jury.bats +1 -1
- package/test/bats/turn-tracker.bats +1 -1
- package/test/bats/warden-sanitizer.bats +3 -3
- package/test/bats/worktree-tracker.bats +2 -2
- 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
|
+
}
|