@onlooker-community/ecosystem 0.34.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # `run --separate-stderr` (used below) requires bats >= 1.5.0.
4
+ bats_require_minimum_version 1.5.0
5
+
6
+ setup() {
7
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
8
+ setup_test_env
9
+
10
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
11
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
12
+
13
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
14
+ mkdir -p "$PROJECT_REPO"
15
+ git -C "$PROJECT_REPO" init -q
16
+ git -C "$PROJECT_REPO" config user.email t@example.com
17
+ git -C "$PROJECT_REPO" config user.name "Test"
18
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/fixture.git
19
+
20
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
21
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
22
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
23
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
24
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
25
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-promote.sh"
26
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
27
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-rubric.sh"
28
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-judge.sh"
29
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
30
+ librarian_config_load "$PROJECT_REPO"
31
+
32
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
33
+ librarian_lesson_storage_init "$PROJECT_KEY"
34
+
35
+ # Promotion must spend nothing. Any invocation of this stub is a failure.
36
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
37
+ mkdir -p "$STUB_BIN"
38
+ cat > "${STUB_BIN}/claude" <<'STUB'
39
+ #!/usr/bin/env bash
40
+ echo "claude was invoked but promotion must spend no tokens" >&2
41
+ exit 99
42
+ STUB
43
+ chmod +x "${STUB_BIN}/claude"
44
+ export PATH="${STUB_BIN}:${PATH}"
45
+ }
46
+
47
+ _dir() { printf '%s' "$(librarian_lessons_dir "$PROJECT_KEY")"; }
48
+
49
+ # A judged proposal. $1 = id, $2 = visibility, $3 = status,
50
+ # $4 = verdict judges JSON array.
51
+ _seed_judged() {
52
+ local id="$1" visibility="$2" status="$3" judges="$4"
53
+ local now
54
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
55
+ jq -n --arg id "$id" --arg v "$visibility" --arg s "$status" \
56
+ --arg t "$now" --argjson j "$judges" \
57
+ '{ id: $id, artifact_id: "art-\($id)", status: $s, visibility: $v,
58
+ confirmed_at: $t, judged_at: $t,
59
+ candidate: { claim: "Prefer jq -c for compact output",
60
+ rationale: "Readable diffs",
61
+ evidence: { resolution: "Applied and verified" },
62
+ applies_to: { stack: ["bash"],
63
+ scope: { kind: "versioned", versions: ">=3.2" } } },
64
+ verdict: { rubric_id: "lesson-promotion", gate_policy: "majority",
65
+ score_threshold: 0.75, aggregate_score: 0.85,
66
+ passed: ($s == "approved"), reason: "gate_passed",
67
+ judges: $j } }' \
68
+ > "$(_dir)/proposals/${id}.json"
69
+ }
70
+
71
+ # Both fixtures carry criterion_scores covering the superset of the org and
72
+ # public rubrics. They are load-bearing, not decoration: librarian_lesson_judge
73
+ # refuses a panel that leaves a floored criterion unscored, so without them
74
+ # these panels are UNJUDGED (2) and never reach the promotion path under test.
75
+ # disclosure sits at 0.95 against its 0.9 floor, which is now a panel MINIMUM.
76
+ _two_passing() {
77
+ printf '%s' '[{"judge_type":"standard","score":0.9,"passed":true,"criterion_scores":{"grounding":0.9,"scope_accuracy":0.9,"generality":0.9,"disclosure":0.95}},{"judge_type":"adversarial","score":0.8,"passed":true,"criterion_scores":{"grounding":0.8,"scope_accuracy":0.8,"generality":0.8,"disclosure":0.95}}]'
78
+ }
79
+ _split() {
80
+ printf '%s' '[{"judge_type":"standard","score":0.95,"passed":true,"criterion_scores":{"grounding":0.95,"scope_accuracy":0.95,"generality":0.95,"disclosure":0.95}},{"judge_type":"adversarial","score":0.75,"passed":false,"criterion_scores":{"grounding":0.75,"scope_accuracy":0.75,"generality":0.75,"disclosure":0.95}}]'
81
+ }
82
+
83
+ @test "an approved org lesson becomes a pool entry with exactly ZLesson's keys" {
84
+ # ZLesson is a z.strictObject: an extra key fails ingest as surely as a
85
+ # missing one, so the key SET is the assertion, not a spot-check.
86
+ _seed_judged "org01" "org" "approved" "$(_two_passing)"
87
+ run librarian_lesson_promote "$PROJECT_KEY" "org01"
88
+ [ "$status" -eq 0 ]
89
+
90
+ local keys
91
+ keys=$(jq -r 'keys_unsorted | sort | join(" ")' "$(_dir)/approved/org01.json")
92
+ [ "$keys" = "applies_to author_key claim consensus evidence id promoted_at rationale schema_version source status superseded_by visibility" ]
93
+ }
94
+
95
+ @test "the pool entry carries the mapped source and a derived consensus" {
96
+ _seed_judged "org02" "org" "approved" "$(_two_passing)"
97
+ librarian_lesson_promote "$PROJECT_KEY" "org02"
98
+
99
+ local e
100
+ e=$(cat "$(_dir)/approved/org02.json")
101
+ [ "$(printf '%s' "$e" | jq -r '.source')" = "org" ]
102
+ [ "$(printf '%s' "$e" | jq -r '.visibility')" = "org" ]
103
+ [ "$(printf '%s' "$e" | jq -r '.schema_version')" = "2" ]
104
+ [ "$(printf '%s' "$e" | jq -r '.status')" = "active" ]
105
+ [ "$(printf '%s' "$e" | jq -r '.superseded_by')" = "null" ]
106
+ [ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "2" ]
107
+ [ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "2" ]
108
+ [ "$(printf '%s' "$e" | jq -r '.author_key')" != "null" ]
109
+ printf '%s' "$e" | jq -e '.author_key | test("^[0-9a-f]{32}$")' >/dev/null || return 1
110
+ }
111
+
112
+ @test "a private lesson maps to source local with zero judges" {
113
+ # Deliberately NOT ingest-valid: ZConsensus requires judges >= 1. A
114
+ # private lesson never syncs, so it never reaches the validator. Do not
115
+ # "fix" this by synthesizing a jury that never sat.
116
+ _seed_judged "priv01" "private" "approved" '[]'
117
+ run librarian_lesson_promote "$PROJECT_KEY" "priv01"
118
+ [ "$status" -eq 0 ]
119
+
120
+ local e
121
+ e=$(cat "$(_dir)/approved/priv01.json")
122
+ [ "$(printf '%s' "$e" | jq -r '.source')" = "local" ]
123
+ [ "$(printf '%s' "$e" | jq -r '.visibility')" = "private" ]
124
+ [ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "0" ]
125
+ [ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "0" ]
126
+ }
127
+
128
+ @test "a public lesson maps to source public" {
129
+ _seed_judged "pub01" "public" "approved" "$(_two_passing)"
130
+ librarian_lesson_promote "$PROJECT_KEY" "pub01"
131
+ [ "$(jq -r '.source' "$(_dir)/approved/pub01.json")" = "public" ]
132
+ }
133
+
134
+ @test "agreed never exceeds judges" {
135
+ # The contract's own ingest rule, which its schema deliberately cannot
136
+ # express (it would need .refine(), which z.toJSONSchema drops).
137
+ _seed_judged "cnt01" "org" "approved" "$(_split)"
138
+ librarian_lesson_promote "$PROJECT_KEY" "cnt01"
139
+
140
+ local e
141
+ e=$(cat "$(_dir)/approved/cnt01.json")
142
+ [ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "2" ]
143
+ [ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "1" ]
144
+ printf '%s' "$e" | jq -e '.consensus.agreed <= .consensus.judges' >/dev/null || return 1
145
+ }
146
+
147
+ @test "a rejected lesson writes a declined row with a NESTED verdict and no pool entry" {
148
+ # .verdict must be an object, not a serialized string. A --arg/--argjson
149
+ # mistake produces a row that looks right and is unusable to a consumer,
150
+ # so assert by indexing into it rather than matching a substring.
151
+ _seed_judged "rej01" "public" "rejected" "$(_split)"
152
+ run librarian_lesson_promote "$PROJECT_KEY" "rej01"
153
+ [ "$status" -eq 0 ]
154
+
155
+ [ ! -f "$(_dir)/approved/rej01.json" ]
156
+ local row
157
+ row=$(grep 'art-rej01' "$(_dir)/declined.jsonl")
158
+ [ "$(printf '%s' "$row" | jq -r '.artifact_id')" = "art-rej01" ]
159
+ [ "$(printf '%s' "$row" | jq -r '.verdict | type')" = "object" ]
160
+ [ "$(printf '%s' "$row" | jq -r '.verdict.judges | length')" = "2" ]
161
+ [ "$(printf '%s' "$row" | jq -r '.verdict.rubric_id')" = "lesson-promotion" ]
162
+ }
163
+
164
+ @test "a stage-5 style decline still writes cleanly and has no verdict key" {
165
+ librarian_lesson_append_declined "$PROJECT_KEY" "art-t5" "transform_invalid" "malformed JSON"
166
+ local row
167
+ row=$(grep 'art-t5' "$(_dir)/declined.jsonl")
168
+ [ "$(printf '%s' "$row" | jq -r '.reason')" = "transform_invalid" ]
169
+ [ "$(printf '%s' "$row" | jq -r 'has("verdict")')" = "false" ]
170
+ }
171
+
172
+ @test "promoting twice leaves one pool entry with an unchanged promoted_at" {
173
+ _seed_judged "idem01" "org" "approved" "$(_two_passing)"
174
+ librarian_lesson_promote "$PROJECT_KEY" "idem01"
175
+ local first
176
+ first=$(cat "$(_dir)/approved/idem01.json")
177
+
178
+ run librarian_lesson_promote "$PROJECT_KEY" "idem01"
179
+ [ "$status" -eq 0 ]
180
+ [ "$(cat "$(_dir)/approved/idem01.json")" = "$first" ]
181
+ [ "$(ls "$(_dir)/approved" | grep -c idem01)" -eq 1 ]
182
+ }
183
+
184
+ @test "promotion is refused before the lesson has been judged" {
185
+ _seed_judged "conf01" "org" "confirmed" '[]'
186
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "conf01"
187
+ [ "$status" -ne 0 ]
188
+ [ "$output" = "" ]
189
+ [[ "$stderr" == *"not been judged"* ]] || return 1
190
+ [ ! -f "$(_dir)/approved/conf01.json" ]
191
+ }
192
+
193
+ @test "promotion is refused from a passed lesson, naming the status" {
194
+ _seed_judged "pass01" "org" "passed" '[]'
195
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "pass01"
196
+ [ "$status" -ne 0 ]
197
+ [[ "$stderr" == *"passed"* ]] || return 1
198
+ }
199
+
200
+ @test "a failing author_key leaves nothing written and the lesson still approved" {
201
+ # THE reconcile property. Promotion fails for reasons judging does not —
202
+ # a malformed secret, absent node, a full disk — and the lesson must stay
203
+ # exactly where a standalone re-run can pick it up.
204
+ _seed_judged "ak01" "org" "approved" "$(_two_passing)"
205
+ local secret_path
206
+ secret_path=$(librarian_author_secret_path)
207
+ mkdir -p "$(dirname "$secret_path")"
208
+ printf 'not-a-valid-secret\n' > "$secret_path"
209
+ chmod 0600 "$secret_path"
210
+
211
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "ak01"
212
+ [ "$status" -ne 0 ]
213
+ [ ! -f "$(_dir)/approved/ak01.json" ]
214
+ [ "$(jq -r 'has("promoted_at")' "$(_dir)/proposals/ak01.json")" = "false" ]
215
+ [ "$(jq -r '.status' "$(_dir)/proposals/ak01.json")" = "approved" ]
216
+ }
217
+
218
+ @test "the proposal survives promotion and carries promoted_at" {
219
+ _seed_judged "keep01" "org" "approved" "$(_two_passing)"
220
+ librarian_lesson_promote "$PROJECT_KEY" "keep01"
221
+ [ -f "$(_dir)/proposals/keep01.json" ]
222
+ [ "$(jq -r '.status' "$(_dir)/proposals/keep01.json")" = "approved" ]
223
+ printf '%s' "$(jq -r '.promoted_at' "$(_dir)/proposals/keep01.json")" \
224
+ | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' || return 1
225
+ # The per-judge detail is why the proposal is kept: the pool entry has
226
+ # only consensus counts.
227
+ [ "$(jq -r '.verdict.judges | length' "$(_dir)/proposals/keep01.json")" = "2" ]
228
+ }
229
+
230
+ @test "a promoted rejection is still seen after its proposal is deleted" {
231
+ # The proposal is deleted after promoting, on purpose. librarian_lesson_seen
232
+ # scans proposals/ unconditionally of status, so leaving it in place would
233
+ # let this test pass even if promote wrote nothing at all. The property
234
+ # under test is that the TERMINAL RECORD — the declined row — is what
235
+ # marks the artifact handled once its proposal ages out, not the
236
+ # (now-gone) proposal file.
237
+ #
238
+ # The approved path has no equivalent, and cannot: see the test directly
239
+ # below, which pins that absence as the known limitation it is.
240
+ _seed_judged "seen02" "public" "rejected" "$(_split)"
241
+ librarian_lesson_promote "$PROJECT_KEY" "seen02"
242
+ rm -f "$(_dir)/proposals/seen02.json"
243
+ run librarian_lesson_seen "$PROJECT_KEY" "art-seen02"
244
+ [ "$status" -eq 0 ]
245
+ }
246
+
247
+ @test "an approved lesson is NOT seen once its proposal is gone — proposals are the sole dedup source" {
248
+ # The limitation ecosystem-d0m settled, made executable so it cannot drift.
249
+ #
250
+ # A rejection survives its proposal because the declined row is a terminal
251
+ # record keyed by artifact_id. An approval has no such record: the pool
252
+ # entry is ZLesson, a strictObject with no artifact_id, so nothing promote
253
+ # writes can answer "was this artifact handled?" Dedup for an approved
254
+ # lesson rests entirely on proposals/ never being pruned.
255
+ #
256
+ # This asserts status 1 deliberately. It is not endorsing the gap — it is
257
+ # the tripwire for it. If someone builds the artifact_id -> lesson id index
258
+ # and closes d0m, THIS TEST WILL FAIL, which is the point: the fix should
259
+ # have to come here and say so, rather than quietly leaving a stale
260
+ # assertion that the limitation still exists.
261
+ #
262
+ # Use a real promote, never a hand-written approved/*.json. The test this
263
+ # replaced fabricated {artifact_id: ...} into the pool — a shape promote
264
+ # cannot produce — and so reported coverage of a scan that never ran.
265
+ _seed_judged "seen03" "public" "approved" "$(_two_passing)"
266
+ librarian_lesson_promote "$PROJECT_KEY" "seen03"
267
+ # The pool entry exists and carries no artifact_id: that is the whole cause.
268
+ [ -f "$(_dir)/approved/seen03.json" ] || return 1
269
+ run jq -e 'has("artifact_id")' "$(_dir)/approved/seen03.json"
270
+ [ "$status" -ne 0 ] || return 1
271
+
272
+ # With the proposal present, dedup works — via proposals/, not the pool.
273
+ run librarian_lesson_seen "$PROJECT_KEY" "art-seen03"
274
+ [ "$status" -eq 0 ] || return 1
275
+
276
+ rm -f "$(_dir)/proposals/seen03.json"
277
+ run librarian_lesson_seen "$PROJECT_KEY" "art-seen03"
278
+ [ "$status" -eq 1 ]
279
+ }
280
+
281
+ @test "promoting a rejected lesson twice writes exactly one declined row" {
282
+ # Mirrors test 8's shape for the approved path. A plain double-promote
283
+ # never reaches the guard under test: a first call that succeeds
284
+ # outright already stamps promoted_at, and the second call short-circuits
285
+ # on that before ever looking at declined.jsonl. The guard only matters
286
+ # when the FIRST call's stamp fails AFTER the declined row has already
287
+ # landed — proposals/ turning read-only mid-promote is a plausible way
288
+ # to hit that, and the same shape test 11 uses for the author_key case.
289
+ _seed_judged "decl01" "public" "rejected" "$(_split)"
290
+
291
+ chmod 0500 "$(_dir)/proposals"
292
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "decl01"
293
+ chmod 0700 "$(_dir)/proposals"
294
+ [ "$status" -ne 0 ]
295
+ [ "$(jq -r 'has("promoted_at")' "$(_dir)/proposals/decl01.json")" = "false" ]
296
+ [ "$(grep -c 'art-decl01' "$(_dir)/declined.jsonl")" -eq 1 ]
297
+ # This is the one failure path that writes something (the declined row
298
+ # above) before failing — the stderr message must say so, not return
299
+ # silently, or this state is indistinguishable from "nothing written."
300
+ [[ "$stderr" == *"terminal record written but promoted_at could not be stamped"* ]] || return 1
301
+
302
+ # The standalone re-run — the exact path the ordering guarantee exists to
303
+ # make safe.
304
+ run librarian_lesson_promote "$PROJECT_KEY" "decl01"
305
+ [ "$status" -eq 0 ]
306
+ [ "$(grep -c 'art-decl01' "$(_dir)/declined.jsonl")" -eq 1 ]
307
+ }
308
+
309
+ @test "the pool entry guard leaves an existing entry untouched when the proposal isn't yet stamped" {
310
+ # Mirrors test 14's shape for the approved path. The ordering guarantee
311
+ # means the pool write can succeed and the stamp fail afterward, so a
312
+ # standalone re-run must find that pool entry already present and skip
313
+ # rewriting it. Nothing before this pins that: "promoting twice..." above
314
+ # never reaches the pool_path guard at all — the earlier already-promoted
315
+ # check (promoted_at present on the proposal) short-circuits first, since
316
+ # a plain double-promote always stamps successfully on the first call.
317
+ _seed_judged "pool01" "org" "approved" "$(_two_passing)"
318
+
319
+ mkdir -p "$(_dir)/approved"
320
+ printf '{"sentinel":"pre-existing pool entry"}' > "$(_dir)/approved/pool01.json"
321
+
322
+ run librarian_lesson_promote "$PROJECT_KEY" "pool01"
323
+ [ "$status" -eq 0 ]
324
+ [ "$(jq -r '.sentinel // empty' "$(_dir)/approved/pool01.json")" = "pre-existing pool entry" ]
325
+ }
326
+
327
+ @test "promotion self-heals a missing approved/ directory" {
328
+ # Empty directories do not survive git, or tar/rsync without -d, so
329
+ # approved/ can be legitimately absent even though proposals/ —
330
+ # non-empty, holding this very lesson — is right there. Unlike
331
+ # librarian_lesson_append_declined, promote did not call
332
+ # librarian_lesson_storage_init; without it this lesson stayed stuck at
333
+ # "cannot write the pool entry" forever.
334
+ _seed_judged "heal01" "org" "approved" "$(_two_passing)"
335
+ rm -rf "$(_dir)/approved"
336
+
337
+ run librarian_lesson_promote "$PROJECT_KEY" "heal01"
338
+ [ "$status" -eq 0 ]
339
+ [ -f "$(_dir)/approved/heal01.json" ]
340
+ }
341
+
342
+ @test "a refusal before the approved branch creates no directory scaffolding" {
343
+ # The self-heal above must not run ahead of every refusal path. A pure
344
+ # refusal ("not judged yet," "not found," wrong status) has to write
345
+ # NOTHING, per this function's own doc comment and the spec — and
346
+ # mkdir -p is a write, even though it's idempotent and inert once made.
347
+ # setup() already ran storage_init once, so start from zero prior
348
+ # activity by removing approved/ again before seeding.
349
+ _seed_judged "refuse01" "org" "confirmed" '[]'
350
+ rm -rf "$(_dir)/approved"
351
+
352
+ run librarian_lesson_promote "$PROJECT_KEY" "refuse01"
353
+ [ "$status" -ne 0 ]
354
+ [ ! -d "$(_dir)/approved" ]
355
+ }
356
+
357
+ @test "a missing lesson is refused" {
358
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "nope01"
359
+ [ "$status" -ne 0 ]
360
+ [[ "$stderr" == *"not found"* ]] || return 1
361
+ }
362
+
363
+ @test "lessons promote lands a pool entry through the CLI" {
364
+ _seed_judged "cli01" "org" "approved" "$(_two_passing)"
365
+ run librarian_cli lessons promote "cli01" "$PROJECT_REPO"
366
+ [ "$status" -eq 0 ]
367
+ [ -f "$(_dir)/approved/cli01.json" ]
368
+ [[ "$output" == *"cli01"* ]] || return 1
369
+ }
370
+
371
+ @test "lessons promote requires a lesson id" {
372
+ run librarian_cli lessons promote
373
+ [ "$status" -ne 0 ]
374
+ [[ "$output" == *"usage:"* && "$output" == *"promote"* ]] || return 1
375
+ }
376
+
377
+ @test "lessons promote rejects an unknown flag" {
378
+ _seed_judged "cli02" "org" "approved" "$(_two_passing)"
379
+ run librarian_cli lessons promote "cli02" --force
380
+ [ "$status" -ne 0 ]
381
+ [[ "$output" == *"unknown option"* && "$output" == *"--force"* ]] || return 1
382
+ }
383
+
384
+ @test "lessons judge promotes automatically after recording a verdict" {
385
+ # The ordinary path is one command: judge, record, promote.
386
+ _seed_judged "auto01" "org" "confirmed" '[]'
387
+ run librarian_cli lessons judge "auto01" "$(_two_passing)" "$PROJECT_REPO"
388
+ [ "$status" -eq 0 ]
389
+ [ "$(jq -r '.status' "$(_dir)/proposals/auto01.json")" = "approved" ]
390
+ [ -f "$(_dir)/approved/auto01.json" ]
391
+ }
392
+
393
+ @test "an unjudged candidate does not even attempt promotion" {
394
+ # The judge returns 2 for UNJUDGED and writes nothing — but that alone
395
+ # does not pin the wiring. librarian_lesson_promote independently refuses
396
+ # a still-`confirmed` proposal ("has not been judged yet"), so a `2)` arm
397
+ # that mistakenly calls it would land on the exact same empty state as a
398
+ # `2)` arm that never calls it at all: same status, same missing pool
399
+ # file. Shadow librarian_lesson_promote with a spy that records whether
400
+ # it was invoked and delegates to the real implementation, so a call
401
+ # wired into the `2)` arm is caught even though the call itself is a
402
+ # no-op.
403
+ _seed_judged "auto02" "org" "confirmed" '[]'
404
+
405
+ local marker="${BATS_TEST_TMPDIR}/promote-invoked"
406
+ eval "$(declare -f librarian_lesson_promote | sed '1s/^librarian_lesson_promote/_real_librarian_lesson_promote/')"
407
+ librarian_lesson_promote() {
408
+ : > "$marker"
409
+ _real_librarian_lesson_promote "$@"
410
+ }
411
+
412
+ run librarian_cli lessons judge "auto02" '[{"judge_type":"standard","score":"bad","passed":true}]' "$PROJECT_REPO"
413
+ [ "$status" -eq 2 ]
414
+ [ "$(jq -r '.status' "$(_dir)/proposals/auto02.json")" = "confirmed" ]
415
+ [ ! -f "$(_dir)/approved/auto02.json" ]
416
+ [ ! -f "$marker" ]
417
+ }
418
+
419
+ @test "a judge-walk promotion failure reports, does not undo the verdict, and reconciles" {
420
+ # The other half of the reconcile property, exercised through the CLI's
421
+ # judge verb rather than promote directly. A promotion failure inside the
422
+ # automatic call must not undo the verdict the jury just recorded — the
423
+ # lesson stays `approved`, unstamped, and the standalone `lessons
424
+ # promote` verb is how it gets picked back up once the failure is fixed.
425
+ # Same corruption Task 1's `ak01` test uses to fail librarian_author_key.
426
+ _seed_judged "jwfail01" "org" "confirmed" '[]'
427
+
428
+ local secret_path
429
+ secret_path=$(librarian_author_secret_path)
430
+ mkdir -p "$(dirname "$secret_path")"
431
+ printf 'not-a-valid-secret\n' > "$secret_path"
432
+ chmod 0600 "$secret_path"
433
+
434
+ run librarian_cli lessons judge "jwfail01" "$(_two_passing)" "$PROJECT_REPO"
435
+ [ "$status" -eq 0 ]
436
+ [[ "$output" == *"Lesson jwfail01 is now approved."* ]] || return 1
437
+ [[ "$output" == *"Lesson jwfail01 was judged but not promoted; run \`lessons promote jwfail01\` to retry."* ]] || return 1
438
+ [ ! -f "$(_dir)/approved/jwfail01.json" ]
439
+ [ "$(jq -r '.status' "$(_dir)/proposals/jwfail01.json")" = "approved" ]
440
+ [ "$(jq -r 'has("promoted_at")' "$(_dir)/proposals/jwfail01.json")" = "false" ]
441
+
442
+ # Repair the secret and reconcile through the standalone verb — the
443
+ # point of the whole design.
444
+ openssl rand -hex 32 > "$secret_path"
445
+ chmod 0600 "$secret_path"
446
+ run librarian_cli lessons promote "jwfail01" "$PROJECT_REPO"
447
+ [ "$status" -eq 0 ]
448
+ [ -f "$(_dir)/approved/jwfail01.json" ]
449
+ }
450
+
451
+ _promote_refuses_shape() {
452
+ # $1 = id, $2 = jq program mutating the seeded proposal, $3 = field name
453
+ # the guard's stderr message must name — pins WHY the refusal happened,
454
+ # not just THAT it happened, so a guard that fires on the right shape but
455
+ # mislabels the field (or a check that's silently missing) still fails
456
+ # this test. Matches the file's existing refusal-test pattern (empty
457
+ # stdout + a specific stderr substring) rather than status/absence alone.
458
+ #
459
+ # Whole-token match, not substring: "id" is a substring of "candidate"
460
+ # (c-a-n-d-id-a-t-e), so a bare *"$field"* glob would accept a message
461
+ # naming candidate.claim for a null .id. Pull the guard's comma-joined
462
+ # field list out of the message, strip spaces, and check exact
463
+ # membership by delimiting both sides with commas.
464
+ local id="$1" mutate="$2" field="$3"
465
+ _seed_judged "$id" "org" "approved" "$(_two_passing)"
466
+ local p
467
+ p="$(_dir)/proposals/${id}.json"
468
+ local tmp="${BATS_TEST_TMPDIR}/${id}.json"
469
+ jq "$mutate" "$p" > "$tmp" && mv "$tmp" "$p"
470
+
471
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "$id"
472
+ [ "$status" -ne 0 ]
473
+ [ "$output" = "" ]
474
+ [ ! -f "$(_dir)/approved/${id}.json" ]
475
+ [ "$(jq -r 'has("promoted_at")' "$p")" = "false" ]
476
+
477
+ local list
478
+ list=$(printf '%s' "$stderr" | sed -n 's/.*bad or missing: \([^)]*\)).*/\1/p')
479
+ [ -n "$list" ] || return 1
480
+ printf ',%s,' "$(printf '%s' "$list" | tr -d ' ')" | grep -Fq ",${field}," || return 1
481
+ }
482
+
483
+ @test "a null judges array is refused, not read as a jury-less lesson" {
484
+ # The worst shape: consensus {judges:0, agreed:0} is byte-identical to a
485
+ # legitimate private entry, but carries source "org" — so unlike a private
486
+ # entry it WILL sync, and then fail ingest on ZConsensus.judges >= 1.
487
+ _promote_refuses_shape "mal01" '.verdict.judges = null' "verdict.judges"
488
+ }
489
+
490
+ @test "a judges object rather than an array is refused" {
491
+ _promote_refuses_shape "mal02" '.verdict.judges = {"a":{"passed":true}}' "verdict.judges"
492
+ }
493
+
494
+ @test "a proposal missing its candidate is refused" {
495
+ # Currently produces the exact 13 ZLesson keys with null claim/rationale/
496
+ # evidence/applies_to — it passes the key-set test while being empty.
497
+ _promote_refuses_shape "mal03" 'del(.candidate)' "candidate.claim"
498
+ }
499
+
500
+ @test "a proposal missing judged_at is refused" {
501
+ _promote_refuses_shape "mal04" 'del(.judged_at)' "judged_at"
502
+ }
503
+
504
+ @test "a proposal with a null id is refused" {
505
+ # .id is read by the entry mapping (id: $p.id) same as candidate/judged_at/
506
+ # verdict.judges — a null id promotes to a pool entry with "id": null,
507
+ # passing the key-set check while being exactly the kind of well-formed-
508
+ # but-wrong entry this guard exists to prevent.
509
+ _promote_refuses_shape "mal05" '.id = null' "id"
510
+ }
511
+
512
+ @test "a well-formed proposal still promotes" {
513
+ # The guard must not reject anything the pipeline actually produces.
514
+ _seed_judged "ok01" "org" "approved" "$(_two_passing)"
515
+ run librarian_lesson_promote "$PROJECT_KEY" "ok01"
516
+ [ "$status" -eq 0 ]
517
+ [ -f "$(_dir)/approved/ok01.json" ]
518
+ }
519
+
520
+ @test "an approved promotion succeeds when sourced through exactly SKILL.md's source list" {
521
+ # The runtime's ONLY entry point for the lessons CLI is SKILL.md's source
522
+ # block — not this file's own setup(), which sources librarian-author-key.sh
523
+ # itself and so could never catch a missing dependency in the shipped list.
524
+ # Parse the `source` lines straight out of SKILL.md (never hand-copy them)
525
+ # and drive one promotion in a subshell seeded with only those, so a future
526
+ # lib gaining an undeclared dependency fails THIS test instead of shipping
527
+ # with an inert runtime.
528
+ local skill="${PLUGIN_ROOT}/skills/librarian/SKILL.md"
529
+ [ -f "$skill" ]
530
+
531
+ _seed_judged "skill01" "org" "approved" "$(_two_passing)"
532
+
533
+ local script="${BATS_TEST_TMPDIR}/drive-skill-sources.sh"
534
+ {
535
+ printf '#!/usr/bin/env bash\n'
536
+ printf 'set -uo pipefail\n'
537
+ # Mirrors SKILL.md's own two-line preamble (unchanged by this branch):
538
+ # librarian-config.sh sources a sibling by the bare $PLUGIN_ROOT
539
+ # variable, not $CLAUDE_PLUGIN_ROOT, so both must be set the same way
540
+ # the skill's bash block sets them, in the same shell as the sources
541
+ # below. This is fixed boilerplate, not "the source list" itself —
542
+ # only the `source` lines that follow are parsed out of SKILL.md.
543
+ printf 'PLUGIN_ROOT=%q\n' "$PLUGIN_ROOT"
544
+ printf 'export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"\n'
545
+ grep -E '^source "\$CLAUDE_PLUGIN_ROOT/' "$skill"
546
+ printf 'librarian_lesson_promote %q %q\n' "$PROJECT_KEY" "skill01"
547
+ } > "$script"
548
+
549
+ run bash "$script"
550
+ [ "$status" -eq 0 ]
551
+ [ -f "$(_dir)/approved/skill01.json" ]
552
+ }