@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
@@ -15,27 +15,166 @@
15
15
  # tribunal_disagreement <verdicts_json>
16
16
  # echoes max(score) - min(score), or 0 if 0/1 verdicts
17
17
  #
18
- # weighted_mean uses *rubric criterion weights*, not per-judge weights the
19
- # semantics are "weight each criterion's contribution, then average judges'
20
- # scores on each criterion." For v0.1 the per-criterion breakdown is not yet
21
- # threaded through verdicts, so weighted_mean degrades to mean when the rubric
22
- # weights cannot be applied. The schema still emits aggregation_method =
23
- # "weighted_mean" so dashboards see the intent.
18
+ # weighted_mean uses *rubric criterion weights*: average the judges' scores on
19
+ # each criterion, weight each criterion's mean, and normalize by the weights
20
+ # actually used. A criterion no judge scored contributes nothing and its weight
21
+ # is excluded from the denominator absence is not a zero. When no criterion
22
+ # has any score (every verdict emitted before judges shipped criterion_scores),
23
+ # weighted_mean degrades to mean rather than collapsing to 0.
24
+ #
25
+ # Normalizing by the weights actually scored is only honest once the panel has
26
+ # covered enough of the rubric. The judge agents are told to omit any criterion
27
+ # they cannot assess, so a panel that scores one cheap criterion and skips the
28
+ # rest would otherwise produce an aggregate computed entirely from that one and
29
+ # renormalized to look complete — and .score, the judge's own overall verdict,
30
+ # is never read here to contradict it. Two guards, and weighted_mean degrades to
31
+ # the plain mean of .score — the panel's own summary judgment — on either:
32
+ #
33
+ # 1. Any criterion carrying a min_pass went unscored. A weight fraction is the
34
+ # wrong proxy for what a floor protects, because a floor protects one named
35
+ # criterion. In the shipped default rubric correctness (0.4) plus
36
+ # completeness (0.3) clears any coverage bar at or below 0.7 while skipping
37
+ # safety, the highest floor there is — which reproduced the exact inversion
38
+ # guard 2 was added to stop, one criterion further along.
39
+ # 2. The scored weight covers less than min_criterion_coverage of the rubric's
40
+ # total declared weight. Still load-bearing for rubrics that mix floored and
41
+ # unfloored criteria, where guard 1 has nothing to say.
42
+ #
43
+ # A criterion with no min_pass is exempt from guard 1 by design: absence is not a
44
+ # zero, and renormalizing over it stays correct. Only floors force the degrade,
45
+ # or every partial panel would collapse weighted_mean into mean.
46
+ #
47
+ # Degrading is deliberately not refusing — a blocked tribunal task retries. That
48
+ # is the asymmetry with librarian, which refuses (UNJUDGED) because a lesson
49
+ # published without its disclosure floor ever running is not recoverable.
50
+
51
+ # Echoes the panel with malformed verdicts removed, naming each on stderr.
52
+ #
53
+ # A judge that returns parseable JSON omitting `score` is malformed, not
54
+ # unanimous, but jq's `add` treats the missing key as null and `null + n == n`
55
+ # — so such a verdict contributed 0 to the numerator and 1 to the denominator,
56
+ # silently dragging the panel down rather than failing loudly. `min` was worse:
57
+ # it returned literal `null`, which the gate feeds to awk as the string "null"
58
+ # and coerces to 0.
59
+ #
60
+ # Dropping rather than refusing is deliberate. Tribunal has no UNJUDGED channel
61
+ # — tribunal_aggregate returns a float — so the choice is between dropping and
62
+ # inventing one. Dropping silently would be its own bug, hence the stderr line.
63
+ #
64
+ # A score outside [0,1] is malformed for the same reason a missing one is: it is
65
+ # not a verdict this scale can read. Dropping rather than clamping is what keeps
66
+ # one rule in this file — the criterion-score path in weighted_mean already
67
+ # filters to `number and >= 0 and <= 1` rather than clamping, and applying two
68
+ # different validity rules to the same concept is what let this through. The
69
+ # realistic trigger is a judge emitting a percentage instead of a fraction (95
70
+ # rather than 0.95), an ordinary LLM formatting slip. Nothing else at the seam
71
+ # rejects it: TribunalVerdictPayload declares score in [0,1], but the runtime
72
+ # emitter fails open (ADR-005), so this is the only place that re-checks.
73
+ #
74
+ # Bounds are inclusive. A judge that scored 0 rejected the work and a judge that
75
+ # scored 1 did not — dropping either would be its own fail-open, since 0 is
76
+ # precisely the verdict that has to survive to drag the panel below threshold.
77
+ #
78
+ # Private to this file. See ecosystem-up8 (missing) and ecosystem-7cl (range).
79
+ _tribunal_usable_verdicts() {
80
+ local verdicts="${1:-[]}"
81
+ local usable_def='def usable: (.score | type) == "number" and .score >= 0 and .score <= 1;'
82
+ local dropped
83
+ # One `def` reused by both passes, rather than the positive predicate here
84
+ # and a hand-negated copy below — that pair drifting is the whole bug class.
85
+ dropped=$(printf '%s' "$verdicts" | jq -r "$usable_def"'
86
+ [ .[]
87
+ | select(usable | not)
88
+ | (.judge_id // .judge_type // "unidentified") ] | join(", ")
89
+ ' 2>/dev/null) || dropped=""
90
+ if [[ -n "$dropped" ]]; then
91
+ printf 'tribunal-aggregate: ignoring verdict(s) with no usable score (missing, non-numeric, or outside [0,1]): %s\n' \
92
+ "$dropped" >&2
93
+ fi
94
+ printf '%s' "$verdicts" | jq -c "$usable_def"'[ .[] | select(usable) ]' \
95
+ 2>/dev/null || printf '[]'
96
+ }
24
97
 
25
98
  tribunal_aggregate() {
26
99
  local method="${1:-mean}"
27
100
  local verdicts="${2:-[]}"
28
- local _rubric="${3:-{}}" # reserved for true weighted_mean once per-criterion scores are threaded
29
- : "$_rubric"
101
+ local rubric="${3:-}"
102
+ [ -z "$rubric" ] && rubric='{}'
103
+
104
+ # This lib is sourced standalone in tests and does not pull in
105
+ # tribunal-config.sh; fall back rather than adding a source line.
106
+ local min_coverage
107
+ if ! type tribunal_config_get >/dev/null 2>&1; then
108
+ min_coverage="0.6"
109
+ else
110
+ min_coverage=$(tribunal_config_get '.tribunal.rubric.min_criterion_coverage' 2>/dev/null)
111
+ fi
112
+ case "$min_coverage" in
113
+ ''|null) min_coverage="0.6" ;;
114
+ esac
30
115
 
31
116
  local count
32
117
  count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
33
118
  [[ "$count" -eq 0 ]] && { printf '0'; return 0; }
34
119
 
120
+ # Malformed verdicts leave the panel before any method runs — including
121
+ # weighted_mean, which would otherwise still consume their criterion_scores.
122
+ # That is the sharpest form of this bug: once criterion_scores cover the
123
+ # rubric, weighted_mean never reads .score, so a judge that returned no
124
+ # verdict at all counted as a fully participating one, and it failed toward
125
+ # not blocking.
126
+ verdicts=$(_tribunal_usable_verdicts "$verdicts")
127
+ count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
128
+ # No usable verdict aggregates to 0, matching the empty-panel line above:
129
+ # below every threshold, so the gate blocks rather than passing on nothing.
130
+ [[ "$count" -eq 0 ]] && { printf '0'; return 0; }
131
+
35
132
  case "$method" in
36
- mean|weighted_mean)
133
+ mean)
37
134
  printf '%s' "$verdicts" | jq -r '[.[].score] | add / length'
38
135
  ;;
136
+ weighted_mean)
137
+ local weighted
138
+ weighted=$(printf '%s' "$verdicts" | jq -r \
139
+ --argjson rubric "$rubric" --argjson mincov "$min_coverage" '
140
+ . as $v
141
+ | ([ ($rubric.criteria // [])[]
142
+ | select((.weight | type) == "number") | .weight ] | add) as $total_w
143
+ | [ ($rubric.criteria // [])[]
144
+ | select((.name | type) == "string" and (.weight | type) == "number")
145
+ | . as $c
146
+ | ([ $v[]
147
+ | select((.criterion_scores | type) == "object")
148
+ | select(.criterion_scores | has($c.name))
149
+ | .criterion_scores[$c.name]
150
+ | select(type == "number" and . >= 0 and . <= 1) ]) as $scores
151
+ | select(($scores | length) > 0)
152
+ | { w: $c.weight, m: (($scores | add) / ($scores | length)) } ] as $covered
153
+ | [ ($rubric.criteria // [])[]
154
+ | select((.name | type) == "string" and (.min_pass | type) == "number")
155
+ | . as $c
156
+ | select([ $v[]
157
+ | select((.criterion_scores | type) == "object")
158
+ | select(.criterion_scores | has($c.name))
159
+ | .criterion_scores[$c.name]
160
+ | select(type == "number" and . >= 0 and . <= 1) ] | length == 0)
161
+ | $c.name ] as $unscored_floors
162
+ | ($covered | map(.w) | add) as $den
163
+ | if ($covered | length) == 0 or $den == null or $den <= 0 then empty
164
+ elif $total_w == null or $total_w <= 0 then empty
165
+ elif ($unscored_floors | length) > 0 then empty
166
+ elif ($den / $total_w) < $mincov then empty
167
+ else ($covered | map(.w * .m) | add) / $den
168
+ end
169
+ ' 2>/dev/null)
170
+ if [ -n "$weighted" ]; then
171
+ printf '%s' "$weighted"
172
+ else
173
+ # No criterion carried a usable score, or the panel covered too
174
+ # little of the rubric to renormalize honestly — degrade to mean.
175
+ printf '%s' "$verdicts" | jq -r '[.[].score] | add / length'
176
+ fi
177
+ ;;
39
178
  median)
40
179
  printf '%s' "$verdicts" | jq -r '
41
180
  [.[].score] | sort as $s
@@ -58,6 +197,12 @@ tribunal_aggregate() {
58
197
 
59
198
  tribunal_disagreement() {
60
199
  local verdicts="${1:-[]}"
200
+ # Same filter as tribunal_aggregate, and this one crashed on a *single*
201
+ # scoreless verdict rather than needing a whole panel of them. A crash
202
+ # prints nothing to stdout, so the caller read an empty dissent score, awk
203
+ # coerced it to 0, and the gate's dissent short-circuit never fired —
204
+ # failing toward not blocking.
205
+ verdicts=$(_tribunal_usable_verdicts "$verdicts")
61
206
  local count
62
207
  count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
63
208
  [[ "$count" -lt 2 ]] && { printf '0'; return 0; }
@@ -15,31 +15,128 @@
15
15
  #
16
16
  # Echoes a JSON object: { passed: bool, reason?: string }
17
17
  # reason is one of: low_score | meta_override | bias_detected | dissent_unresolved
18
+ # | criterion_floor (with failed_criterion naming the criterion)
18
19
  #
19
- # Usage: result=$(tribunal_gate_decide "$policy" "$verdicts" "$agg" "$thr" "$meta" "$dissent" "$dissent_thr")
20
+ # Usage: result=$(tribunal_gate_decide "$policy" "$verdicts" "$agg" "$thr" "$meta" "$dissent" "$dissent_thr" "$rubric")
21
+
22
+ # Names the first criterion sitting below its min_pass, or echoes nothing.
23
+ #
24
+ # The *lowest* score among the judges that scored it, not the mean: a floor
25
+ # means nobody may be below it. Using the mean let a specialist's finding be
26
+ # diluted by generalists who did not look — with safety floored at 0.8, a
27
+ # security judge scoring 0.6 against two generalists at 0.95 averaged to 0.83
28
+ # and passed, so empanelling more judges made every floor weaker. Weighting
29
+ # still uses the mean; only the floor is a minimum.
30
+ #
31
+ # A criterion no judge scored is NOT a violation: absence is not a zero, and
32
+ # treating it as one would fail every verdict emitted before judges shipped
33
+ # criterion_scores. tribunal_aggregate degrades weighted_mean to the plain mean
34
+ # when that happens, and the caller warns on stderr.
35
+ #
36
+ # A criterion a judge DID score but scored unreadably — outside [0,1], or not a
37
+ # number — is a different thing, and it is a violation. Absence means the judge
38
+ # declined to assess (the agents are told to omit what they cannot judge);
39
+ # unreadable means they assessed it and the answer did not survive. A floor is
40
+ # the rubric's hard constraint, so "cannot confirm this held" must fail closed.
41
+ #
42
+ # ANY unreadable report violates, not merely an all-unreadable criterion, and
43
+ # that follows from why the floor uses min rather than mean in the first place:
44
+ # a specialist's finding must not be diluted by generalists who did not look.
45
+ # Merely filtering the bad value reproduces the exact dilution this design was
46
+ # built to prevent — a security judge emitting 30 (plausibly 0.30, a violation)
47
+ # would be discarded so two generalists at 0.95 carry the floor.
48
+ #
49
+ # The range test matches _tribunal_usable_verdicts in tribunal-aggregate.sh and
50
+ # the criterion filter in weighted_mean. Those disagreeing is what let an
51
+ # out-of-range score satisfy a floor while aggregation refused to score it at
52
+ # all — see ecosystem-5fy.
53
+ #
54
+ # Private to this file. It exists as a function because the answer is needed in
55
+ # two places — once to veto `meta_override: accept`, once for the blocking
56
+ # precedence chain — and duplicating the jq is how the two drift apart.
57
+ _tribunal_floor_failed() {
58
+ local verdicts="${1:-[]}"
59
+ local rubric="${2:-}"
60
+ [ -z "$rubric" ] && rubric='{}'
61
+ printf '%s' "$verdicts" | jq -r --argjson rubric "$rubric" '
62
+ def readable: (type == "number") and . >= 0 and . <= 1;
63
+ . as $v
64
+ | [ ($rubric.criteria // [])[]
65
+ | select((.name | type) == "string" and (.min_pass | type) == "number")
66
+ | . as $c
67
+ | ([ $v[]
68
+ | select((.criterion_scores | type) == "object")
69
+ | select(.criterion_scores | has($c.name))
70
+ | .criterion_scores[$c.name] ]) as $reported
71
+ | ([ $reported[] | select(readable) ]) as $scores
72
+ | select(
73
+ # somebody reported a score this scale cannot read...
74
+ ($reported | length) > ($scores | length)
75
+ # ...or the readable ones put somebody under the floor.
76
+ or (($scores | length) > 0 and ($scores | min) < $c.min_pass)
77
+ )
78
+ | $c.name ]
79
+ | first // empty
80
+ ' 2>/dev/null || printf ''
81
+ }
20
82
 
21
83
  tribunal_gate_decide() {
22
84
  local policy="${1:-majority}"
23
85
  local verdicts="${2:-[]}"
24
86
  local aggregated_score="${3:-0}"
25
87
  local score_threshold="${4:-0.75}"
26
- local meta="${5:-{}}"
88
+ local meta="${5:-}"
89
+ [ -z "$meta" ] && meta='{}'
27
90
  local dissent_score="${6:-0}"
28
91
  local dissent_threshold="${7:-0.25}"
92
+ local rubric="${8:-}"
93
+ [ -z "$rubric" ] && rubric='{}'
29
94
 
30
95
  local meta_bias_detected meta_override
31
96
  meta_bias_detected=$(printf '%s' "$meta" | jq -r '.bias_detected // false' 2>/dev/null)
32
97
  meta_override=$(printf '%s' "$meta" | jq -r '.override_recommendation // empty' 2>/dev/null)
33
98
 
99
+ # Which criterion, if any, sits below its floor. Computed here rather than
100
+ # beside the other blocking reasons because `meta_override: accept` returns
101
+ # before them, and a floor the Meta-Judge can lift is not a floor. See the
102
+ # longer note on min-not-mean further down.
103
+ local floor_failed
104
+ floor_failed=$(_tribunal_floor_failed "$verdicts" "$rubric")
105
+
106
+ # A violated floor is worth naming on whichever arm blocks: the worst
107
+ # failures drag the aggregate under the threshold too, so attaching
108
+ # failed_criterion only to the criterion_floor arm left the diagnostic
109
+ # absent exactly where it mattered most.
110
+ #
111
+ # Built HERE, beside floor_failed, rather than down with the final chain.
112
+ # It used to be defined below the short-circuit returns, which could
113
+ # therefore never reference it however much they should have — so
114
+ # dissent_unresolved carried the criterion from the chain arm and dropped it
115
+ # from the short-circuit, one reason string with two behaviors. See
116
+ # ecosystem-973. Every blocking return below now decorates, and the test
117
+ # "every blocking arm that can see a violated floor names it" holds a newly
118
+ # added arm to the same rule without anyone having to remember.
119
+ local floor_suffix=""
120
+ [[ -n "$floor_failed" ]] && floor_suffix=$(printf ',"failed_criterion":"%s"' "$floor_failed")
121
+
34
122
  # meta_override policy: the Meta-Judge wins, regardless of jury.
35
123
  if [[ "$policy" == "meta_override" ]]; then
36
124
  case "$meta_override" in
37
125
  accept)
126
+ # ...but not regardless of the rubric. `accept` overrules the
127
+ # jury's *opinion*; a floor is the rubric's hard constraint, so
128
+ # it survives. Every other short-circuit below already blocks,
129
+ # which made this the one arm that could turn a violated floor
130
+ # into a pass.
131
+ if [[ -n "$floor_failed" ]]; then
132
+ printf '{"passed":false,"reason":"criterion_floor","failed_criterion":"%s"}' "$floor_failed"
133
+ return 0
134
+ fi
38
135
  printf '{"passed":true}'
39
136
  return 0
40
137
  ;;
41
138
  reject)
42
- printf '{"passed":false,"reason":"meta_override"}'
139
+ printf '{"passed":false,"reason":"meta_override"%s}' "$floor_suffix"
43
140
  return 0
44
141
  ;;
45
142
  re-evaluate|"")
@@ -50,7 +147,7 @@ tribunal_gate_decide() {
50
147
 
51
148
  # Bias detection short-circuit (any policy).
52
149
  if [[ "$meta_bias_detected" == "true" && "$meta_override" == "reject" ]]; then
53
- printf '{"passed":false,"reason":"bias_detected"}'
150
+ printf '{"passed":false,"reason":"bias_detected"%s}' "$floor_suffix"
54
151
  return 0
55
152
  fi
56
153
 
@@ -59,13 +156,36 @@ tribunal_gate_decide() {
59
156
  # retries with a fresh Actor pass.
60
157
  if awk -v d="$dissent_score" -v t="$dissent_threshold" 'BEGIN { exit !(d > t) }' \
61
158
  && [[ -z "$meta_override" || "$meta_override" == "re-evaluate" ]]; then
62
- printf '{"passed":false,"reason":"dissent_unresolved"}'
159
+ printf '{"passed":false,"reason":"dissent_unresolved"%s}' "$floor_suffix"
63
160
  return 0
64
161
  fi
65
162
 
163
+ # A verdict tribunal_aggregate refuses to score cannot vote to approve, but
164
+ # its SEAT STILL COUNTS. The usability test below is the same one
165
+ # _tribunal_usable_verdicts applies in tribunal-aggregate.sh — a numeric
166
+ # score inside [0,1], so a judge emitting 95 instead of 0.95 is as unusable
167
+ # as one that emitted nothing. The two copies must stay in step, and
168
+ # "usable verdicts agree with tribunal_aggregate" in the bats file is what
169
+ # catches them drifting.
170
+ #
171
+ # Denying the vote rather than dropping the verdict is deliberate, and it
172
+ # is NOT what ecosystem-y7y originally asked for. Making both halves agree
173
+ # on panel membership sounds right and loosens the gate: dropping shrinks
174
+ # the panel, and a shrunken panel is a trivially satisfied majority. One
175
+ # real approval beside two verdicts that never arrived would become a
176
+ # 1-of-1 pass, and strict would clear on a panel that never fully reported
177
+ # — the hazard librarian's judge-type check exists to prevent. Measured
178
+ # across the five reachable cases, dropping fixed one and loosened three.
179
+ #
180
+ # A judge that failed to return a verdict did not leave the panel; it
181
+ # failed. So majority still needs a majority of everyone empaneled, and
182
+ # strict still needs all of them.
66
183
  local count passed_count
67
184
  count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
68
- passed_count=$(printf '%s' "$verdicts" | jq '[.[] | select(.passed == true)] | length' 2>/dev/null) || passed_count=0
185
+ passed_count=$(printf '%s' "$verdicts" | jq '
186
+ def usable: (.score | type) == "number" and .score >= 0 and .score <= 1;
187
+ [.[] | select(.passed == true and usable)] | length
188
+ ' 2>/dev/null) || passed_count=0
69
189
 
70
190
  local jury_ok=1 # 0 = ok, 1 = not ok (shell convention)
71
191
  case "$policy" in
@@ -91,21 +211,80 @@ tribunal_gate_decide() {
91
211
  local score_ok=1
92
212
  awk -v s="$aggregated_score" -v t="$score_threshold" 'BEGIN { exit !(s >= t) }' && score_ok=0
93
213
 
94
- if [[ "$jury_ok" -eq 0 && "$score_ok" -eq 0 ]]; then
95
- printf '{"passed":true}'
96
- return 0
214
+ # A floor nobody scored is a silent hole in the rubric, so say so. The gate
215
+ # does not block on it — see _tribunal_floor_failed for why absence is not a
216
+ # zero — but tribunal_aggregate has already refused to renormalize around it,
217
+ # so the aggregate this gate just judged is the panel's plain mean.
218
+ #
219
+ # ABSENCE ONLY: this names criteria nobody reported, not ones reported
220
+ # unreadably. Those now violate the floor and block, so naming them here
221
+ # would print "their min_pass floors did not apply" about a floor that did
222
+ # apply and did block. The two also cannot share a predicate: conflating
223
+ # them meant the sole signal for "a floor got skipped" was emitted by the
224
+ # very defect that skipped it, which is how this stayed quiet.
225
+ local unscored_floors any_scored
226
+ any_scored=$(printf '%s' "$verdicts" | jq -r '
227
+ [.[] | select((.criterion_scores | type) == "object")
228
+ | select((.criterion_scores | length) > 0)] | length > 0
229
+ ' 2>/dev/null) || any_scored="false"
230
+
231
+ if [[ "$any_scored" == "true" ]]; then
232
+ unscored_floors=$(printf '%s' "$verdicts" | jq -r --argjson rubric "$rubric" '
233
+ . as $v
234
+ | [ ($rubric.criteria // [])[]
235
+ | select((.name | type) == "string" and (.min_pass | type) == "number")
236
+ | . as $c
237
+ | select([ $v[]
238
+ | select((.criterion_scores | type) == "object")
239
+ | select(.criterion_scores | has($c.name))
240
+ | .criterion_scores[$c.name] ] | length == 0)
241
+ | $c.name ]
242
+ | join(", ")
243
+ ' 2>/dev/null) || unscored_floors=""
244
+ if [[ -n "$unscored_floors" ]]; then
245
+ printf 'tribunal-gate: no judge scored these criteria, so their min_pass floors did not apply: %s\n' \
246
+ "$unscored_floors" >&2
247
+ fi
97
248
  fi
98
249
 
99
- # Pick the most informative blocking reason.
250
+ # Pick the most informative blocking reason. Every arm here blocks — this
251
+ # chain only decides what the Actor is TOLD, never whether the work passes.
252
+ # Keeping those two apart is the whole basis for the order:
253
+ #
254
+ # low_score — the aggregate missed the threshold, which is more
255
+ # actionable than any single criterion.
256
+ # jury arms — WHY the panel failed. A Meta-Judge rejection is
257
+ # the more useful thing to hand back on retry than
258
+ # which criterion sat low, and the floor still rides
259
+ # along as failed_criterion.
260
+ # criterion_floor — no jury complaint, so the floor IS the complaint.
261
+ #
262
+ # The jury arms sit ahead of criterion_floor deliberately (ecosystem-4d3),
263
+ # matching librarian's shipped decision — "lesson gate: jury policy still
264
+ # wins over criterion_floor". This does not weaken floors: a violated floor
265
+ # still blocks on the arm below, and `meta_override: accept` still cannot
266
+ # lift one, because that veto lives further up and returns before any of
267
+ # this. What changed is only that a floor no longer HIDES a rejection.
268
+ #
269
+ # Ordering it the other way is what made floor_suffix on the jury arms dead
270
+ # code (ecosystem-cs8): behind `elif [[ -n $floor_failed ]]`, floor_failed
271
+ # was guaranteed empty by the time those arms ran, so the suffix always
272
+ # rendered as the empty string while two shipped docs described it working.
273
+ #
274
+ # The tests under "blocking-reason precedence" pin every arm. Nothing pinned
275
+ # them before, which is how a new arm went in ahead of two others under a
276
+ # comment asserting the precedence was unchanged.
100
277
  if [[ "$score_ok" -ne 0 ]]; then
101
- printf '{"passed":false,"reason":"low_score"}'
102
- else
103
- # Jury did not pass even though score cleared threshold — surface as
104
- # meta_override when meta said reject, else dissent_unresolved.
278
+ printf '{"passed":false,"reason":"low_score"%s}' "$floor_suffix"
279
+ elif [[ "$jury_ok" -ne 0 ]]; then
105
280
  if [[ "$meta_override" == "reject" ]]; then
106
- printf '{"passed":false,"reason":"meta_override"}'
281
+ printf '{"passed":false,"reason":"meta_override"%s}' "$floor_suffix"
107
282
  else
108
- printf '{"passed":false,"reason":"dissent_unresolved"}'
283
+ printf '{"passed":false,"reason":"dissent_unresolved"%s}' "$floor_suffix"
109
284
  fi
285
+ elif [[ -n "$floor_failed" ]]; then
286
+ printf '{"passed":false,"reason":"criterion_floor","failed_criterion":"%s"}' "$floor_failed"
287
+ else
288
+ printf '{"passed":true}'
110
289
  fi
111
290
  }
@@ -117,9 +117,11 @@ For `iteration_number` from `0` while `iteration_number < max_iterations`:
117
117
  7. **Gate.**
118
118
  ```bash
119
119
  policy=$(printf '%s' "$rubric" | jq -r '.gate_policy // "majority"')
120
- gate=$(tribunal_gate_decide "$policy" "$verdicts" "$aggregated" "$threshold" "$meta" "$dissent" "$dissent_threshold")
120
+ gate=$(tribunal_gate_decide "$policy" "$verdicts" "$aggregated" "$threshold" "$meta" "$dissent" "$dissent_threshold" "$rubric")
121
121
  ```
122
- If `gate.passed == true`, emit `tribunal.gate.passed` with `final_score: aggregated` and break the loop with outcome `accepted`. Otherwise emit `tribunal.gate.blocked` with the `reason`, `will_retry: (iteration_number + 1 < max_iterations)`, and `retry_iteration_number` if retrying. Persist `gate.json` either way.
122
+ If `gate.passed == true`, emit `tribunal.gate.passed` with `final_score: aggregated` and break the loop with outcome `accepted`. Otherwise emit `tribunal.gate.blocked` with the `reason`, `will_retry: (iteration_number + 1 < max_iterations)`, and `retry_iteration_number` if retrying. **Whenever `gate.failed_criterion` is present, copy it onto the event payload as `failed_criterion` — whatever the `reason` is.** The gate names the criterion on **every** blocking arm, not only when `criterion_floor` won the precedence contest: a criterion scored far below its floor also drags the aggregate under `score_threshold`, so the *worst* failures surface as `low_score`. Copying it only on `criterion_floor` drops the diagnostic precisely where it matters most. That now holds for the Meta-Judge's short-circuit returns too (`bias_detected`, and the early `meta_override` / `dissent_unresolved` arms), so an absent `failed_criterion` means no floor was violated — one reason string never reports two different ways depending on which return produced it. Persist `gate.json` either way.
123
+
124
+ A `criterion_floor` block means the aggregate *cleared* its threshold and one criterion still failed its floor. Say so when you report it: "blocked on `safety` (0.30 < 0.80) despite an overall 0.82" is actionable, "blocked" is not.
123
125
 
124
126
  If blocking and retrying, build the retry digest (lowest-scoring criteria + meta override + dissent summary) and feed it into the next iteration's Actor prompt.
125
127
 
@@ -146,6 +148,6 @@ Keep the summary terse. The artifacts on disk are the long form.
146
148
 
147
149
  ## Error handling
148
150
 
149
- - If a judge subagent fails to return parseable JSON, treat that judge as `score: 0, passed: false, confidence: 0` and surface the parse error in `feedback_summary`. Do not abort the iteration — let the gate decide.
151
+ - If a judge subagent fails to return parseable JSON, treat that judge as `score: 0, passed: false, confidence: 0` **and give it a `criterion_scores` object assigning `0` to every criterion in the active rubric.** Surface the parse error in `feedback_summary`. Do not abort the iteration — let the gate decide. A crashed judge was dispatched and produced nothing — that is the "assessed and failed" case, not the "did not assess" case. Omitting `criterion_scores` (or sending `{}`) would make the crash invisible to `weighted_mean`, which never reads `.score`, and the panel would score as though the judge had never been empaneled.
150
152
  - If the Meta-Judge fails, default to `verdict_quality: "questionable", bias_detected: false` so the gate falls back to score-based logic.
151
153
  - If event emission fails (schema validation), keep going and write a warning to stderr. The persisted artifacts on disk are still trustworthy.
@@ -148,7 +148,12 @@ prompt_rules_pattern_matches() {
148
148
  prompt_rules_emit() {
149
149
  local session_id="${1:-unknown}"
150
150
  local event_type="${2:-}"
151
- local payload_json="${3:-{\}}"
151
+ # Default the payload without inlining braces in the expansion. Both
152
+ # `${3:-{\}}` and `${3:-{}}` are wrong: the first keeps the backslash on
153
+ # bash 3.2 (macOS system bash), and the second appends a stray `}` to any
154
+ # payload that IS supplied, on every bash version.
155
+ local payload_json="${3:-}"
156
+ [ -z "$payload_json" ] && payload_json='{}'
152
157
  [[ -z "$event_type" ]] && return 1
153
158
  ensure_file_exists "$ONLOOKER_EVENTS_LOG" || return 1
154
159
 
@@ -46,7 +46,7 @@ setup() {
46
46
  echo "$output" | jq -e '.hookSpecificOutput.hookEventName == "SessionStart"' >/dev/null
47
47
  local ctx
48
48
  ctx=$(echo "$output" | jq -r '.hookSpecificOutput.additionalContext')
49
- [[ "$ctx" == *"use git remote SHA256 as project key"* ]]
49
+ [[ "$ctx" == *"use git remote SHA256 as project key"* ]] || return 1
50
50
  [[ "$ctx" == *"Archivist injected 1"* ]]
51
51
  }
52
52
 
@@ -70,7 +70,7 @@ setup() {
70
70
  @test "extraction prompt includes the message and the JSON contract" {
71
71
  run assayer_build_extraction_prompt "I ran the tests and they pass." 5
72
72
  [ "$status" -eq 0 ]
73
- [[ "$output" == *"I ran the tests and they pass."* ]]
74
- [[ "$output" == *"TESTABLE SUCCESS CLAIM"* ]]
73
+ [[ "$output" == *"I ran the tests and they pass."* ]] || return 1
74
+ [[ "$output" == *"TESTABLE SUCCESS CLAIM"* ]] || return 1
75
75
  [[ "$output" == *"at most 5 claims"* ]]
76
76
  }
@@ -68,8 +68,8 @@ _run_hook() {
68
68
  _seed_ledger
69
69
  _run_hook
70
70
  [ "$status" -eq 0 ]
71
- [[ "$output" == *"hookSpecificOutput"* ]]
72
- [[ "$output" == *"SessionStart"* ]]
71
+ [[ "$output" == *"hookSpecificOutput"* ]] || return 1
72
+ [[ "$output" == *"SessionStart"* ]] || return 1
73
73
  [[ "$output" == *"burned \$0.42"* ]]
74
74
  }
75
75
 
@@ -85,7 +85,7 @@ _run_hook() {
85
85
  > "${dir}/sessions.jsonl"
86
86
  _run_hook
87
87
  [ "$status" -eq 0 ]
88
- [[ "$output" == *"burned \$0.00"* ]]
88
+ [[ "$output" == *"burned \$0.00"* ]] || return 1
89
89
  [[ "$output" != *"Enable governor"* ]]
90
90
  }
91
91
 
@@ -80,3 +80,46 @@ setup() {
80
80
  echo "$v" | jq -e 'type == "array" and length > 0' >/dev/null
81
81
  echo "$v" | jq -e 'any(. == "node_modules")' >/dev/null
82
82
  }
83
+
84
+ @test "undocumented_entity: defaults ship enabled with plugin and skill globs" {
85
+ cartographer_config_load ""
86
+ local enabled globs max
87
+ enabled=$(cartographer_config_undocumented_enabled)
88
+ globs=$(cartographer_config_undocumented_globs)
89
+ max=$(cartographer_config_undocumented_max_findings)
90
+ [ "$enabled" = "true" ]
91
+ [ "$max" = "20" ]
92
+ [ "$(printf '%s' "$globs" | jq -r 'length')" = "2" ] || return 1
93
+ [ "$(printf '%s' "$globs" | jq -r '.[0]')" = "plugins/*/" ]
94
+ }
95
+
96
+ @test "undocumented_entity: user settings can disable the phase" {
97
+ mkdir -p "${HOME}/.claude"
98
+ printf '%s\n' '{"cartographer":{"undocumented_entity":{"enabled":false}}}' \
99
+ > "${HOME}/.claude/settings.json"
100
+ cartographer_config_load ""
101
+ local enabled max
102
+ enabled=$(cartographer_config_undocumented_enabled)
103
+ max=$(cartographer_config_undocumented_max_findings)
104
+ [ "$enabled" = "false" ]
105
+ [ "$max" = "20" ]
106
+ }
107
+
108
+ @test "undocumented_entity: repo settings replace the glob list wholesale" {
109
+ local repo="${BATS_TEST_TMPDIR}/repo-glob"
110
+ mkdir -p "${repo}/.claude"
111
+ printf '%s\n' '{"cartographer":{"undocumented_entity":{"globs":["agents/*/"]}}}' \
112
+ > "${repo}/.claude/settings.json"
113
+ cartographer_config_load "$repo"
114
+ local globs
115
+ globs=$(cartographer_config_undocumented_globs)
116
+ [ "$(printf '%s' "$globs" | jq -r 'length')" = "1" ] || return 1
117
+ [ "$(printf '%s' "$globs" | jq -r '.[0]')" = "agents/*/" ]
118
+ }
119
+
120
+ @test "undocumented_entity: exclude defaults to empty" {
121
+ cartographer_config_load ""
122
+ local excl
123
+ excl=$(cartographer_config_undocumented_exclude)
124
+ [ "$(printf '%s' "$excl" | jq -r 'length')" = "0" ]
125
+ }