@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,1232 @@
|
|
|
1
|
+
# Lesson Judging Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Give librarian a human-invoked jury that judges confirmed lesson candidates against a visibility-scoped rubric and records an `approved` or `rejected` verdict on each proposal.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The agent orchestrates and bash decides. A new `/librarian lessons judge` route gathers confirmed candidates, dispatches tribunal's judge subagents per candidate, and hands the raw verdicts to bash, which selects the rubric by visibility, aggregates, gates, and writes the result. Everything except subagent dispatch lives in testable shell.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** bash (macOS bash 3.2 compatible), `jq`, bats, `awk` for float comparison.
|
|
10
|
+
|
|
11
|
+
## Deviation from the spec, and why
|
|
12
|
+
|
|
13
|
+
The spec says the skill "parses their verdicts, aggregates and gates" and gives the
|
|
14
|
+
lib only the write (`librarian_lesson_record_verdict <key> <lesson_id> <verdict_json>`).
|
|
15
|
+
|
|
16
|
+
**This plan moves aggregation and gating into bash instead**, and the lib entry
|
|
17
|
+
point becomes `librarian_lesson_judge <key> <lesson_id> <verdicts_json>`.
|
|
18
|
+
|
|
19
|
+
The reason is the spec's own Testing section. It requires asserting that "a public
|
|
20
|
+
candidate that one judge blocks is rejected even when the aggregate clears
|
|
21
|
+
`score_threshold`" — that is gate behavior. Gate logic living in SKILL.md prose
|
|
22
|
+
cannot be exercised by bats at all, so the spec's central test would be
|
|
23
|
+
unwritable. The division of labor the spec argues for (agent orchestrates, bash
|
|
24
|
+
does the rest) is preserved; the seam simply sits one step later.
|
|
25
|
+
|
|
26
|
+
## Global Constraints
|
|
27
|
+
|
|
28
|
+
- Runtime artifacts go under `$ONLOOKER_DIR`; never a hardcoded `~/.onlooker`.
|
|
29
|
+
- Project key via `librarian_project_key <repo-root>` from `librarian-project-key.sh`. Never recompute the SHA.
|
|
30
|
+
- **No event emission anywhere in this stage.** `@onlooker-community/schema` 2.11.0 registers only `meridian.lesson.curated`; the emitter exits 1 on an unknown `event_type`.
|
|
31
|
+
- **Do not source any file under `plugins/tribunal/`.** The ADR in Task 5 licenses reusing tribunal's *agent definitions* by name, not its bash. Librarian implements its own aggregate and gate.
|
|
32
|
+
- Bash 3.2 compatible: no associative arrays, no `${var^^}`, no `mapfile`.
|
|
33
|
+
- bats runs under macOS system bash 3.2, where a failing **non-final** `[[ ]]` does NOT fail the test. Every non-final `[[ ]]` assertion needs `|| return 1`. Single-bracket `[ ]` gates on its own.
|
|
34
|
+
- **Assert on messages, not just exit codes.** Two vacuous tests already shipped on the `si6` branch because a non-zero exit was produced by an unrelated guard. If a test asserts a refusal, pin the refusal's text.
|
|
35
|
+
- Every new bats assertion must be broken once to confirm it discriminates. Do fault injection in a **fresh `git worktree`**, never the shared working tree.
|
|
36
|
+
- American English in all comments, messages, and commits.
|
|
37
|
+
- Commit via the `/commit` skill contract: `<type>(<scope>): <subject> :emoji:`, subject ≤72 chars including the emoji, why-focused body.
|
|
38
|
+
|
|
39
|
+
## File Structure
|
|
40
|
+
|
|
41
|
+
| File | Responsibility |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `plugins/librarian/config.json` | **Modify.** Add `librarian.lesson_judging.rubrics` — the two builtins. |
|
|
44
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-rubric.sh` | **Create.** Load a rubric by id; map a visibility to its rubric id. |
|
|
45
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-judge.sh` | **Create.** Aggregate verdicts, apply the gate, write the result. The heart of the stage. |
|
|
46
|
+
| `plugins/librarian/scripts/lib/librarian-cli.sh` | **Modify.** Add the `judge` verb and `list --confirmed --json`. |
|
|
47
|
+
| `plugins/librarian/skills/librarian/SKILL.md` | **Modify.** Add the `/librarian lessons judge` route. |
|
|
48
|
+
| `plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md` | **Create.** Record the cross-plugin reading. |
|
|
49
|
+
| `test/bats/librarian-lesson-judge.bats` | **Create.** Rubric, aggregate, gate, and record tests. |
|
|
50
|
+
| `test/bats/librarian-lesson-review.bats` | **Modify.** Assert `unconfirm` refuses `approved` and `rejected`. |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
### Task 1: The two rubrics and their loader
|
|
55
|
+
|
|
56
|
+
**Files:**
|
|
57
|
+
- Modify: `plugins/librarian/config.json`
|
|
58
|
+
- Create: `plugins/librarian/scripts/lib/librarian-lesson-rubric.sh`
|
|
59
|
+
- Test: `test/bats/librarian-lesson-judge.bats`
|
|
60
|
+
|
|
61
|
+
**Interfaces:**
|
|
62
|
+
- Consumes: `librarian_config_load <repo_root>` and `librarian_config_get <jq-path>` from `librarian-config.sh`.
|
|
63
|
+
- Produces:
|
|
64
|
+
- `librarian_lesson_rubric_id_for_visibility <visibility>` → echoes `lesson-promotion` for `org`, `lesson-promotion-public` for `public`, empty string for `private`; returns 1 for anything else.
|
|
65
|
+
- `librarian_lesson_rubric_get <rubric_id>` → echoes the rubric object as compact JSON; returns 1 and echoes nothing if unknown.
|
|
66
|
+
|
|
67
|
+
- [ ] **Step 1: Write the failing tests**
|
|
68
|
+
|
|
69
|
+
Create `test/bats/librarian-lesson-judge.bats`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
#!/usr/bin/env bats
|
|
73
|
+
|
|
74
|
+
setup() {
|
|
75
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
76
|
+
setup_test_env
|
|
77
|
+
|
|
78
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
|
|
79
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
80
|
+
export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
|
|
81
|
+
|
|
82
|
+
PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
|
|
83
|
+
mkdir -p "$PROJECT_REPO"
|
|
84
|
+
git -C "$PROJECT_REPO" init -q
|
|
85
|
+
git -C "$PROJECT_REPO" config user.email t@example.com
|
|
86
|
+
git -C "$PROJECT_REPO" config user.name "Test"
|
|
87
|
+
git -C "$PROJECT_REPO" remote add origin git@github.com:org/fixture.git
|
|
88
|
+
|
|
89
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
|
|
90
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
|
|
91
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-rubric.sh"
|
|
92
|
+
librarian_config_load "$PROJECT_REPO"
|
|
93
|
+
|
|
94
|
+
PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@test "org visibility selects the lesson-promotion rubric" {
|
|
98
|
+
run librarian_lesson_rubric_id_for_visibility "org"
|
|
99
|
+
[ "$status" -eq 0 ]
|
|
100
|
+
[ "$output" = "lesson-promotion" ]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@test "public visibility selects the public rubric" {
|
|
104
|
+
run librarian_lesson_rubric_id_for_visibility "public"
|
|
105
|
+
[ "$status" -eq 0 ]
|
|
106
|
+
[ "$output" = "lesson-promotion-public" ]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@test "private visibility selects no rubric" {
|
|
110
|
+
run librarian_lesson_rubric_id_for_visibility "private"
|
|
111
|
+
[ "$status" -eq 0 ]
|
|
112
|
+
[ "$output" = "" ]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@test "an unknown visibility is refused" {
|
|
116
|
+
run librarian_lesson_rubric_id_for_visibility "everyone"
|
|
117
|
+
[ "$status" -ne 0 ]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@test "the org rubric gates on majority and the public rubric on unanimous" {
|
|
121
|
+
local org public
|
|
122
|
+
org=$(librarian_lesson_rubric_get "lesson-promotion")
|
|
123
|
+
public=$(librarian_lesson_rubric_get "lesson-promotion-public")
|
|
124
|
+
[ "$(printf '%s' "$org" | jq -r '.gate_policy')" = "majority" ]
|
|
125
|
+
[ "$(printf '%s' "$public" | jq -r '.gate_policy')" = "unanimous" ]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@test "both rubrics carry a 0.75 score threshold and two judge types" {
|
|
129
|
+
local r
|
|
130
|
+
for r in lesson-promotion lesson-promotion-public; do
|
|
131
|
+
local got
|
|
132
|
+
got=$(librarian_lesson_rubric_get "$r")
|
|
133
|
+
[ "$(printf '%s' "$got" | jq -r '.score_threshold')" = "0.75" ]
|
|
134
|
+
[ "$(printf '%s' "$got" | jq -c '.judge_types')" = '["standard","adversarial"]' ]
|
|
135
|
+
done
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@test "neither rubric carries a max_iterations knob" {
|
|
139
|
+
# There is no Actor in this pipeline, so a retry setting would be a knob
|
|
140
|
+
# that cannot do anything. See the spec's "There is no Actor" section.
|
|
141
|
+
local r
|
|
142
|
+
for r in lesson-promotion lesson-promotion-public; do
|
|
143
|
+
local got
|
|
144
|
+
got=$(librarian_lesson_rubric_get "$r")
|
|
145
|
+
[ "$(printf '%s' "$got" | jq 'has("max_iterations")')" = "false" ]
|
|
146
|
+
done
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@test "each rubric's criterion weights sum to exactly 1.00" {
|
|
150
|
+
# Tribunal validates each weight in [0,1] but never their total. An
|
|
151
|
+
# unnormalized set would silently mis-score the moment ecosystem-pht
|
|
152
|
+
# implements real weighted_mean.
|
|
153
|
+
local r
|
|
154
|
+
for r in lesson-promotion lesson-promotion-public; do
|
|
155
|
+
local sum
|
|
156
|
+
sum=$(librarian_lesson_rubric_get "$r" | jq '[.criteria[].weight] | add | . * 100 | round')
|
|
157
|
+
[ "$sum" -eq 100 ]
|
|
158
|
+
done
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@test "only the public rubric carries the disclosure criterion" {
|
|
162
|
+
local org public
|
|
163
|
+
org=$(librarian_lesson_rubric_get "lesson-promotion" | jq -c '[.criteria[].name]')
|
|
164
|
+
public=$(librarian_lesson_rubric_get "lesson-promotion-public" | jq -c '[.criteria[].name]')
|
|
165
|
+
[ "$org" = '["grounding","scope_accuracy","generality"]' ]
|
|
166
|
+
[ "$public" = '["grounding","scope_accuracy","generality","disclosure"]' ]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
@test "disclosure carries the highest floor of any criterion" {
|
|
170
|
+
local floor
|
|
171
|
+
floor=$(librarian_lesson_rubric_get "lesson-promotion-public" \
|
|
172
|
+
| jq '.criteria[] | select(.name == "disclosure") | .min_pass')
|
|
173
|
+
[ "$floor" = "0.9" ]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@test "an unknown rubric id is refused and echoes nothing" {
|
|
177
|
+
run librarian_lesson_rubric_get "no-such-rubric"
|
|
178
|
+
[ "$status" -ne 0 ]
|
|
179
|
+
[ "$output" = "" ]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
184
|
+
|
|
185
|
+
Run: `bats test/bats/librarian-lesson-judge.bats`
|
|
186
|
+
Expected: every test FAILS — `librarian-lesson-rubric.sh` does not exist, so `source` in `setup()` errors.
|
|
187
|
+
|
|
188
|
+
- [ ] **Step 3: Add the rubrics to config.json**
|
|
189
|
+
|
|
190
|
+
Add a `lesson_judging` key inside the existing `librarian` object in
|
|
191
|
+
`plugins/librarian/config.json`, as a sibling of `lesson_transform`.
|
|
192
|
+
|
|
193
|
+
**Use these weights verbatim. Do not recompute them.** The public set is the org
|
|
194
|
+
set scaled to 70% with disclosure taking the remaining 30%, rounded *to preserve
|
|
195
|
+
the sum*: `0.35 × 0.7 = 0.245` is written `0.24`, because rounding it to `0.25`
|
|
196
|
+
totals 1.01.
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
"lesson_judging": {
|
|
200
|
+
"rubrics": [
|
|
201
|
+
{
|
|
202
|
+
"id": "lesson-promotion",
|
|
203
|
+
"criteria": [
|
|
204
|
+
{ "name": "grounding", "weight": 0.45, "min_pass": 0.7 },
|
|
205
|
+
{ "name": "scope_accuracy", "weight": 0.35, "min_pass": 0.7 },
|
|
206
|
+
{ "name": "generality", "weight": 0.20, "min_pass": 0.6 }
|
|
207
|
+
],
|
|
208
|
+
"score_threshold": 0.75,
|
|
209
|
+
"judge_types": ["standard", "adversarial"],
|
|
210
|
+
"gate_policy": "majority"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"id": "lesson-promotion-public",
|
|
214
|
+
"criteria": [
|
|
215
|
+
{ "name": "grounding", "weight": 0.32, "min_pass": 0.7 },
|
|
216
|
+
{ "name": "scope_accuracy", "weight": 0.24, "min_pass": 0.7 },
|
|
217
|
+
{ "name": "generality", "weight": 0.14, "min_pass": 0.6 },
|
|
218
|
+
{ "name": "disclosure", "weight": 0.30, "min_pass": 0.9 }
|
|
219
|
+
],
|
|
220
|
+
"score_threshold": 0.75,
|
|
221
|
+
"judge_types": ["standard", "adversarial"],
|
|
222
|
+
"gate_policy": "unanimous"
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- [ ] **Step 4: Write the rubric loader**
|
|
229
|
+
|
|
230
|
+
Create `plugins/librarian/scripts/lib/librarian-lesson-rubric.sh`:
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
#!/usr/bin/env bash
|
|
234
|
+
# Rubric selection for lesson judging.
|
|
235
|
+
#
|
|
236
|
+
# Two builtins live in config.json under librarian.lesson_judging.rubrics,
|
|
237
|
+
# mirroring tribunal's rubric.builtins shape so they stay legible to anyone who
|
|
238
|
+
# knows tribunal. Librarian loads them itself rather than sourcing tribunal's
|
|
239
|
+
# lib — see docs/adr/002-agent-definitions-are-shared-assets.md.
|
|
240
|
+
#
|
|
241
|
+
# The per-criterion weights and min_pass floors are DECLARED BUT INERT today:
|
|
242
|
+
# tribunal_aggregate discards the rubric it is handed, and no per-criterion
|
|
243
|
+
# score reaches any gate. They are the honest statement of intent and go live
|
|
244
|
+
# unchanged when ecosystem-pht lands. Nothing here may depend on them.
|
|
245
|
+
#
|
|
246
|
+
# Exposes:
|
|
247
|
+
# librarian_lesson_rubric_id_for_visibility <visibility>
|
|
248
|
+
# librarian_lesson_rubric_get <rubric_id>
|
|
249
|
+
|
|
250
|
+
# Map a confirmed lesson's visibility to the rubric that judges it.
|
|
251
|
+
#
|
|
252
|
+
# `private` maps to the empty string on purpose: that tier runs no jury at all,
|
|
253
|
+
# which is what makes cost scale with intent rather than artifact volume.
|
|
254
|
+
#
|
|
255
|
+
# Usage: librarian_lesson_rubric_id_for_visibility <visibility>
|
|
256
|
+
librarian_lesson_rubric_id_for_visibility() {
|
|
257
|
+
case "${1:-}" in
|
|
258
|
+
private) printf '' ;;
|
|
259
|
+
org) printf 'lesson-promotion' ;;
|
|
260
|
+
public) printf 'lesson-promotion-public' ;;
|
|
261
|
+
*) return 1 ;;
|
|
262
|
+
esac
|
|
263
|
+
return 0
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
# Echo one rubric as compact JSON. Returns 1 and echoes nothing if unknown.
|
|
267
|
+
#
|
|
268
|
+
# Usage: librarian_lesson_rubric_get <rubric_id>
|
|
269
|
+
librarian_lesson_rubric_get() {
|
|
270
|
+
local rubric_id="${1:-}"
|
|
271
|
+
[[ -z "$rubric_id" ]] && return 1
|
|
272
|
+
|
|
273
|
+
local rubrics found
|
|
274
|
+
rubrics=$(librarian_config_get '.librarian.lesson_judging.rubrics')
|
|
275
|
+
[[ -z "$rubrics" || "$rubrics" == "null" ]] && return 1
|
|
276
|
+
|
|
277
|
+
found=$(printf '%s' "$rubrics" | jq -c --arg id "$rubric_id" \
|
|
278
|
+
'map(select(.id == $id)) | first // empty' 2>/dev/null) || return 1
|
|
279
|
+
[[ -z "$found" || "$found" == "null" ]] && return 1
|
|
280
|
+
|
|
281
|
+
printf '%s' "$found"
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
- [ ] **Step 5: Run the tests to verify they pass**
|
|
286
|
+
|
|
287
|
+
Run: `bats test/bats/librarian-lesson-judge.bats`
|
|
288
|
+
Expected: PASS, 11/11.
|
|
289
|
+
|
|
290
|
+
If `librarian_config_get` returns the rubrics array as a string rather than
|
|
291
|
+
JSON, check how `config_get` in `scripts/lib/config-loader.sh` handles array
|
|
292
|
+
values and adjust the `jq` invocation — do not work around it by re-reading
|
|
293
|
+
`config.json` directly, which would skip the settings overlay layers.
|
|
294
|
+
|
|
295
|
+
- [ ] **Step 6: Prove the weight-sum test discriminates**
|
|
296
|
+
|
|
297
|
+
In a throwaway worktree only:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
git worktree add /tmp/lj-verify HEAD
|
|
301
|
+
# In /tmp/lj-verify, change the public rubric's scope_accuracy weight 0.24 -> 0.25
|
|
302
|
+
bats test/bats/librarian-lesson-judge.bats
|
|
303
|
+
# Expected: "each rubric's criterion weights sum to exactly 1.00" FAILS
|
|
304
|
+
git worktree remove --force /tmp/lj-verify
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
- [ ] **Step 7: Run lint and commit**
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
npm run lint:check
|
|
311
|
+
shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-rubric.sh
|
|
312
|
+
git add plugins/librarian/config.json \
|
|
313
|
+
plugins/librarian/scripts/lib/librarian-lesson-rubric.sh \
|
|
314
|
+
test/bats/librarian-lesson-judge.bats
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Commit subject: `feat(librarian): add the two lesson-promotion rubrics :scales:`
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
### Task 2: Aggregate, gate, and record the verdict
|
|
322
|
+
|
|
323
|
+
**Files:**
|
|
324
|
+
- Create: `plugins/librarian/scripts/lib/librarian-lesson-judge.sh`
|
|
325
|
+
- Test: `test/bats/librarian-lesson-judge.bats` (append)
|
|
326
|
+
|
|
327
|
+
**Interfaces:**
|
|
328
|
+
- Consumes: `librarian_lesson_rubric_id_for_visibility`, `librarian_lesson_rubric_get` (Task 1); `librarian_lessons_dir <key>` from `librarian-lesson-storage.sh`.
|
|
329
|
+
- Produces:
|
|
330
|
+
- `librarian_lesson_aggregate <verdicts_json>` → echoes the mean of `.score`, or empty and returns 1 on an empty array.
|
|
331
|
+
- `librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <score_threshold>` → echoes `{"passed":bool,"reason":string}`.
|
|
332
|
+
- `librarian_lesson_judge <key> <lesson_id> <verdicts_json>` → returns **0** when a verdict was recorded, **1** on a usage/state error, **2** when the candidate is unjudged and nothing was written.
|
|
333
|
+
|
|
334
|
+
**The three return codes are the interface Task 3 and Task 4 depend on.** `2` is
|
|
335
|
+
not a failure — it means "could not judge," which the spec requires be kept
|
|
336
|
+
distinct from "judged and failed."
|
|
337
|
+
|
|
338
|
+
- [ ] **Step 1: Write the failing tests**
|
|
339
|
+
|
|
340
|
+
Append to `test/bats/librarian-lesson-judge.bats`. Add these to `setup()` after
|
|
341
|
+
the existing `source` lines:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
345
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-judge.sh"
|
|
346
|
+
|
|
347
|
+
# A claude stub that fails loudly. Any path asserted to spend no tokens
|
|
348
|
+
# must not invoke it. Same technique that proved stage 5's unavailable
|
|
349
|
+
# path and stage 6's no-model guarantee.
|
|
350
|
+
STUB_BIN="${BATS_TEST_TMPDIR}/bin"
|
|
351
|
+
mkdir -p "$STUB_BIN"
|
|
352
|
+
cat > "${STUB_BIN}/claude" <<'STUB'
|
|
353
|
+
#!/usr/bin/env bash
|
|
354
|
+
echo "claude was invoked but this path must spend no tokens" >&2
|
|
355
|
+
exit 99
|
|
356
|
+
STUB
|
|
357
|
+
chmod +x "${STUB_BIN}/claude"
|
|
358
|
+
export PATH="${STUB_BIN}:${PATH}"
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
And this helper, which seeds a confirmed proposal:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
_seed_confirmed() {
|
|
365
|
+
local id="$1" visibility="$2"
|
|
366
|
+
local dir="$(librarian_lessons_dir "$PROJECT_KEY")/proposals"
|
|
367
|
+
mkdir -p "$dir"
|
|
368
|
+
jq -n --arg id "$id" --arg v "$visibility" \
|
|
369
|
+
--arg t "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
370
|
+
'{ id: $id, artifact_id: "art-\($id)", status: "confirmed",
|
|
371
|
+
visibility: $v, confirmed_at: $t,
|
|
372
|
+
candidate: { claim: "Prefer jq -c for compact output",
|
|
373
|
+
rationale: "Readable diffs",
|
|
374
|
+
evidence: { resolution: "Applied and verified" },
|
|
375
|
+
applies_to: { stack: ["bash"],
|
|
376
|
+
scope: { kind: "versioned", versions: ">=3.2" } } } }' \
|
|
377
|
+
> "${dir}/${id}.json"
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_status_of() {
|
|
381
|
+
jq -r '.status' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/${1}.json"
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# Two judges, both passing, mean 0.85 — clears the 0.75 threshold.
|
|
385
|
+
_verdicts_pass() {
|
|
386
|
+
printf '%s' '[{"judge_type":"standard","score":0.9,"passed":true,"confidence":0.9,"feedback_summary":"Well grounded."},{"judge_type":"adversarial","score":0.8,"passed":true,"confidence":0.8,"feedback_summary":"Holds up."}]'
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# Split panel: mean 0.85 still clears the threshold, but one judge blocks.
|
|
390
|
+
_verdicts_split() {
|
|
391
|
+
printf '%s' '[{"judge_type":"standard","score":0.95,"passed":true,"confidence":0.9,"feedback_summary":"Strong."},{"judge_type":"adversarial","score":0.75,"passed":false,"confidence":0.8,"feedback_summary":"Scope claim is not supported."}]'
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Now the tests:
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
@test "aggregate averages the judges' scores" {
|
|
399
|
+
run librarian_lesson_aggregate "$(_verdicts_pass)"
|
|
400
|
+
[ "$status" -eq 0 ]
|
|
401
|
+
# 0.9 + 0.8 = 1.7 / 2 = 0.85
|
|
402
|
+
[ "$(printf '%s' "$output" | awk '{printf "%.2f", $1}')" = "0.85" ]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
@test "aggregate refuses an empty panel" {
|
|
406
|
+
run librarian_lesson_aggregate '[]'
|
|
407
|
+
[ "$status" -ne 0 ]
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
@test "a unanimous gate blocks when one judge blocks" {
|
|
411
|
+
run librarian_lesson_gate "unanimous" "$(_verdicts_split)" "0.85" "0.75"
|
|
412
|
+
[ "$status" -eq 0 ]
|
|
413
|
+
[ "$(printf '%s' "$output" | jq -r '.passed')" = "false" ]
|
|
414
|
+
[ "$(printf '%s' "$output" | jq -r '.reason')" = "jury_not_unanimous" ]
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
@test "majority and unanimous diverge on a two-of-three panel" {
|
|
418
|
+
# Two judges with one dissenter does NOT clear majority either — it needs
|
|
419
|
+
# strictly more than half. A three-judge panel is the smallest one where
|
|
420
|
+
# the two policies actually disagree, which is what this pins.
|
|
421
|
+
local three='[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.9,"passed":true},{"judge_type":"standard","score":0.75,"passed":false}]'
|
|
422
|
+
run librarian_lesson_gate "majority" "$three" "0.867" "0.75"
|
|
423
|
+
[ "$status" -eq 0 ]
|
|
424
|
+
[ "$(printf '%s' "$output" | jq -r '.passed')" = "true" ]
|
|
425
|
+
|
|
426
|
+
run librarian_lesson_gate "unanimous" "$three" "0.867" "0.75"
|
|
427
|
+
[ "$(printf '%s' "$output" | jq -r '.passed')" = "false" ]
|
|
428
|
+
[ "$(printf '%s' "$output" | jq -r '.reason')" = "jury_not_unanimous" ]
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
@test "a below-threshold aggregate blocks even when every judge passed" {
|
|
432
|
+
local low='[{"judge_type":"standard","score":0.6,"passed":true},{"judge_type":"adversarial","score":0.6,"passed":true}]'
|
|
433
|
+
run librarian_lesson_gate "majority" "$low" "0.6" "0.75"
|
|
434
|
+
[ "$(printf '%s' "$output" | jq -r '.passed')" = "false" ]
|
|
435
|
+
[ "$(printf '%s' "$output" | jq -r '.reason')" = "below_threshold" ]
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
@test "a private candidate is approved with no model call and no judges" {
|
|
439
|
+
_seed_confirmed "priv01" "private"
|
|
440
|
+
run librarian_lesson_judge "$PROJECT_KEY" "priv01" '[]'
|
|
441
|
+
[ "$status" -eq 0 ]
|
|
442
|
+
[ "$(_status_of priv01)" = "approved" ]
|
|
443
|
+
|
|
444
|
+
local v
|
|
445
|
+
v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/priv01.json")
|
|
446
|
+
[ "$(printf '%s' "$v" | jq -r '.reason')" = "private_no_jury" ]
|
|
447
|
+
[ "$(printf '%s' "$v" | jq -c '.judges')" = "[]" ]
|
|
448
|
+
[ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "null" ]
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
@test "an org candidate the jury passes is approved under the org rubric" {
|
|
452
|
+
_seed_confirmed "org01" "org"
|
|
453
|
+
run librarian_lesson_judge "$PROJECT_KEY" "org01" "$(_verdicts_pass)"
|
|
454
|
+
[ "$status" -eq 0 ]
|
|
455
|
+
[ "$(_status_of org01)" = "approved" ]
|
|
456
|
+
|
|
457
|
+
local v
|
|
458
|
+
v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/org01.json")
|
|
459
|
+
[ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "lesson-promotion" ]
|
|
460
|
+
[ "$(printf '%s' "$v" | jq -r '.gate_policy')" = "majority" ]
|
|
461
|
+
[ "$(printf '%s' "$v" | jq '.judges | length')" -eq 2 ]
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
@test "a public candidate one judge blocks is rejected though the aggregate clears" {
|
|
465
|
+
# The whole reason public differs from org. The mean here is 0.85, well
|
|
466
|
+
# above the 0.75 threshold; only the unanimous policy stops it.
|
|
467
|
+
_seed_confirmed "pub01" "public"
|
|
468
|
+
run librarian_lesson_judge "$PROJECT_KEY" "pub01" "$(_verdicts_split)"
|
|
469
|
+
[ "$status" -eq 0 ]
|
|
470
|
+
[ "$(_status_of pub01)" = "rejected" ]
|
|
471
|
+
|
|
472
|
+
local v
|
|
473
|
+
v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/pub01.json")
|
|
474
|
+
[ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "lesson-promotion-public" ]
|
|
475
|
+
[ "$(printf '%s' "$v" | jq -r '.gate_policy')" = "unanimous" ]
|
|
476
|
+
[ "$(printf '%s' "$v" | jq -r '.reason')" = "jury_not_unanimous" ]
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
@test "the same split panel is rejected at org, but for the majority reason" {
|
|
480
|
+
# The verdicts are identical to the public case above. 1 of 2 passing
|
|
481
|
+
# clears neither policy, so both tiers reject — what this pins is that the
|
|
482
|
+
# RUBRIC actually differs by visibility, via the recorded reason and id.
|
|
483
|
+
# Without this, the public test alone would pass even if both visibilities
|
|
484
|
+
# resolved to the same rubric.
|
|
485
|
+
_seed_confirmed "org02" "org"
|
|
486
|
+
run librarian_lesson_judge "$PROJECT_KEY" "org02" "$(_verdicts_split)"
|
|
487
|
+
[ "$status" -eq 0 ]
|
|
488
|
+
[ "$(_status_of org02)" = "rejected" ]
|
|
489
|
+
|
|
490
|
+
local v
|
|
491
|
+
v=$(jq -c '.verdict' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/org02.json")
|
|
492
|
+
[ "$(printf '%s' "$v" | jq -r '.rubric_id')" = "lesson-promotion" ]
|
|
493
|
+
[ "$(printf '%s' "$v" | jq -r '.gate_policy')" = "majority" ]
|
|
494
|
+
[ "$(printf '%s' "$v" | jq -r '.reason')" = "jury_not_majority" ]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
@test "a three-judge panel with one dissenter separates the two tiers" {
|
|
498
|
+
# The case where the tiers genuinely produce different OUTCOMES, not just
|
|
499
|
+
# different reasons. This is the public tier's whole justification.
|
|
500
|
+
local three='[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.9,"passed":true},{"judge_type":"standard","score":0.8,"passed":false}]'
|
|
501
|
+
|
|
502
|
+
_seed_confirmed "tier01" "org"
|
|
503
|
+
run librarian_lesson_judge "$PROJECT_KEY" "tier01" "$three"
|
|
504
|
+
[ "$status" -eq 0 ]
|
|
505
|
+
[ "$(_status_of tier01)" = "approved" ]
|
|
506
|
+
|
|
507
|
+
_seed_confirmed "tier02" "public"
|
|
508
|
+
run librarian_lesson_judge "$PROJECT_KEY" "tier02" "$three"
|
|
509
|
+
[ "$status" -eq 0 ]
|
|
510
|
+
[ "$(_status_of tier02)" = "rejected" ]
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
@test "a rejected proposal keeps its file" {
|
|
514
|
+
# librarian_lesson_seen scans proposals/ by artifact_id; deleting the file
|
|
515
|
+
# would let the same artifact re-propose and re-pay tokens next scan.
|
|
516
|
+
_seed_confirmed "rej01" "public"
|
|
517
|
+
run librarian_lesson_judge "$PROJECT_KEY" "rej01" "$(_verdicts_split)"
|
|
518
|
+
[ -f "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/rej01.json" ]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
@test "a malformed verdict leaves the candidate confirmed and writes nothing" {
|
|
522
|
+
_seed_confirmed "bad01" "org"
|
|
523
|
+
local before
|
|
524
|
+
before=$(cat "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/bad01.json")
|
|
525
|
+
|
|
526
|
+
run librarian_lesson_judge "$PROJECT_KEY" "bad01" '[{"judge_type":"standard","score":"not-a-number","passed":true}]'
|
|
527
|
+
[ "$status" -eq 2 ]
|
|
528
|
+
[ "$(_status_of bad01)" = "confirmed" ]
|
|
529
|
+
[ "$(cat "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/bad01.json")" = "$before" ]
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
@test "unparseable verdict JSON is unjudged, not rejected" {
|
|
533
|
+
_seed_confirmed "bad02" "org"
|
|
534
|
+
run librarian_lesson_judge "$PROJECT_KEY" "bad02" 'this is not json'
|
|
535
|
+
[ "$status" -eq 2 ]
|
|
536
|
+
[ "$(_status_of bad02)" = "confirmed" ]
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
@test "an empty panel on a non-private candidate is unjudged" {
|
|
540
|
+
_seed_confirmed "bad03" "public"
|
|
541
|
+
run librarian_lesson_judge "$PROJECT_KEY" "bad03" '[]'
|
|
542
|
+
[ "$status" -eq 2 ]
|
|
543
|
+
[ "$(_status_of bad03)" = "confirmed" ]
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
@test "judging proceeds only from confirmed" {
|
|
547
|
+
_seed_confirmed "st01" "org"
|
|
548
|
+
local path="$(librarian_lessons_dir "$PROJECT_KEY")/proposals/st01.json"
|
|
549
|
+
local tmp="${BATS_TEST_TMPDIR}/st01.json"
|
|
550
|
+
jq '.status = "pending"' "$path" > "$tmp" && mv "$tmp" "$path"
|
|
551
|
+
|
|
552
|
+
run librarian_lesson_judge "$PROJECT_KEY" "st01" "$(_verdicts_pass)"
|
|
553
|
+
[ "$status" -eq 1 ]
|
|
554
|
+
[[ "$output" == *"pending"* ]] || return 1
|
|
555
|
+
[ "$(_status_of st01)" = "pending" ]
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
@test "re-judging an already-approved candidate is refused, naming the status" {
|
|
559
|
+
_seed_confirmed "st02" "org"
|
|
560
|
+
run librarian_lesson_judge "$PROJECT_KEY" "st02" "$(_verdicts_pass)"
|
|
561
|
+
[ "$status" -eq 0 ]
|
|
562
|
+
|
|
563
|
+
run librarian_lesson_judge "$PROJECT_KEY" "st02" "$(_verdicts_pass)"
|
|
564
|
+
[ "$status" -eq 1 ]
|
|
565
|
+
[[ "$output" == *"approved"* ]] || return 1
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
@test "a missing lesson is refused" {
|
|
569
|
+
run librarian_lesson_judge "$PROJECT_KEY" "nope01" "$(_verdicts_pass)"
|
|
570
|
+
[ "$status" -eq 1 ]
|
|
571
|
+
[[ "$output" == *"not found"* ]] || return 1
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
576
|
+
|
|
577
|
+
Run: `bats test/bats/librarian-lesson-judge.bats`
|
|
578
|
+
Expected: the new tests FAIL — `librarian-lesson-judge.sh` does not exist.
|
|
579
|
+
|
|
580
|
+
- [ ] **Step 3: Write the judge lib**
|
|
581
|
+
|
|
582
|
+
Create `plugins/librarian/scripts/lib/librarian-lesson-judge.sh`:
|
|
583
|
+
|
|
584
|
+
```bash
|
|
585
|
+
#!/usr/bin/env bash
|
|
586
|
+
# Aggregate, gate, and record a jury verdict on a confirmed lesson.
|
|
587
|
+
#
|
|
588
|
+
# The agent orchestrates the jury; this file decides. Everything except
|
|
589
|
+
# subagent dispatch lives here so it can be tested.
|
|
590
|
+
#
|
|
591
|
+
# Librarian implements its own aggregate and gate rather than sourcing
|
|
592
|
+
# tribunal's. Reusing tribunal's published AGENT definitions is licensed by
|
|
593
|
+
# docs/adr/002-agent-definitions-are-shared-assets.md; sourcing its bash would
|
|
594
|
+
# be the hook-to-hook runtime coupling that ADR rules out.
|
|
595
|
+
#
|
|
596
|
+
# Exposes:
|
|
597
|
+
# librarian_lesson_aggregate <verdicts_json>
|
|
598
|
+
# librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <threshold>
|
|
599
|
+
# librarian_lesson_judge <key> <lesson_id> <verdicts_json>
|
|
600
|
+
|
|
601
|
+
# Mean of the judges' scores. Returns 1 on an empty panel.
|
|
602
|
+
#
|
|
603
|
+
# A plain mean, deliberately: per-criterion scores never reach this layer, so
|
|
604
|
+
# there is nothing to weight. See ecosystem-pht.
|
|
605
|
+
#
|
|
606
|
+
# Usage: librarian_lesson_aggregate <verdicts_json>
|
|
607
|
+
librarian_lesson_aggregate() {
|
|
608
|
+
local verdicts="${1:-[]}"
|
|
609
|
+
local n
|
|
610
|
+
n=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || return 1
|
|
611
|
+
[[ -z "$n" || "$n" -eq 0 ]] && return 1
|
|
612
|
+
printf '%s' "$verdicts" | jq -r '[.[].score] | add / length' 2>/dev/null || return 1
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
# Decide pass/block from the panel and the aggregate.
|
|
616
|
+
#
|
|
617
|
+
# Echoes {"passed": bool, "reason": string}. Both conditions must hold: the
|
|
618
|
+
# jury must clear its policy AND the aggregate must clear the threshold.
|
|
619
|
+
#
|
|
620
|
+
# Usage: librarian_lesson_gate <gate_policy> <verdicts_json> <aggregate> <threshold>
|
|
621
|
+
librarian_lesson_gate() {
|
|
622
|
+
local policy="${1:-majority}"
|
|
623
|
+
local verdicts="${2:-[]}"
|
|
624
|
+
local aggregate="${3:-0}"
|
|
625
|
+
local threshold="${4:-0.75}"
|
|
626
|
+
|
|
627
|
+
local count passed_count
|
|
628
|
+
count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
|
|
629
|
+
passed_count=$(printf '%s' "$verdicts" | jq '[.[] | select(.passed == true)] | length' 2>/dev/null) || passed_count=0
|
|
630
|
+
|
|
631
|
+
local jury_ok=1 jury_reason=""
|
|
632
|
+
case "$policy" in
|
|
633
|
+
unanimous)
|
|
634
|
+
if [[ "$count" -gt 0 && "$passed_count" -eq "$count" ]]; then
|
|
635
|
+
jury_ok=0
|
|
636
|
+
else
|
|
637
|
+
jury_reason="jury_not_unanimous"
|
|
638
|
+
fi
|
|
639
|
+
;;
|
|
640
|
+
majority)
|
|
641
|
+
if [[ "$count" -gt 0 ]] && (( passed_count * 2 > count )); then
|
|
642
|
+
jury_ok=0
|
|
643
|
+
else
|
|
644
|
+
jury_reason="jury_not_majority"
|
|
645
|
+
fi
|
|
646
|
+
;;
|
|
647
|
+
*)
|
|
648
|
+
printf '{"passed":false,"reason":"unknown_gate_policy"}'
|
|
649
|
+
return 0
|
|
650
|
+
;;
|
|
651
|
+
esac
|
|
652
|
+
|
|
653
|
+
if [[ "$jury_ok" -ne 0 ]]; then
|
|
654
|
+
printf '{"passed":false,"reason":"%s"}' "$jury_reason"
|
|
655
|
+
return 0
|
|
656
|
+
fi
|
|
657
|
+
|
|
658
|
+
# awk for the float comparison: bash cannot compare decimals.
|
|
659
|
+
if awk -v s="$aggregate" -v t="$threshold" 'BEGIN { exit !(s >= t) }'; then
|
|
660
|
+
printf '{"passed":true,"reason":"gate_passed"}'
|
|
661
|
+
else
|
|
662
|
+
printf '{"passed":false,"reason":"below_threshold"}'
|
|
663
|
+
fi
|
|
664
|
+
return 0
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
# Judge one confirmed lesson and record the outcome.
|
|
668
|
+
#
|
|
669
|
+
# Return codes are the interface the CLI and skill depend on:
|
|
670
|
+
# 0 a verdict was recorded (status is now approved or rejected)
|
|
671
|
+
# 1 usage or state error; nothing written
|
|
672
|
+
# 2 UNJUDGED — the panel was unusable; nothing written, lesson stays
|
|
673
|
+
# confirmed, and the next run retries it
|
|
674
|
+
#
|
|
675
|
+
# 2 is not a failure. "Judged and failed" must stay distinct from "could not
|
|
676
|
+
# judge": the watermark has already advanced past this artifact, so treating a
|
|
677
|
+
# broken judge as a rejection would bury a good lesson permanently.
|
|
678
|
+
#
|
|
679
|
+
# Usage: librarian_lesson_judge <key> <lesson_id> <verdicts_json>
|
|
680
|
+
librarian_lesson_judge() {
|
|
681
|
+
local key="$1"
|
|
682
|
+
local lesson_id="$2"
|
|
683
|
+
local verdicts="${3:-[]}"
|
|
684
|
+
[[ -z "$key" || -z "$lesson_id" ]] && return 1
|
|
685
|
+
|
|
686
|
+
local path
|
|
687
|
+
path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
|
|
688
|
+
[[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
|
|
689
|
+
|
|
690
|
+
local current_status visibility
|
|
691
|
+
current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
|
|
692
|
+
visibility=$(jq -r '.visibility // ""' "$path" 2>/dev/null)
|
|
693
|
+
|
|
694
|
+
if [[ "$current_status" != "confirmed" ]]; then
|
|
695
|
+
printf 'Lesson %s is not confirmed; its status is: %s\n' "$lesson_id" "$current_status" >&2
|
|
696
|
+
return 1
|
|
697
|
+
fi
|
|
698
|
+
|
|
699
|
+
local rubric_id
|
|
700
|
+
rubric_id=$(librarian_lesson_rubric_id_for_visibility "$visibility") || {
|
|
701
|
+
printf 'Lesson %s has an unrecognized visibility: %s\n' "$lesson_id" "$visibility" >&2
|
|
702
|
+
return 1
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
local verdict now
|
|
706
|
+
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
707
|
+
|
|
708
|
+
if [[ -z "$rubric_id" ]]; then
|
|
709
|
+
# private: no jury, no model call, straight through.
|
|
710
|
+
verdict=$(jq -cn --arg t "$now" \
|
|
711
|
+
'{rubric_id: null, gate_policy: null, score_threshold: null,
|
|
712
|
+
aggregate_score: null, passed: true, reason: "private_no_jury",
|
|
713
|
+
judges: []}') || return 1
|
|
714
|
+
else
|
|
715
|
+
# Every judge must have returned a usable verdict, or this candidate
|
|
716
|
+
# is unjudged. With a two-judge panel under either policy, losing one
|
|
717
|
+
# verdict means the gate cannot be decided at all.
|
|
718
|
+
local usable
|
|
719
|
+
usable=$(printf '%s' "$verdicts" | jq '
|
|
720
|
+
if type != "array" or length == 0 then false
|
|
721
|
+
else all(.[]; (.judge_type | type) == "string"
|
|
722
|
+
and (.score | type) == "number"
|
|
723
|
+
and (.passed | type) == "boolean")
|
|
724
|
+
end' 2>/dev/null) || usable="false"
|
|
725
|
+
[[ "$usable" != "true" ]] && return 2
|
|
726
|
+
|
|
727
|
+
local rubric threshold policy aggregate gate
|
|
728
|
+
rubric=$(librarian_lesson_rubric_get "$rubric_id") || return 1
|
|
729
|
+
threshold=$(printf '%s' "$rubric" | jq -r '.score_threshold')
|
|
730
|
+
policy=$(printf '%s' "$rubric" | jq -r '.gate_policy')
|
|
731
|
+
|
|
732
|
+
aggregate=$(librarian_lesson_aggregate "$verdicts") || return 2
|
|
733
|
+
gate=$(librarian_lesson_gate "$policy" "$verdicts" "$aggregate" "$threshold") || return 1
|
|
734
|
+
|
|
735
|
+
verdict=$(jq -cn \
|
|
736
|
+
--arg r "$rubric_id" --arg p "$policy" \
|
|
737
|
+
--argjson th "$threshold" --argjson ag "$aggregate" \
|
|
738
|
+
--argjson g "$gate" --argjson j "$verdicts" \
|
|
739
|
+
'{rubric_id: $r, gate_policy: $p, score_threshold: $th,
|
|
740
|
+
aggregate_score: $ag, passed: $g.passed, reason: $g.reason,
|
|
741
|
+
judges: $j}') || return 1
|
|
742
|
+
fi
|
|
743
|
+
|
|
744
|
+
local new_status updated
|
|
745
|
+
if [[ "$(printf '%s' "$verdict" | jq -r '.passed')" == "true" ]]; then
|
|
746
|
+
new_status="approved"
|
|
747
|
+
else
|
|
748
|
+
new_status="rejected"
|
|
749
|
+
fi
|
|
750
|
+
|
|
751
|
+
updated=$(jq --arg s "$new_status" --arg t "$now" --argjson v "$verdict" \
|
|
752
|
+
'.status = $s | .judged_at = $t | .verdict = $v' "$path" 2>/dev/null) || return 1
|
|
753
|
+
[[ -z "$updated" || "$updated" == "null" ]] && return 1
|
|
754
|
+
printf '%s\n' "$updated" > "$path"
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
- [ ] **Step 4: Run the tests to verify they pass**
|
|
759
|
+
|
|
760
|
+
Run: `bats test/bats/librarian-lesson-judge.bats`
|
|
761
|
+
Expected: PASS, all tests.
|
|
762
|
+
|
|
763
|
+
- [ ] **Step 5: Prove the tier-separating test discriminates**
|
|
764
|
+
|
|
765
|
+
This is the test the whole public tier exists for. In a throwaway worktree,
|
|
766
|
+
change the public rubric's `gate_policy` from `unanimous` to `majority`:
|
|
767
|
+
|
|
768
|
+
```bash
|
|
769
|
+
git worktree add /tmp/lj-verify2 HEAD
|
|
770
|
+
# In /tmp/lj-verify2 only: plugins/librarian/config.json,
|
|
771
|
+
# "lesson-promotion-public" -> "gate_policy": "majority"
|
|
772
|
+
bats test/bats/librarian-lesson-judge.bats
|
|
773
|
+
git worktree remove --force /tmp/lj-verify2
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
Expected: **"a three-judge panel with one dissenter separates the two tiers"
|
|
777
|
+
FAILS** — `tier02` would be approved. That test is the one that proves the
|
|
778
|
+
outcomes diverge; the two-judge cases reject under either policy and so cannot
|
|
779
|
+
detect this on their own.
|
|
780
|
+
|
|
781
|
+
Report which tests failed under the injection. If only the `.reason`
|
|
782
|
+
assertions moved and no `.status` did, say so plainly rather than reporting the
|
|
783
|
+
guard as proven.
|
|
784
|
+
|
|
785
|
+
- [ ] **Step 6: Run lint and commit**
|
|
786
|
+
|
|
787
|
+
```bash
|
|
788
|
+
npm run lint:check
|
|
789
|
+
shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-judge.sh
|
|
790
|
+
git add plugins/librarian/scripts/lib/librarian-lesson-judge.sh \
|
|
791
|
+
test/bats/librarian-lesson-judge.bats
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
Commit subject: `feat(librarian): judge a confirmed lesson and record it :gavel:`
|
|
795
|
+
|
|
796
|
+
---
|
|
797
|
+
|
|
798
|
+
### Task 3: The CLI surface
|
|
799
|
+
|
|
800
|
+
**Files:**
|
|
801
|
+
- Modify: `plugins/librarian/scripts/lib/librarian-cli.sh`
|
|
802
|
+
- Test: `test/bats/librarian-lesson-judge.bats` (append)
|
|
803
|
+
|
|
804
|
+
**Interfaces:**
|
|
805
|
+
- Consumes: `librarian_lesson_judge <key> <lesson_id> <verdicts_json>` with its 0/1/2 return codes (Task 2); `librarian_lesson_list_by_status <key> <status>` (already shipped).
|
|
806
|
+
- Produces: `librarian_cli lessons judge <id> <verdicts-json> [cwd]` and `librarian_cli lessons list --confirmed --json`.
|
|
807
|
+
|
|
808
|
+
`--json` is a small addition beyond the spec. The skill needs each candidate's
|
|
809
|
+
`visibility` to pick a rubric and to count publics for the pre-spend prompt;
|
|
810
|
+
without it the skill would parse a two-column text format and then call `show`
|
|
811
|
+
once per candidate. One machine-readable call replaces N+1 fragile ones.
|
|
812
|
+
|
|
813
|
+
- [ ] **Step 1: Write the failing tests**
|
|
814
|
+
|
|
815
|
+
Append to `test/bats/librarian-lesson-judge.bats`. Add to `setup()`:
|
|
816
|
+
|
|
817
|
+
```bash
|
|
818
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
```bash
|
|
822
|
+
@test "lessons judge records a verdict through the CLI" {
|
|
823
|
+
_seed_confirmed "cli01" "org"
|
|
824
|
+
run librarian_cli lessons judge "cli01" "$(_verdicts_pass)" "$PROJECT_REPO"
|
|
825
|
+
[ "$status" -eq 0 ]
|
|
826
|
+
[ "$(_status_of cli01)" = "approved" ]
|
|
827
|
+
[[ "$output" == *"approved"* ]] || return 1
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
@test "lessons judge reports an unjudged candidate distinctly from a rejection" {
|
|
831
|
+
_seed_confirmed "cli02" "org"
|
|
832
|
+
run librarian_cli lessons judge "cli02" '[{"judge_type":"standard","score":"nope","passed":true}]' "$PROJECT_REPO"
|
|
833
|
+
[ "$status" -eq 2 ]
|
|
834
|
+
[[ "$output" == *"could not be judged"* ]] || return 1
|
|
835
|
+
[ "$(_status_of cli02)" = "confirmed" ]
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
@test "lessons judge requires a lesson id" {
|
|
839
|
+
run librarian_cli lessons judge
|
|
840
|
+
[ "$status" -ne 0 ]
|
|
841
|
+
[[ "$output" == *"usage:"* && "$output" == *"judge"* ]] || return 1
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
@test "lessons judge requires verdicts" {
|
|
845
|
+
run librarian_cli lessons judge "cli03"
|
|
846
|
+
[ "$status" -ne 0 ]
|
|
847
|
+
[[ "$output" == *"usage:"* && "$output" == *"judge"* ]] || return 1
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
@test "lessons judge rejects an unknown flag" {
|
|
851
|
+
_seed_confirmed "cli04" "org"
|
|
852
|
+
run librarian_cli lessons judge "cli04" "$(_verdicts_pass)" --force
|
|
853
|
+
[ "$status" -ne 0 ]
|
|
854
|
+
[[ "$output" == *"unknown option"* && "$output" == *"--force"* ]] || return 1
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
@test "lessons list --confirmed --json emits rows carrying visibility" {
|
|
858
|
+
_seed_confirmed "js01" "public"
|
|
859
|
+
_seed_confirmed "js02" "org"
|
|
860
|
+
run librarian_cli lessons list --confirmed --json "$PROJECT_REPO"
|
|
861
|
+
[ "$status" -eq 0 ]
|
|
862
|
+
[ "$(printf '%s' "$output" | jq 'length')" -eq 2 ]
|
|
863
|
+
[ "$(printf '%s' "$output" | jq -r '[.[] | select(.visibility == "public")] | length')" -eq 1 ]
|
|
864
|
+
[ "$(printf '%s' "$output" | jq -r '.[0] | has("id")')" = "true" ]
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
@test "lessons list --json on an empty set emits an empty array, not prose" {
|
|
868
|
+
# The skill parses this; a human-readable empty-state message would break it.
|
|
869
|
+
run librarian_cli lessons list --confirmed --json "$PROJECT_REPO"
|
|
870
|
+
[ "$status" -eq 0 ]
|
|
871
|
+
[ "$output" = "[]" ]
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
@test "bare lessons list is unchanged by the --json addition" {
|
|
875
|
+
run librarian_cli lessons list "$PROJECT_REPO"
|
|
876
|
+
[ "$status" -eq 0 ]
|
|
877
|
+
[ "$output" = "No pending lessons." ]
|
|
878
|
+
}
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
882
|
+
|
|
883
|
+
Run: `bats test/bats/librarian-lesson-judge.bats`
|
|
884
|
+
Expected: the eight new tests FAIL — `judge` is an unknown lessons action, and `--json` is refused as an unknown option.
|
|
885
|
+
|
|
886
|
+
- [ ] **Step 3: Add `--json` to the list verb**
|
|
887
|
+
|
|
888
|
+
In `librarian_cli_lessons_list`, add a `json=0` local, a `--json) json=1; shift ;;`
|
|
889
|
+
arm to the existing `while` loop before the `--*` catch-all, and branch the
|
|
890
|
+
output:
|
|
891
|
+
|
|
892
|
+
```bash
|
|
893
|
+
if [[ "$json" -eq 1 ]]; then
|
|
894
|
+
printf '%s' "$rows"
|
|
895
|
+
return 0
|
|
896
|
+
fi
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
Place that **before** the existing empty-check, so an empty set emits `[]`
|
|
900
|
+
rather than the prose empty-state the skill cannot parse.
|
|
901
|
+
|
|
902
|
+
- [ ] **Step 4: Add the judge verb**
|
|
903
|
+
|
|
904
|
+
Add to `librarian-cli.sh`, following the shape of the sibling verbs:
|
|
905
|
+
|
|
906
|
+
```bash
|
|
907
|
+
# Usage: librarian_cli_lessons_judge <lesson_id> <verdicts-json> [cwd]
|
|
908
|
+
#
|
|
909
|
+
# Verdicts come from the judge subagents the skill dispatched. Exit 2 means the
|
|
910
|
+
# candidate could not be judged and stays confirmed — distinct from a rejection,
|
|
911
|
+
# which is a real verdict and exits 0.
|
|
912
|
+
librarian_cli_lessons_judge() {
|
|
913
|
+
local lesson_id="" verdicts="" cwd=""
|
|
914
|
+
local positional=0
|
|
915
|
+
while [[ $# -gt 0 ]]; do
|
|
916
|
+
case "$1" in
|
|
917
|
+
--*)
|
|
918
|
+
printf 'unknown option: %s\n' "$1" >&2
|
|
919
|
+
return 1
|
|
920
|
+
;;
|
|
921
|
+
*)
|
|
922
|
+
case "$positional" in
|
|
923
|
+
0) lesson_id="$1" ;;
|
|
924
|
+
1) verdicts="$1" ;;
|
|
925
|
+
*) cwd="$1" ;;
|
|
926
|
+
esac
|
|
927
|
+
positional=$((positional + 1))
|
|
928
|
+
shift
|
|
929
|
+
;;
|
|
930
|
+
esac
|
|
931
|
+
done
|
|
932
|
+
|
|
933
|
+
if [[ -z "$lesson_id" || -z "$verdicts" ]]; then
|
|
934
|
+
printf 'usage: librarian_cli lessons judge <lesson_id> <verdicts-json> [cwd]\n'
|
|
935
|
+
return 1
|
|
936
|
+
fi
|
|
937
|
+
|
|
938
|
+
local key
|
|
939
|
+
key=$(_librarian_cli_project_key "$cwd")
|
|
940
|
+
[[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
|
|
941
|
+
|
|
942
|
+
librarian_lesson_judge "$key" "$lesson_id" "$verdicts"
|
|
943
|
+
local rc=$?
|
|
944
|
+
case "$rc" in
|
|
945
|
+
0)
|
|
946
|
+
printf 'Lesson %s is now %s.\n' "$lesson_id" \
|
|
947
|
+
"$(jq -r '.status' "$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json")"
|
|
948
|
+
;;
|
|
949
|
+
2)
|
|
950
|
+
printf 'Lesson %s could not be judged; it stays confirmed. Re-run to retry.\n' "$lesson_id"
|
|
951
|
+
;;
|
|
952
|
+
esac
|
|
953
|
+
return $rc
|
|
954
|
+
}
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
Register it in `librarian_cli_lessons`'s `case`, after `unconfirm`:
|
|
958
|
+
|
|
959
|
+
```bash
|
|
960
|
+
judge) librarian_cli_lessons_judge "$@" ;;
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
Source `librarian-lesson-judge.sh` and `librarian-lesson-rubric.sh` alongside
|
|
964
|
+
the existing lesson lib sources in `librarian-cli.sh`.
|
|
965
|
+
|
|
966
|
+
- [ ] **Step 5: Run the tests to verify they pass**
|
|
967
|
+
|
|
968
|
+
Run: `bats test/bats/librarian-lesson-judge.bats && bats test/bats/librarian-cli.bats && bats test/bats/librarian-lesson-review.bats`
|
|
969
|
+
Expected: all PASS. Read each exit code directly — never through a pipe, which reports the pipe's last command.
|
|
970
|
+
|
|
971
|
+
- [ ] **Step 6: Prove the flag guard and the empty-array test discriminate**
|
|
972
|
+
|
|
973
|
+
In a throwaway worktree, separately: remove the `--*` arm from
|
|
974
|
+
`librarian_cli_lessons_judge` and confirm "lessons judge rejects an unknown
|
|
975
|
+
flag" fails; then move the `--json` early return *after* the empty-check and
|
|
976
|
+
confirm "lessons list --json on an empty set emits an empty array" fails.
|
|
977
|
+
|
|
978
|
+
- [ ] **Step 7: Run lint and commit**
|
|
979
|
+
|
|
980
|
+
```bash
|
|
981
|
+
npm run lint:check
|
|
982
|
+
shellcheck -S error -x plugins/librarian/scripts/lib/librarian-cli.sh
|
|
983
|
+
git add plugins/librarian/scripts/lib/librarian-cli.sh test/bats/librarian-lesson-judge.bats
|
|
984
|
+
```
|
|
985
|
+
|
|
986
|
+
Commit subject: `feat(librarian): surface judging on the lessons CLI :gavel:`
|
|
987
|
+
|
|
988
|
+
---
|
|
989
|
+
|
|
990
|
+
### Task 4: Assert the seam si6 left for this stage
|
|
991
|
+
|
|
992
|
+
**Files:**
|
|
993
|
+
- Test: `test/bats/librarian-lesson-review.bats` (append)
|
|
994
|
+
|
|
995
|
+
**Interfaces:**
|
|
996
|
+
- Consumes: `librarian_lesson_judge` (Task 2), `librarian_lesson_unconfirm` (already shipped).
|
|
997
|
+
- Produces: nothing. This task adds tests only.
|
|
998
|
+
|
|
999
|
+
`si6` promised that `unconfirm` would refuse whatever status this stage
|
|
1000
|
+
introduced, automatically, through its catch-all — with no change to `unconfirm`
|
|
1001
|
+
and no coordination between the stages. **Do not modify `librarian_lesson_unconfirm`.**
|
|
1002
|
+
This task collects on that promise by asserting it.
|
|
1003
|
+
|
|
1004
|
+
- [ ] **Step 1: Write the tests**
|
|
1005
|
+
|
|
1006
|
+
Append to `test/bats/librarian-lesson-review.bats`. Reuse that file's existing
|
|
1007
|
+
setup and seeding helpers; source `librarian-lesson-judge.sh` and
|
|
1008
|
+
`librarian-lesson-rubric.sh` in its `setup()` if they are not already.
|
|
1009
|
+
|
|
1010
|
+
The existing helpers in that file are `_review_setup` (call it first, as every
|
|
1011
|
+
test there does) and `_seed_pending`, which writes a `versioned` candidate and
|
|
1012
|
+
echoes its id.
|
|
1013
|
+
|
|
1014
|
+
```bash
|
|
1015
|
+
@test "unconfirm refuses an approved lesson, naming the status" {
|
|
1016
|
+
_review_setup
|
|
1017
|
+
local id
|
|
1018
|
+
id=$(_seed_pending)
|
|
1019
|
+
librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
|
|
1020
|
+
librarian_lesson_judge "$PROJECT_KEY" "$id" \
|
|
1021
|
+
'[{"judge_type":"standard","score":0.9,"passed":true},{"judge_type":"adversarial","score":0.8,"passed":true}]'
|
|
1022
|
+
|
|
1023
|
+
run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
|
|
1024
|
+
[ "$status" -ne 0 ]
|
|
1025
|
+
[[ "$output" == *"unrecognized status"* && "$output" == *"approved"* ]] || return 1
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
@test "unconfirm refuses a rejected lesson, naming the status" {
|
|
1029
|
+
_review_setup
|
|
1030
|
+
local id
|
|
1031
|
+
id=$(_seed_pending)
|
|
1032
|
+
librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
|
|
1033
|
+
librarian_lesson_judge "$PROJECT_KEY" "$id" \
|
|
1034
|
+
'[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.75,"passed":false}]'
|
|
1035
|
+
|
|
1036
|
+
run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
|
|
1037
|
+
[ "$status" -ne 0 ]
|
|
1038
|
+
[[ "$output" == *"unrecognized status"* && "$output" == *"rejected"* ]] || return 1
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
`_seed_pending` writes a `versioned` candidate, so confirming at `public` needs
|
|
1043
|
+
no `--justification` — that flag is only required to convert scope to
|
|
1044
|
+
`version_independent`.
|
|
1045
|
+
|
|
1046
|
+
- [ ] **Step 2: Run the tests**
|
|
1047
|
+
|
|
1048
|
+
Run: `bats test/bats/librarian-lesson-review.bats`
|
|
1049
|
+
Expected: PASS. These pass on the first try — that is the point. `unconfirm`'s
|
|
1050
|
+
catch-all already handles both statuses.
|
|
1051
|
+
|
|
1052
|
+
- [ ] **Step 3: Confirm the tests are not vacuous**
|
|
1053
|
+
|
|
1054
|
+
Both assert a non-zero exit, which several guards in this file can produce.
|
|
1055
|
+
The message pin (`unrecognized status` plus the status name) is what makes them
|
|
1056
|
+
discriminate. Verify in a throwaway worktree: change `unconfirm`'s catch-all to
|
|
1057
|
+
`*) return 1 ;;` with no message, and confirm both tests fail.
|
|
1058
|
+
|
|
1059
|
+
- [ ] **Step 4: Commit**
|
|
1060
|
+
|
|
1061
|
+
```bash
|
|
1062
|
+
git add test/bats/librarian-lesson-review.bats
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
Commit subject: `test(librarian): collect on si6's forward-safety promise :handshake:`
|
|
1066
|
+
|
|
1067
|
+
---
|
|
1068
|
+
|
|
1069
|
+
### Task 5: The skill route and the ADR
|
|
1070
|
+
|
|
1071
|
+
**Files:**
|
|
1072
|
+
- Modify: `plugins/librarian/skills/librarian/SKILL.md`
|
|
1073
|
+
- Create: `plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md`
|
|
1074
|
+
|
|
1075
|
+
**Interfaces:**
|
|
1076
|
+
- Consumes: `librarian_cli lessons list --confirmed --json`, `librarian_cli lessons show <id>`, `librarian_cli lessons judge <id> <verdicts-json>` (Task 3).
|
|
1077
|
+
- Produces: nothing programmatic.
|
|
1078
|
+
|
|
1079
|
+
- [ ] **Step 1: Write the ADR**
|
|
1080
|
+
|
|
1081
|
+
Create `plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md`:
|
|
1082
|
+
|
|
1083
|
+
```markdown
|
|
1084
|
+
# ADR-002: Agent definitions are shared assets; hooks are not
|
|
1085
|
+
|
|
1086
|
+
## Status
|
|
1087
|
+
|
|
1088
|
+
Accepted.
|
|
1089
|
+
|
|
1090
|
+
## Context
|
|
1091
|
+
|
|
1092
|
+
CLAUDE.md states: "Plugins communicate by emitting events to the JSONL log —
|
|
1093
|
+
they do not call each other directly. All plugins depend on the ecosystem
|
|
1094
|
+
substrate; no plugin depends on another plugin directly."
|
|
1095
|
+
|
|
1096
|
+
Lesson judging (`ecosystem-4z8.3`) needs a jury. Tribunal ships three judge
|
|
1097
|
+
agent definitions and the rubric vocabulary. The stage couples librarian and
|
|
1098
|
+
tribunal in some direction no matter how it is arranged: either librarian
|
|
1099
|
+
reaches for tribunal's judges, or tribunal reaches into librarian's
|
|
1100
|
+
project-keyed proposal files and writes verdicts back into them.
|
|
1101
|
+
|
|
1102
|
+
## Decision
|
|
1103
|
+
|
|
1104
|
+
The invariant forbids **runtime** coupling — one plugin's hook or library
|
|
1105
|
+
calling another's, which makes one plugin's failure another's. It does not
|
|
1106
|
+
forbid reusing a **published agent definition** by name.
|
|
1107
|
+
|
|
1108
|
+
Librarian therefore owns the `judge` verb, both rubrics, the aggregate, and the
|
|
1109
|
+
gate decision, and dispatches `tribunal-judge-standard` and
|
|
1110
|
+
`tribunal-judge-adversarial` by name.
|
|
1111
|
+
|
|
1112
|
+
Librarian does **not** source any file under `plugins/tribunal/`. It implements
|
|
1113
|
+
its own aggregate and gate — roughly twenty lines — rather than calling
|
|
1114
|
+
`tribunal_aggregate` or `tribunal_gate_decide`.
|
|
1115
|
+
|
|
1116
|
+
## Rationale
|
|
1117
|
+
|
|
1118
|
+
An agent definition is declarative: a markdown file with frontmatter and a
|
|
1119
|
+
prompt. It has no runtime surface, cannot fail at call time in a way that
|
|
1120
|
+
propagates, and is resolved by the harness rather than by librarian. Depending
|
|
1121
|
+
on one is closer to depending on a published schema than to calling another
|
|
1122
|
+
plugin's code.
|
|
1123
|
+
|
|
1124
|
+
Sourcing tribunal's bash would be the real coupling: a change to
|
|
1125
|
+
`tribunal_gate_decide`'s signature would break librarian silently, and
|
|
1126
|
+
tribunal's own tests would not catch it.
|
|
1127
|
+
|
|
1128
|
+
Keeping the lifecycle in librarian also keeps `ecosystem-4z8.4`'s pool and
|
|
1129
|
+
ledger in one plugin instead of splitting them across two.
|
|
1130
|
+
|
|
1131
|
+
## Consequences
|
|
1132
|
+
|
|
1133
|
+
A judge agent renamed or removed in tribunal breaks lesson judging at dispatch
|
|
1134
|
+
time. That is a visible, loud failure at the moment a human invokes the verb —
|
|
1135
|
+
not a silent one — and the "could not judge" path already handles it: the
|
|
1136
|
+
candidate stays `confirmed` and nothing is written.
|
|
1137
|
+
|
|
1138
|
+
Librarian's gate logic can drift from tribunal's. Accepted deliberately: they
|
|
1139
|
+
answer different questions. Tribunal gates an Actor's output with retry;
|
|
1140
|
+
librarian gates a fixed artifact with none.
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
- [ ] **Step 2: Add the judge route to SKILL.md**
|
|
1144
|
+
|
|
1145
|
+
Add to the frontmatter `description`, in the sentence that lists the lesson
|
|
1146
|
+
routes, so `judge` is discoverable: mention `/librarian lessons judge`.
|
|
1147
|
+
|
|
1148
|
+
Add this section after the existing `lessons` walk:
|
|
1149
|
+
|
|
1150
|
+
```markdown
|
|
1151
|
+
For `lessons judge`, run the jury over confirmed candidates:
|
|
1152
|
+
|
|
1153
|
+
1. Call `librarian_cli lessons list --confirmed --json`. Each row carries `id`,
|
|
1154
|
+
`visibility`, and the full `candidate`. If the array is empty, tell the user
|
|
1155
|
+
there is nothing awaiting judgment and stop.
|
|
1156
|
+
2. **Report the batch before spending anything.** Say how many candidates are
|
|
1157
|
+
confirmed and how many are `public`, and ask whether to proceed. This is the
|
|
1158
|
+
most expensive step in the pipeline. If the user declines, stop — nothing is
|
|
1159
|
+
written and every candidate stays `confirmed`.
|
|
1160
|
+
3. For each candidate, in order:
|
|
1161
|
+
- **If `visibility` is `private`, dispatch no judges at all.** Call
|
|
1162
|
+
`librarian_cli lessons judge <id> '[]'` and move on. Private lessons run no
|
|
1163
|
+
jury; that is what makes cost scale with intent rather than artifact volume.
|
|
1164
|
+
- Otherwise spawn **both** `tribunal-judge-standard` and
|
|
1165
|
+
`tribunal-judge-adversarial` with the Task tool. Give each the candidate's
|
|
1166
|
+
`claim`, `rationale`, `evidence.resolution`, and `applies_to`, plus the
|
|
1167
|
+
rubric criteria for its visibility: for `org`, grounding / scope_accuracy /
|
|
1168
|
+
generality; for `public`, those three plus **disclosure** — does the text
|
|
1169
|
+
leak a credential, internal hostname, customer name, or proprietary detail?
|
|
1170
|
+
- Each judge returns a JSON object with `score`, `passed`, `judge_type`, and
|
|
1171
|
+
`feedback_summary`. Collect both into a JSON array **verbatim** — never
|
|
1172
|
+
summarize or reconstruct a judge's verdict.
|
|
1173
|
+
- Call `librarian_cli lessons judge <id> '<verdicts-json>'`. Record before
|
|
1174
|
+
moving to the next candidate, so an interrupted run costs at most one
|
|
1175
|
+
re-judgment.
|
|
1176
|
+
4. **If either judge fails to return parseable JSON, do not invent a verdict and
|
|
1177
|
+
do not drop that judge.** Pass what you have to the CLI; it will exit 2, leave
|
|
1178
|
+
the candidate `confirmed`, and report that it could not be judged. Collect
|
|
1179
|
+
those ids and list them at the end so the user knows to re-run. A broken judge
|
|
1180
|
+
must never become a rejection — the artifact's watermark has already moved,
|
|
1181
|
+
so a false rejection buries a good lesson permanently.
|
|
1182
|
+
5. Finish by reporting counts: approved, rejected, and could-not-judge.
|
|
1183
|
+
|
|
1184
|
+
`scope_accuracy` is the criterion that matters most on a `version_independent`
|
|
1185
|
+
candidate. The schema guarantees such a lesson **carries** a justification; this
|
|
1186
|
+
criterion asks whether it is **true**.
|
|
1187
|
+
```
|
|
1188
|
+
|
|
1189
|
+
- [ ] **Step 3: Verify the docs lint and cross-references pass**
|
|
1190
|
+
|
|
1191
|
+
```bash
|
|
1192
|
+
npm run lint:check
|
|
1193
|
+
npm run test:schema
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
Expected: both exit 0. `check-references.mjs` validates that documents referenced
|
|
1197
|
+
from other documents exist; a mistyped ADR path fails there.
|
|
1198
|
+
|
|
1199
|
+
- [ ] **Step 4: Run the full suite**
|
|
1200
|
+
|
|
1201
|
+
```bash
|
|
1202
|
+
npm run test:ci
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
Expected: exit 0. Read the exit code directly, not through a pipe.
|
|
1206
|
+
|
|
1207
|
+
- [ ] **Step 5: Commit**
|
|
1208
|
+
|
|
1209
|
+
```bash
|
|
1210
|
+
git add plugins/librarian/skills/librarian/SKILL.md \
|
|
1211
|
+
plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
Commit subject: `feat(librarian): walk the jury from the lessons skill :balance_scale:`
|
|
1215
|
+
|
|
1216
|
+
---
|
|
1217
|
+
|
|
1218
|
+
## Spec coverage
|
|
1219
|
+
|
|
1220
|
+
Every case in the spec's Testing section maps to a task:
|
|
1221
|
+
|
|
1222
|
+
| Spec test | Task |
|
|
1223
|
+
|---|---|
|
|
1224
|
+
| `private` reaches `approved` with no model call | 2 |
|
|
1225
|
+
| `org` uses `lesson-promotion` + majority | 1, 2 |
|
|
1226
|
+
| `public` uses `lesson-promotion-public` + unanimous | 1, 2 |
|
|
1227
|
+
| a public candidate one judge blocks is rejected though the aggregate clears | 2 |
|
|
1228
|
+
| one judge returning unparseable output leaves it `confirmed`, id reported | 2, 3 |
|
|
1229
|
+
| a below-threshold candidate is rejected and keeps its file | 2 |
|
|
1230
|
+
| `unconfirm` refuses `approved` and `rejected`, naming the status | 4 |
|
|
1231
|
+
| re-running skips candidates that are not `confirmed` | 2 |
|
|
1232
|
+
| both rubrics' weights sum to 1.00 | 1 |
|