@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
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env bats
2
2
 
3
+ # `run --separate-stderr` (used below) requires bats >= 1.5.0.
4
+ bats_require_minimum_version 1.5.0
5
+
3
6
  setup() {
4
7
  source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
8
  setup_test_env
@@ -12,6 +15,10 @@ setup() {
12
15
 
13
16
  VERDICTS='[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6},{"judge_id":"c","score":0.4}]'
14
17
 
18
+ # The rubric tribunal actually ships. Every criterion carries a min_pass, which
19
+ # is why a weight-fraction coverage guard alone cannot protect its floors.
20
+ RUBRIC_DEFAULT='{"criteria":[{"name":"correctness","weight":0.4,"min_pass":0.7},{"name":"completeness","weight":0.3,"min_pass":0.7},{"name":"safety","weight":0.2,"min_pass":0.8},{"name":"clarity","weight":0.1,"min_pass":0.5}]}'
21
+
15
22
  @test "mean of [0.8, 0.6, 0.4] is 0.6" {
16
23
  local v
17
24
  v=$(tribunal_aggregate "mean" "$VERDICTS")
@@ -37,12 +44,80 @@ VERDICTS='[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6},{"judge_id"
37
44
  awk -v v="$v" 'BEGIN { exit !(v > 0.39 && v < 0.41) }'
38
45
  }
39
46
 
40
- @test "weighted_mean degrades to mean in v0.1" {
47
+ @test "weighted_mean falls back to mean with neither rubric nor criterion scores" {
41
48
  local v
42
49
  v=$(tribunal_aggregate "weighted_mean" "$VERDICTS")
43
50
  awk -v v="$v" 'BEGIN { exit !(v > 0.59 && v < 0.61) }'
44
51
  }
45
52
 
53
+ @test "weighted_mean degrades to mean when the panel covered too little of the rubric" {
54
+ # The C1 regression: two judges rating work at 0.45/0.50 overall while
55
+ # scoring one cheap criterion at 0.9 produced an aggregate of 0.9 —
56
+ # renormalized over a fifth of the rubric and passing a gate that the
57
+ # judges' own scores would have blocked.
58
+ #
59
+ # `depth` deliberately carries NO min_pass, so every floored criterion in
60
+ # this rubric is scored. That is what keeps this test pinned to
61
+ # min_criterion_coverage: the unscored-floor guard below cannot fire here,
62
+ # so only the coverage guard can produce the plain mean.
63
+ local out
64
+ out=$(tribunal_aggregate "weighted_mean" '[
65
+ {"judge_id":"a","score":0.45,"passed":true,"criterion_scores":{"clarity":0.9}},
66
+ {"judge_id":"b","score":0.50,"passed":true,"criterion_scores":{"clarity":0.9}}
67
+ ]' '{"criteria":[{"name":"clarity","weight":0.2,"min_pass":0.5},{"name":"depth","weight":0.8}]}')
68
+ # Must be the plain mean of .score (0.475), not the renormalized 0.9.
69
+ awk -v a="$out" 'BEGIN { exit !(a > 0.474 && a < 0.476) }'
70
+ }
71
+
72
+ @test "weighted_mean degrades to mean when a floored criterion went unscored" {
73
+ # min_criterion_coverage is a weight fraction, but what a floor protects is
74
+ # that one criterion. In the shipped default rubric correctness (0.4) plus
75
+ # completeness (0.3) clears the 0.6 guard while skipping safety — the
76
+ # highest floor in the rubric — so the very inversion the coverage guard
77
+ # was added to stop reappeared one criterion further along: judges rating
78
+ # the work 0.45 and 0.50 aggregated to 0.95 and passed.
79
+ local out
80
+ out=$(tribunal_aggregate "weighted_mean" '[
81
+ {"judge_id":"a","score":0.45,"passed":true,"criterion_scores":{"correctness":0.95,"completeness":0.95}},
82
+ {"judge_id":"b","score":0.50,"passed":true,"criterion_scores":{"correctness":0.95,"completeness":0.95}}
83
+ ]' "$RUBRIC_DEFAULT")
84
+ # 0.7 of the weight is covered, so min_criterion_coverage is satisfied and
85
+ # only the unscored-floor guard can produce the plain mean (0.475) here.
86
+ awk -v a="$out" 'BEGIN { exit !(a > 0.474 && a < 0.476) }'
87
+ }
88
+
89
+ @test "weighted_mean trusts a panel that scored every floored criterion" {
90
+ local out
91
+ out=$(tribunal_aggregate "weighted_mean" '[
92
+ {"judge_id":"a","score":0.1,"passed":true,"criterion_scores":{"correctness":0.8,"completeness":0.8,"safety":0.8,"clarity":0.8}}
93
+ ]' "$RUBRIC_DEFAULT")
94
+ # Nothing floored is missing, so the weighted value (0.8) must win over .score.
95
+ awk -v a="$out" 'BEGIN { exit !(a > 0.799 && a < 0.801) }'
96
+ }
97
+
98
+ @test "weighted_mean still renormalizes over an unscored criterion carrying no floor" {
99
+ # Absence is not a zero, and renormalizing over the scored weight stays
100
+ # correct when what went unscored was never a floor. Only a missing *floor*
101
+ # forces the degrade — otherwise the fix for the inversion above would
102
+ # collapse weighted_mean into mean for every partial panel.
103
+ local out
104
+ out=$(tribunal_aggregate "weighted_mean" '[
105
+ {"judge_id":"a","score":0.1,"passed":true,"criterion_scores":{"grounding":0.8}}
106
+ ]' '{"criteria":[{"name":"grounding","weight":0.7,"min_pass":0.6},{"name":"polish","weight":0.3}]}')
107
+ # grounding is the only floor and it is scored; 0.7 coverage clears 0.6, so
108
+ # this renormalizes to 0.8 rather than degrading to .score.
109
+ awk -v a="$out" 'BEGIN { exit !(a > 0.799 && a < 0.801) }'
110
+ }
111
+
112
+ @test "an out-of-range criterion score is ignored, not trusted" {
113
+ local out
114
+ out=$(tribunal_aggregate "weighted_mean" '[
115
+ {"judge_id":"a","score":0.5,"criterion_scores":{"correctness":100}}
116
+ ]' '{"criteria":[{"name":"correctness","weight":1.0,"min_pass":0.7}]}')
117
+ # 100 is filtered, nothing is covered, so this degrades to the mean of .score.
118
+ awk -v a="$out" 'BEGIN { exit !(a > 0.499 && a < 0.501) }'
119
+ }
120
+
46
121
  @test "unknown method falls back to mean with warning on stderr" {
47
122
  run bash -c '
48
123
  source "${REPO_ROOT}/plugins/tribunal/scripts/lib/tribunal-aggregate.sh"
@@ -75,3 +150,281 @@ VERDICTS='[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6},{"judge_id"
75
150
  d=$(tribunal_disagreement '[{"score":0.7}]')
76
151
  [ "$d" = "0" ]
77
152
  }
153
+
154
+ # Two criteria with deliberately unequal weights, so weighted_mean and mean
155
+ # cannot coincide. Judge A is strong on the heavy criterion, weak on the light
156
+ # one; judge B is the reverse.
157
+ # `clarity` carries NO min_pass on purpose, and that is load-bearing: the tests
158
+ # below leave it unscored to exercise the renormalization arithmetic, and an
159
+ # unscored *floor* degrades weighted_mean to the plain mean before any of that
160
+ # arithmetic runs. Re-adding a min_pass here does not fail anything loudly — it
161
+ # quietly converts five weight tests into five assertions about the mean of
162
+ # .score. `correctness` stays floored and stays scored, so the guard is still
163
+ # live in this fixture; it is just never tripped.
164
+ RUBRIC_UNEQUAL='{"criteria":[{"name":"correctness","weight":0.9,"min_pass":0.7},{"name":"clarity","weight":0.1}]}'
165
+ SCORED='[
166
+ {"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":0.0}},
167
+ {"judge_id":"b","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":0.0}}
168
+ ]'
169
+
170
+ @test "weighted_mean differs from mean when weights are unequal" {
171
+ # mean of .score is 0.5 for both judges. The weighted mean is
172
+ # 0.9*1.0 + 0.1*0.0 = 0.9. If these come out equal, weights are still inert.
173
+ local w m
174
+ w=$(tribunal_aggregate "weighted_mean" "$SCORED" "$RUBRIC_UNEQUAL")
175
+ m=$(tribunal_aggregate "mean" "$SCORED" "$RUBRIC_UNEQUAL")
176
+ awk -v a="$w" -v b="$m" 'BEGIN { exit !(a != b) }' || return 1
177
+ awk -v a="$w" 'BEGIN { exit !(a > 0.89 && a < 0.91) }'
178
+ }
179
+
180
+ @test "weighted_mean averages judges within a criterion before weighting" {
181
+ local out
182
+ out=$(tribunal_aggregate "weighted_mean" '[
183
+ {"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":1.0}},
184
+ {"judge_id":"b","score":0.5,"criterion_scores":{"correctness":0.0,"clarity":1.0}}
185
+ ]' "$RUBRIC_UNEQUAL")
186
+ # correctness mean 0.5, clarity mean 1.0 → 0.9*0.5 + 0.1*1.0 = 0.55
187
+ awk -v a="$out" 'BEGIN { exit !(a > 0.549 && a < 0.551) }'
188
+ }
189
+
190
+ @test "weighted_mean degrades to mean when no verdict carries criterion_scores" {
191
+ # Every verdict emitted before Task 1 shipped looks like this.
192
+ local out
193
+ out=$(tribunal_aggregate "weighted_mean" \
194
+ '[{"judge_id":"a","score":0.8},{"judge_id":"b","score":0.6}]' "$RUBRIC_UNEQUAL")
195
+ awk -v a="$out" 'BEGIN { exit !(a > 0.699 && a < 0.701) }'
196
+ }
197
+
198
+ @test "an absent criterion is skipped, not counted as zero" {
199
+ # clarity is absent everywhere. If absence read as 0 the answer would be
200
+ # 0.9*1.0 + 0.1*0.0 = 0.9. Skipping it renormalizes to 0.9/0.9 = 1.0.
201
+ local out
202
+ out=$(tribunal_aggregate "weighted_mean" \
203
+ '[{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0}}]' \
204
+ "$RUBRIC_UNEQUAL")
205
+ awk -v a="$out" 'BEGIN { exit !(a > 0.999 && a < 1.001) }'
206
+ }
207
+
208
+ @test "a criterion scored at zero is honored, not treated as absent" {
209
+ # The mirror of the previous test, and the one that catches a `// 0` fix
210
+ # that "passes" the absence test by accident.
211
+ local out
212
+ out=$(tribunal_aggregate "weighted_mean" \
213
+ '[{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":0.0}}]' \
214
+ "$RUBRIC_UNEQUAL")
215
+ awk -v a="$out" 'BEGIN { exit !(a >= 0 && a < 0.001) }'
216
+ }
217
+
218
+ @test "weights that do not sum to 1.0 are normalized" {
219
+ # tribunal_rubric_validate rejects such a rubric, but librarian's loader
220
+ # validates nothing and hands its rubric straight through. Normalizing here
221
+ # means the two paths cannot disagree.
222
+ local out
223
+ out=$(tribunal_aggregate "weighted_mean" \
224
+ '[{"judge_id":"a","score":0.1,"criterion_scores":{"correctness":1.0,"clarity":0.0}}]' \
225
+ '{"criteria":[{"name":"correctness","weight":1.8,"min_pass":0.7},{"name":"clarity","weight":0.2,"min_pass":0.5}]}')
226
+ # 1.8*1.0 + 0.2*0.0 = 1.8, over a weight sum of 2.0 → 0.9
227
+ awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
228
+ }
229
+
230
+ @test "a hyphenated criterion name scores correctly" {
231
+ # A dotted jq path would be a COMPILE error here: exit 3, empty stdout,
232
+ # which awk reads as 0.
233
+ local out
234
+ out=$(tribunal_aggregate "weighted_mean" \
235
+ '[{"judge_id":"a","score":0.2,"criterion_scores":{"path-traversal":1.0}}]' \
236
+ '{"criteria":[{"name":"path-traversal","weight":1.0,"min_pass":0.5}]}')
237
+ awk -v a="$out" 'BEGIN { exit !(a > 0.999 && a < 1.001) }'
238
+ }
239
+
240
+ @test "weighted_mean falls back to mean when the rubric is absent" {
241
+ local out
242
+ out=$(tribunal_aggregate "weighted_mean" "$SCORED")
243
+ awk -v a="$out" 'BEGIN { exit !(a > 0.499 && a < 0.501) }'
244
+ }
245
+
246
+ @test "a non-number criterion score is ignored rather than poisoning the mean" {
247
+ local out
248
+ out=$(tribunal_aggregate "weighted_mean" \
249
+ '[{"judge_id":"a","score":0.5,"criterion_scores":{"correctness":1.0,"clarity":"n/a"}}]' \
250
+ "$RUBRIC_UNEQUAL")
251
+ awk -v a="$out" 'BEGIN { exit !(a > 0.999 && a < 1.001) }'
252
+ }
253
+
254
+ # --- Verdicts with no usable .score (ecosystem-up8) -------------------------
255
+ #
256
+ # A judge that returns parseable JSON omitting `score` is malformed, not
257
+ # unanimous. jq's `add` treats the missing key as null and `null + n == n`, so
258
+ # such a verdict used to contribute 0 to the numerator and 1 to the
259
+ # denominator — wrong rather than loud. It is dropped from the panel instead.
260
+
261
+ SCORELESS_RUBRIC='{"criteria":[{"name":"correctness","weight":1.0,"min_pass":0.7}]}'
262
+
263
+ @test "mean drops a scoreless verdict instead of counting it as zero" {
264
+ local out
265
+ out=$(tribunal_aggregate "mean" '[{"judge_id":"a","score":0.9},{"judge_id":"b"}]')
266
+ # Counting the missing score as 0 gives 0.45. Dropping it gives 0.9.
267
+ awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
268
+ }
269
+
270
+ @test "weighted_mean does not count a scoreless judge's criterion scores" {
271
+ # The failure mode ecosystem-pht introduced: once criterion_scores cover the
272
+ # rubric, weighted_mean never reads .score, so a judge that returned no
273
+ # verdict at all was silently counted as a fully participating one — and it
274
+ # fails toward NOT blocking. Dropping happens before any method runs, so the
275
+ # malformed judge takes its criterion scores with it.
276
+ local out
277
+ out=$(tribunal_aggregate "weighted_mean" '[
278
+ {"judge_id":"a","score":0.9,"criterion_scores":{"correctness":0.9}},
279
+ {"judge_id":"b","criterion_scores":{"correctness":0.1}}
280
+ ]' "$SCORELESS_RUBRIC")
281
+ # Averaging both criterion scores gives 0.5; dropping judge b gives 0.9.
282
+ awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
283
+ }
284
+
285
+ @test "median drops a scoreless verdict" {
286
+ local out
287
+ out=$(tribunal_aggregate "median" '[{"score":0.4},{"score":0.8},{"judge_id":"c"}]')
288
+ # Two usable scores left, so the median is their mean: 0.6.
289
+ awk -v a="$out" 'BEGIN { exit !(a > 0.599 && a < 0.601) }'
290
+ }
291
+
292
+ @test "min never returns null when a verdict has no score" {
293
+ # jq's `min` over [null, 0.9] is null, which the gate then feeds to awk as
294
+ # the string "null" and coerces to 0 — a non-numeric aggregate that reads
295
+ # as the worst possible score.
296
+ local out
297
+ out=$(tribunal_aggregate "min" '[{"judge_id":"a","score":0.9},{"judge_id":"b"}]')
298
+ [ "$out" != "null" ] || return 1
299
+ awk -v a="$out" 'BEGIN { exit !(a > 0.899 && a < 0.901) }'
300
+ }
301
+
302
+ @test "a panel where no verdict carries a score aggregates to 0, not a crash" {
303
+ # Fails closed, matching the empty-panel convention directly above it: 0 is
304
+ # below every threshold, so the gate blocks.
305
+ local none='[{"judge_id":"a"},{"judge_id":"b"}]'
306
+ local m
307
+ for m in mean weighted_mean median min; do
308
+ # --separate-stderr: the drop warning would otherwise land in $output.
309
+ run --separate-stderr tribunal_aggregate "$m" "$none" "$SCORELESS_RUBRIC"
310
+ [ "$status" -eq 0 ] || return 1
311
+ [ "$output" = "0" ] || return 1
312
+ done
313
+ }
314
+
315
+ @test "a scoreless verdict with criterion_scores still aggregates to 0 when it is the whole panel" {
316
+ # weighted_mean must not resurrect a panel that has no usable verdict just
317
+ # because the criterion map looks complete.
318
+ run --separate-stderr tribunal_aggregate "weighted_mean" \
319
+ '[{"judge_id":"a","criterion_scores":{"correctness":0.9}}]' "$SCORELESS_RUBRIC"
320
+ [ "$status" -eq 0 ] || return 1
321
+ [ "$output" = "0" ]
322
+ }
323
+
324
+ @test "dropping a scoreless verdict is reported on stderr with its judge_id" {
325
+ run --separate-stderr tribunal_aggregate "mean" \
326
+ '[{"judge_id":"a","score":0.9},{"judge_id":"bad-judge"}]'
327
+ [ "$status" -eq 0 ] || return 1
328
+ local re='bad-judge'
329
+ [[ "$stderr" =~ $re ]]
330
+ }
331
+
332
+ @test "no dropped-verdict warning when every verdict carries a score" {
333
+ run --separate-stderr tribunal_aggregate "mean" '[{"score":0.8},{"score":0.6}]'
334
+ [ -z "$stderr" ]
335
+ }
336
+
337
+ @test "disagreement ignores a verdict with no score" {
338
+ local d
339
+ d=$(tribunal_disagreement '[{"score":0.2},{"score":0.8},{"judge_id":"c"}]')
340
+ awk -v d="$d" 'BEGIN { exit !(d > 0.599 && d < 0.601) }'
341
+ }
342
+
343
+ @test "disagreement is 0 when fewer than two verdicts carry a score" {
344
+ # It crashed on a single scoreless verdict, and a crash prints nothing to
345
+ # stdout — so the caller read an empty dissent score, awk coerced it to 0,
346
+ # and the gate's dissent short-circuit never fired. Fails toward not blocking.
347
+ local d
348
+ d=$(tribunal_disagreement '[{"score":0.9},{"judge_id":"b"}]')
349
+ [ "$d" = "0" ] || return 1
350
+ d=$(tribunal_disagreement '[{"judge_id":"a"},{"judge_id":"b"}]')
351
+ [ "$d" = "0" ]
352
+ }
353
+
354
+ # --- out-of-range verdict scores (ecosystem-7cl) --------------------------
355
+ #
356
+ # A .score outside [0,1] is malformed for the same reason a missing one is: it
357
+ # is not a verdict this scale can read. The realistic trigger is a judge
358
+ # emitting a percentage instead of a fraction — 95 rather than 0.95 — which is
359
+ # an ordinary LLM formatting slip, not an adversarial input. Nothing at the
360
+ # seam rejects it: TribunalVerdictPayload declares score in [0,1] but the
361
+ # runtime emitter fails open (ADR-005), so the aggregate has to re-check.
362
+
363
+ @test "an out-of-range verdict score is dropped, not averaged in" {
364
+ local out
365
+ out=$(tribunal_aggregate "mean" \
366
+ '[{"judge_id":"a","score":100},{"judge_id":"b","score":0.6}]' 2>/dev/null)
367
+ # Trusting 100 gives 50.3. Dropping it leaves b's 0.6.
368
+ awk -v a="$out" 'BEGIN { exit !(a > 0.599 && a < 0.601) }'
369
+ }
370
+
371
+ @test "a negative verdict score is dropped" {
372
+ local out
373
+ out=$(tribunal_aggregate "mean" \
374
+ '[{"judge_id":"a","score":-5},{"judge_id":"b","score":0.6}]' 2>/dev/null)
375
+ awk -v a="$out" 'BEGIN { exit !(a > 0.599 && a < 0.601) }'
376
+ }
377
+
378
+ @test "a whole panel of out-of-range scores aggregates to 0, not a passing score" {
379
+ # THE fail-open case. The mixed panel above happens to block, but only
380
+ # incidentally — dissent 99.4 trips the gate's dissent short-circuit. Once
381
+ # the entire panel is out of range there is no disagreement left to catch
382
+ # it, so every threshold cleared and the gate passed on garbage.
383
+ local all='[{"judge_id":"a","score":100},{"judge_id":"b","score":95}]'
384
+ local m
385
+ for m in mean weighted_mean median min; do
386
+ # --separate-stderr: the drop warning would otherwise land in $output.
387
+ run --separate-stderr tribunal_aggregate "$m" "$all" "$SCORELESS_RUBRIC"
388
+ [ "$status" -eq 0 ] || return 1
389
+ [ "$output" = "0" ] || return 1
390
+ done
391
+ }
392
+
393
+ @test "the range bounds themselves stay usable" {
394
+ # [0,1] is inclusive: a judge that scored 0 rejected the work, and one that
395
+ # scored 1 is not malformed. Dropping either would be its own fail-open —
396
+ # a 0 is exactly the verdict that must survive to drag the mean down.
397
+ local out
398
+ out=$(tribunal_aggregate "mean" \
399
+ '[{"judge_id":"a","score":0},{"judge_id":"b","score":1}]' 2>/dev/null)
400
+ awk -v a="$out" 'BEGIN { exit !(a > 0.499 && a < 0.501) }'
401
+ }
402
+
403
+ @test "weighted_mean does not count an out-of-range judge's criterion scores" {
404
+ # Same sharpest-form hazard as the scoreless case: once criterion_scores
405
+ # cover the rubric weighted_mean never reads .score, so a judge whose
406
+ # verdict was refused would otherwise still steer the aggregate.
407
+ local out
408
+ out=$(tribunal_aggregate "weighted_mean" '[
409
+ {"judge_id":"a","score":100,"criterion_scores":{"correctness":0.9}}
410
+ ]' "$SCORELESS_RUBRIC" 2>/dev/null)
411
+ [ "$out" = "0" ]
412
+ }
413
+
414
+ @test "dropping an out-of-range verdict is reported on stderr with its judge_id" {
415
+ run --separate-stderr tribunal_aggregate "mean" \
416
+ '[{"judge_id":"a","score":0.9},{"judge_id":"percent-judge","score":95}]'
417
+ [ "$status" -eq 0 ] || return 1
418
+ local re='percent-judge'
419
+ [[ "$stderr" =~ $re ]]
420
+ }
421
+
422
+ @test "disagreement ignores an out-of-range verdict" {
423
+ # Verified pre-fix: two judges at 100 and 0.1 reported dissent 99.9, which
424
+ # blocked for the wrong reason. Dissent must describe the panel that was
425
+ # actually scored.
426
+ local d
427
+ d=$(tribunal_disagreement \
428
+ '[{"judge_id":"a","score":100},{"judge_id":"b","score":0.2},{"judge_id":"c","score":0.8}]' 2>/dev/null)
429
+ awk -v d="$d" 'BEGIN { exit !(d > 0.599 && d < 0.601) }'
430
+ }