@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,392 @@
1
+ # Librarian Follow-up Cluster 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:** Close four small librarian defects found during the lesson-promotion epic — `ecosystem-a3b`, `qx5`, `wqd`, `mpt`.
6
+
7
+ **Architecture:** Four independent fixes in four different files. No interaction between them beyond Task 1 relocating a helper that Task 4's file already uses.
8
+
9
+ **Tech Stack:** bash (macOS bash 3.2 compatible), `jq`, bats.
10
+
11
+ ## Global Constraints
12
+
13
+ - Bash 3.2: no associative arrays, no `${var^^}`, no `mapfile`.
14
+ - **bats runs under macOS system bash 3.2, where a failing NON-FINAL `[[ ]]` does NOT fail the test.** Every non-final `[[ ]]` needs `|| return 1`; single-bracket `[ ]` gates on its own.
15
+ - **A test asserting empty stdout alongside a required stderr reason MUST use `run --separate-stderr`** — plain `run` merges them, making the assertion unsatisfiable by any correct implementation.
16
+ - **Every new test must fail when its fix is reverted.** This codebase has shipped five tests that passed for a reason other than the one they claimed, every one caught in review rather than by the suite. The recurring shape: a downstream guard makes an upstream guard's test pass regardless of whether the upstream guard exists. When you write a test, ask what it would do if the code under test were deleted.
17
+ - Failure returns non-zero, writes nothing, reason on stderr.
18
+ - No event emission. `$ONLOOKER_DIR`, never a hardcoded `~/.onlooker`. American English.
19
+ - Commit via the `/commit` contract: `<type>(<scope>): <subject> :emoji:`, subject ≤72 chars including the emoji, why-focused body.
20
+
21
+ ## File Structure
22
+
23
+ | File | Task |
24
+ |---|---|
25
+ | `plugins/librarian/scripts/lib/librarian-lesson-storage.sh` | 1 — gains the shared atomic-write helper |
26
+ | `plugins/librarian/scripts/lib/librarian-lesson-promote.sh` | 1 (helper removed), 4 (validation added) |
27
+ | `plugins/librarian/scripts/lib/librarian-lesson-review.sh` | 1 — three writes become atomic |
28
+ | `plugins/librarian/scripts/lib/librarian-lesson-judge.sh` | 2 — silent returns gain reasons |
29
+ | `plugins/librarian/scripts/lib/librarian-cli.sh` | 2 — `case "$rc"` gains a default arm |
30
+ | `plugins/librarian/scripts/lib/librarian-author-key.sh` | 3 — digest hex check |
31
+
32
+ ---
33
+
34
+ ### Task 1: Make the three proposal writes atomic (`ecosystem-a3b`)
35
+
36
+ **Files:**
37
+ - Modify: `plugins/librarian/scripts/lib/librarian-lesson-storage.sh`, `librarian-lesson-promote.sh`, `librarian-lesson-review.sh`
38
+ - Test: `test/bats/librarian-lesson-review.bats`
39
+
40
+ **Interfaces:**
41
+ - Produces: `librarian_lesson_write_atomic <path> <content>` in `librarian-lesson-storage.sh` — temp file in the same directory, then `mv`. Returns non-zero without touching `<path>` on any failure.
42
+
43
+ `confirm`, `unconfirm`, and `pass` each write with `printf '%s\n' "$updated" > "$path"`, which **truncates before writing**. An interrupted write leaves a zero-byte proposal that every verb then refuses with "unrecognized status: " and `list_pending` hides — permanently stuck, and `unconfirm`, the recovery verb, cannot recover it either.
44
+
45
+ - [ ] **Step 1: Move the helper into storage**
46
+
47
+ `_librarian_lesson_write_atomic` already exists in `librarian-lesson-promote.sh`. Move it verbatim into `librarian-lesson-storage.sh`, renamed `librarian_lesson_write_atomic` (no leading underscore — it is no longer private to promote), and delete it from promote. Update promote's two call sites to the new name.
48
+
49
+ Both `review` and `promote` already depend on storage, so this is the direction dependencies already run.
50
+
51
+ ```bash
52
+ # Write a file atomically: temp in the same directory, then mv.
53
+ #
54
+ # `printf > "$path"` truncates before writing, so an interrupted write leaves
55
+ # a zero-byte file. For a proposal that is permanently stuck: every verb
56
+ # refuses it with "unrecognized status: " and list_pending hides it, so even
57
+ # unconfirm — the recovery verb — cannot bring it back.
58
+ #
59
+ # Usage: librarian_lesson_write_atomic <path> <content>
60
+ librarian_lesson_write_atomic() {
61
+ local path="$1"
62
+ local content="$2"
63
+ local tmp
64
+ tmp=$(mktemp "${path}.XXXXXX" 2>/dev/null) || return 1
65
+ printf '%s\n' "$content" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
66
+ mv "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
67
+ }
68
+ ```
69
+
70
+ - [ ] **Step 2: Write the failing test**
71
+
72
+ Append to `test/bats/librarian-lesson-review.bats`. **A read-only-directory test would NOT discriminate** — it prevents the open entirely, so the old truncating code also leaves the original intact. The honest discriminator is that the write goes through a temp file at all, so spy on `mv`:
73
+
74
+ ```bash
75
+ @test "confirm writes the proposal atomically, never truncating in place" {
76
+ # `printf > path` truncates first, so an interrupted write leaves a
77
+ # zero-byte proposal that every verb refuses and list_pending hides —
78
+ # unrecoverable even by unconfirm. A read-only-dir test would NOT catch
79
+ # this: it blocks the open entirely, so the truncating code also leaves
80
+ # the original intact. What distinguishes atomic from not is that the
81
+ # write lands somewhere else first, so spy on the rename.
82
+ _review_setup
83
+ local id
84
+ id=$(_seed_pending)
85
+
86
+ local marker="${BATS_TEST_TMPDIR}/mv-called"
87
+ rm -f "$marker"
88
+ mv() { printf '%s -> %s\n' "$1" "$2" >> "$marker"; command mv "$@"; }
89
+
90
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
91
+
92
+ [ -f "$marker" ]
93
+ grep -q "proposals/${id}.json" "$marker" || return 1
94
+ [ "$(jq -r '.status' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/${id}.json")" = "confirmed" ]
95
+ unset -f mv
96
+ }
97
+ ```
98
+
99
+ Add the equivalent for `pass` and for `unconfirm`, each seeding the state that verb requires.
100
+
101
+ - [ ] **Step 3: Run to verify they fail**
102
+
103
+ Run: `bats test/bats/librarian-lesson-review.bats`
104
+ Expected: the three new tests FAIL — `mv` is never called by the truncating writes.
105
+
106
+ - [ ] **Step 4: Convert the three sites**
107
+
108
+ Replace each `printf '%s\n' "$updated" > "$path"` in `librarian-lesson-review.sh` (three sites: `confirm`, `unconfirm`, `pass`) with:
109
+
110
+ ```bash
111
+ librarian_lesson_write_atomic "$path" "$updated"
112
+ ```
113
+
114
+ Preserve each site's existing return semantics — the function already returns non-zero on failure, so a bare call at the end of a function propagates correctly. Where a site is not the final statement, keep the existing `|| return 1`.
115
+
116
+ - [ ] **Step 5: Run to verify they pass, then prove they discriminate**
117
+
118
+ Run: `bats test/bats/librarian-lesson-review.bats` — expect PASS.
119
+
120
+ Then in a **fresh `git worktree`**, revert one site to `printf > "$path"` and confirm only that site's test fails. Remove the worktree.
121
+
122
+ - [ ] **Step 6: Lint and commit**
123
+
124
+ ```bash
125
+ npm run lint:check
126
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-storage.sh
127
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-review.sh
128
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-promote.sh
129
+ bats test/bats/librarian-lesson-promote.bats
130
+ ```
131
+
132
+ Commit subject: `fix(librarian): stop a killed write from bricking a proposal :lock:`
133
+
134
+ ---
135
+
136
+ ### Task 2: Give the silent failures a reason (`ecosystem-qx5`)
137
+
138
+ **Files:**
139
+ - Modify: `plugins/librarian/scripts/lib/librarian-lesson-judge.sh`, `librarian-cli.sh`
140
+ - Test: `test/bats/librarian-lesson-judge.bats`
141
+
142
+ `librarian_lesson_judge` returns 1 with no output on several paths: empty `key`/`lesson_id`, `librarian_lesson_rubric_get` failing after visibility already validated, the two `jq -cn` verdict-construction failures, and the final write's `jq` failure. `librarian_cli_lessons_judge`'s `case "$rc"` has arms only for `0` and `2`, so nothing prints at either layer and the user sees an exit code with no explanation.
143
+
144
+ - [ ] **Step 1: Write the failing test**
145
+
146
+ Append to `test/bats/librarian-lesson-judge.bats`:
147
+
148
+ ```bash
149
+ @test "a rubric missing from config is refused with a reason, not silently" {
150
+ # Reachable by config drift: the visibility map still names a rubric that
151
+ # librarian.lesson_judging.rubrics no longer defines. State stays safe —
152
+ # nothing is written — but a user sees only an exit code.
153
+ _seed_confirmed "cfg01" "org"
154
+ _LIBRARIAN_CONFIG=$(printf '%s' "$_LIBRARIAN_CONFIG" | jq 'del(.librarian.lesson_judging.rubrics)')
155
+
156
+ run --separate-stderr librarian_lesson_judge "$PROJECT_KEY" "cfg01" "$(_verdicts_pass)"
157
+ [ "$status" -eq 1 ]
158
+ [ "$output" = "" ]
159
+ [[ "$stderr" == *"rubric"* ]] || return 1
160
+ [ "$(_status_of cfg01)" = "confirmed" ]
161
+ }
162
+ ```
163
+
164
+ - [ ] **Step 2: Run to verify it fails**
165
+
166
+ Expected: FAIL on the `$stderr` assertion — the path currently prints nothing.
167
+
168
+ - [ ] **Step 3: Add the reasons**
169
+
170
+ Give every bare `return 1` in `librarian_lesson_judge` a `printf … >&2` naming what failed, matching the style of its siblings (`'Lesson %s not found.\n'`, `'Lesson %s is not confirmed; its status is: %s\n'`). At minimum:
171
+
172
+ - missing `key` or `lesson_id` → `author-key`-style usage message
173
+ - `librarian_lesson_rubric_get` failing → name the rubric id that is missing from config
174
+ - each `jq -cn` verdict-construction failure → say the verdict could not be built
175
+ - the final write failure → say the verdict could not be recorded
176
+
177
+ Then add a default arm to `librarian_cli_lessons_judge`'s `case "$rc"` so an unexplained non-zero still says something:
178
+
179
+ ```bash
180
+ *)
181
+ printf 'Lesson %s could not be judged (exit %d); see above.\n' "$lesson_id" "$rc"
182
+ ;;
183
+ ```
184
+
185
+ - [ ] **Step 4: Run, then prove it discriminates**
186
+
187
+ Run the judge suite — expect PASS. Then in a **fresh `git worktree`**, revert the rubric-missing message to a bare `return 1` and confirm the new test fails. Remove the worktree.
188
+
189
+ - [ ] **Step 5: Lint and commit**
190
+
191
+ ```bash
192
+ npm run lint:check
193
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-judge.sh
194
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-cli.sh
195
+ bats test/bats/librarian-lesson-judge.bats
196
+ bats test/bats/librarian-lesson-promote.bats
197
+ ```
198
+
199
+ Commit subject: `fix(librarian): say why judging failed instead of just exiting :speech_balloon:`
200
+
201
+ ---
202
+
203
+ ### Task 3: Check the digest's shape, not just its width (`ecosystem-wqd`)
204
+
205
+ **Files:**
206
+ - Modify: `plugins/librarian/scripts/lib/librarian-author-key.sh`
207
+ - Test: `test/bats/librarian-author-key.bats`
208
+
209
+ `librarian_author_key` sanity-checks the HMAC subprocess's output with `[[ "${#digest}" -eq 64 ]]` — length only. A subprocess exiting 0 and printing 64 non-hex characters passes, gets truncated to 32, and is returned as an `author_key`, violating the interface's own contract and failing the contract's `/^[0-9a-f]{32}$/` at ingest.
210
+
211
+ - [ ] **Step 1: Write the failing test**
212
+
213
+ Append to `test/bats/librarian-author-key.bats`:
214
+
215
+ ```bash
216
+ @test "a 64-character non-hex digest is refused, not truncated and returned" {
217
+ # The width check alone cannot tell a digest from 64 arbitrary bytes.
218
+ # Requires a misbehaving node, which is a serious precondition — but a
219
+ # subprocess can misbehave for reasons other than compromise, and a
220
+ # non-hex key fails the contract's own /^[0-9a-f]{32}$/ at ingest.
221
+ _fixed_secret
222
+ local stub_bin
223
+ stub_bin="${BATS_TEST_TMPDIR}/bin"
224
+ mkdir -p "$stub_bin"
225
+ cat > "${stub_bin}/node" <<'STUB'
226
+ #!/usr/bin/env bash
227
+ printf 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'
228
+ STUB
229
+ chmod +x "${stub_bin}/node"
230
+
231
+ local old_path="$PATH"
232
+ export PATH="${stub_bin}:${PATH}"
233
+ run --separate-stderr librarian_author_key "public"
234
+ export PATH="$old_path"
235
+
236
+ [ "$status" -ne 0 ]
237
+ [ "$output" = "" ]
238
+ [[ "$stderr" == *"malformed"* ]] || return 1
239
+ }
240
+ ```
241
+
242
+ - [ ] **Step 2: Run to verify it fails**
243
+
244
+ Expected: FAIL — the current check passes a 64-character non-hex string and returns `zzzz…` truncated to 32.
245
+
246
+ - [ ] **Step 3: Anchor the check**
247
+
248
+ Replace the width-only check with an anchored hex check on the raw digest, before truncation. Keep it fail-closed — non-zero, empty stdout, reason on stderr:
249
+
250
+ ```bash
251
+ if ! printf '%s' "$digest" | grep -Eq '^[0-9a-f]{64}$'; then
252
+ printf 'author-key: HMAC returned a malformed digest (expected 64 lowercase hex characters).\n' >&2
253
+ return 1
254
+ fi
255
+ ```
256
+
257
+ **The three golden vectors must still pass unchanged.** If they move, the derivation changed — stop and report rather than updating them.
258
+
259
+ - [ ] **Step 4: Run, then prove it discriminates**
260
+
261
+ Run the author-key suite — expect PASS, golden vectors unchanged. Then in a **fresh `git worktree`**, revert to the width-only check and confirm the new test fails. Remove the worktree.
262
+
263
+ - [ ] **Step 5: Lint and commit**
264
+
265
+ ```bash
266
+ npm run lint:check
267
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-author-key.sh
268
+ bats test/bats/librarian-author-key.bats
269
+ ```
270
+
271
+ Commit subject: `fix(librarian): refuse a digest that is not 64 hex :closed_lock_with_key:`
272
+
273
+ ---
274
+
275
+ ### Task 4: Handle malformed proposals consistently (`ecosystem-mpt`)
276
+
277
+ **Files:**
278
+ - Modify: `plugins/librarian/scripts/lib/librarian-lesson-promote.sh`
279
+ - Test: `test/bats/librarian-lesson-promote.bats`
280
+
281
+ Measured shapes, all `org`/`approved`: `verdict.judges: null` **accepted** (`consensus {judges:0, agreed:0}` — byte-identical to a legitimate private entry but carrying `source: "org"`, so unlike a private entry it *will* sync and fail ingest); `verdict.judges: "two"` refused; `judges` as an **object** accepted; no `candidate` accepted with the exact 13 keys and null values; no `judged_at` accepted with `decided_at: null`.
282
+
283
+ Three accepted, one refused, for the same class of corruption.
284
+
285
+ - [ ] **Step 1: Write the failing tests**
286
+
287
+ Append to `test/bats/librarian-lesson-promote.bats`, one per shape:
288
+
289
+ ```bash
290
+ _promote_refuses_shape() {
291
+ # $1 = id, $2 = jq program mutating the seeded proposal
292
+ local id="$1" mutate="$2"
293
+ _seed_judged "$id" "org" "approved" "$(_two_passing)"
294
+ local p
295
+ p="$(_dir)/proposals/${id}.json"
296
+ local tmp="${BATS_TEST_TMPDIR}/${id}.json"
297
+ jq "$mutate" "$p" > "$tmp" && mv "$tmp" "$p"
298
+
299
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "$id"
300
+ [ "$status" -ne 0 ]
301
+ [ ! -f "$(_dir)/approved/${id}.json" ]
302
+ [ "$(jq -r 'has("promoted_at")' "$p")" = "false" ]
303
+ }
304
+
305
+ @test "a null judges array is refused, not read as a jury-less lesson" {
306
+ # The worst shape: consensus {judges:0, agreed:0} is byte-identical to a
307
+ # legitimate private entry, but carries source "org" — so unlike a private
308
+ # entry it WILL sync, and then fail ingest on ZConsensus.judges >= 1.
309
+ _promote_refuses_shape "mal01" '.verdict.judges = null'
310
+ }
311
+
312
+ @test "a judges object rather than an array is refused" {
313
+ _promote_refuses_shape "mal02" '.verdict.judges = {"a":{"passed":true}}'
314
+ }
315
+
316
+ @test "a proposal missing its candidate is refused" {
317
+ # Currently produces the exact 13 ZLesson keys with null claim/rationale/
318
+ # evidence/applies_to — it passes the key-set test while being empty.
319
+ _promote_refuses_shape "mal03" 'del(.candidate)'
320
+ }
321
+
322
+ @test "a proposal missing judged_at is refused" {
323
+ _promote_refuses_shape "mal04" 'del(.judged_at)'
324
+ }
325
+
326
+ @test "a well-formed proposal still promotes" {
327
+ # The guard must not reject anything the pipeline actually produces.
328
+ _seed_judged "ok01" "org" "approved" "$(_two_passing)"
329
+ run librarian_lesson_promote "$PROJECT_KEY" "ok01"
330
+ [ "$status" -eq 0 ]
331
+ [ -f "$(_dir)/approved/ok01.json" ]
332
+ }
333
+ ```
334
+
335
+ - [ ] **Step 2: Run to verify they fail**
336
+
337
+ Expected: `mal01`, `mal02`, `mal03`, `mal04` FAIL (currently accepted); `ok01` passes.
338
+
339
+ - [ ] **Step 3: Validate what promote actually reads**
340
+
341
+ Add one guard in `librarian_lesson_promote`, before building the entry, checking exactly the fields the mapping consumes. Emit one message naming what is missing:
342
+
343
+ ```bash
344
+ # Validate the fields the entry mapping reads. Without this, a corrupt
345
+ # proposal produces a well-formed-but-empty pool entry that passes the
346
+ # key-set check and fails at ingest — and a null judges array yields a
347
+ # consensus byte-identical to a legitimate private entry while carrying a
348
+ # syncing source.
349
+ local missing
350
+ missing=$(jq -r '
351
+ [ (if (.candidate.claim | type) != "string" then "candidate.claim" else empty end),
352
+ (if (.candidate.rationale | type) != "string" then "candidate.rationale" else empty end),
353
+ (if (.candidate.evidence | type) != "object" then "candidate.evidence" else empty end),
354
+ (if (.candidate.applies_to | type) != "object" then "candidate.applies_to" else empty end),
355
+ (if (.judged_at | type) != "string" then "judged_at" else empty end),
356
+ (if (.verdict.judges | type) != "array" then "verdict.judges" else empty end)
357
+ ] | join(", ")' "$path" 2>/dev/null) || missing="unreadable"
358
+ if [[ -n "$missing" ]]; then
359
+ printf 'Lesson %s is malformed; cannot promote (bad or missing: %s).\n' \
360
+ "$lesson_id" "$missing" >&2
361
+ return 1
362
+ fi
363
+ ```
364
+
365
+ Place it in the `approved` branch only — a `rejected` proposal writes a declined row and never builds an entry, so requiring `candidate` fields of it would refuse rejections the pipeline legitimately produces. **Confirm that against the rejected-path tests before committing.**
366
+
367
+ - [ ] **Step 4: Run, then prove it discriminates**
368
+
369
+ Run the promote suite — expect PASS including the pre-existing 25. Then in a **fresh `git worktree`**, delete the guard and confirm exactly the four new refusal tests fail. Remove the worktree.
370
+
371
+ - [ ] **Step 5: Lint, full suite, and commit**
372
+
373
+ ```bash
374
+ npm run lint:check
375
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-promote.sh
376
+ npm run test:ci
377
+ ```
378
+
379
+ Read each exit code from `$?` directly — never through a pipe.
380
+
381
+ Commit subject: `fix(librarian): refuse a corrupt proposal instead of promoting it :shield:`
382
+
383
+ ---
384
+
385
+ ## Spec coverage
386
+
387
+ | Bead | Acceptance | Task |
388
+ |---|---|---|
389
+ | `a3b` | All three proposal writes atomic; an interrupted write leaves the prior proposal intact | 1 |
390
+ | `qx5` | Every `return 1` from `librarian_lesson_judge` produces a message identifying the failure | 2 |
391
+ | `wqd` | A subprocess returning 64 non-hex characters is refused, not truncated and returned; golden vectors unchanged | 3 |
392
+ | `mpt` | All malformed shapes produce the same outcome rather than three accepted and one refused | 4 |