@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,747 @@
1
+ # Approved Pool and Declined Ledger 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:** Turn a judged proposal into its terminal record — a `ZLesson`-shaped pool entry awaiting sync, or a row in the declined ledger.
6
+
7
+ **Architecture:** One new lib function plus a small extension to an existing one. The terminal record lands first, then a `promoted_at` stamp on the proposal makes the operation detectably done. The `lessons judge` CLI calls it automatically; it is also runnable standalone to reconcile.
8
+
9
+ **Tech Stack:** bash (macOS bash 3.2 compatible), `jq`, bats.
10
+
11
+ ## Global Constraints
12
+
13
+ - **Write order is load-bearing.** The terminal record (pool entry or declined row) lands **before** `promoted_at` is stamped. Reversed, a stamp followed by a failed write leaves the lesson marked done, present nowhere, and invisible to a reconcile that keys on the stamp's absence.
14
+ - **Both writes are atomic** — temp file then `mv`. `ecosystem-a3b` is open against three existing `printf > path` sites in this plugin; do not add more. Do not fix the existing three either — that is a3b's branch.
15
+ - **`ZLesson` is a `z.strictObject`.** The pool entry's key set must be exactly: `applies_to author_key claim consensus evidence id promoted_at rationale schema_version source status superseded_by visibility`. An extra key fails ingest as surely as a missing one.
16
+ - **`source` is not `visibility`.** `private` → **`local`**, `org` → `org`, `public` → `public`. Emitting `private` fails ingest.
17
+ - A `private` entry has `consensus.judges: 0` and is deliberately **not** ingest-valid. Do not synthesize a jury to make it validate.
18
+ - Failure returns non-zero, writes **nothing**, and leaves the proposal `approved` without `promoted_at`, with a reason on stderr.
19
+ - Already promoted is a **no-op success**, not an error.
20
+ - Runtime artifacts under `$ONLOOKER_DIR`; never a hardcoded `~/.onlooker`.
21
+ - No event emission. `@onlooker-community/schema` 2.11.0 registers no `librarian.lesson.*` and the emitter exits 1 on an unknown type.
22
+ - Bash 3.2: no associative arrays, no `${var^^}`, no `mapfile`.
23
+ - **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.
24
+ - **A test asserting "nothing on stdout" alongside a required stderr reason MUST use `run --separate-stderr`** — plain `run` merges them, and the assertion becomes unsatisfiable by any correct implementation. This defect has already cost two fix rounds on prior branches.
25
+ - Assert on messages, not just exit codes, wherever a test asserts a refusal.
26
+ - American English. Commit via the `/commit` contract: `<type>(<scope>): <subject> :emoji:`, subject ≤72 chars including the emoji, why-focused body.
27
+
28
+ ## File Structure
29
+
30
+ | File | Responsibility |
31
+ |---|---|
32
+ | `plugins/librarian/scripts/lib/librarian-lesson-storage.sh` | **Modify.** Extend `librarian_lesson_append_declined` with an optional `verdict`. |
33
+ | `plugins/librarian/scripts/lib/librarian-lesson-promote.sh` | **Create.** `librarian_lesson_promote`. |
34
+ | `plugins/librarian/scripts/lib/librarian-cli.sh` | **Modify** (Task 2). Add `lessons promote`; call promote from `lessons judge`. |
35
+ | `plugins/librarian/skills/librarian/SKILL.md` | **Modify** (Task 2). Source the new lib; describe promotion in the judge walk. |
36
+ | `test/bats/librarian-lesson-promote.bats` | **Create.** |
37
+
38
+ ---
39
+
40
+ ### Task 1: Promote a judged proposal
41
+
42
+ **Files:**
43
+ - Modify: `plugins/librarian/scripts/lib/librarian-lesson-storage.sh`
44
+ - Create: `plugins/librarian/scripts/lib/librarian-lesson-promote.sh`
45
+ - Test: `test/bats/librarian-lesson-promote.bats`
46
+
47
+ **Interfaces:**
48
+ - Consumes: `librarian_lessons_dir <key>`, `librarian_lesson_storage_init <key>` (storage); `librarian_author_key <visibility>` (author-key lib).
49
+ - Produces:
50
+ - `librarian_lesson_append_declined <key> <artifact_id> <reason> [detail] [verdict_json]` — gains an optional 5th argument.
51
+ - `librarian_lesson_promote <key> <lesson_id>` → 0 on success (including the already-promoted no-op), 1 on refusal or failure.
52
+
53
+ - [ ] **Step 1: Write the failing tests**
54
+
55
+ Create `test/bats/librarian-lesson-promote.bats`:
56
+
57
+ ```bash
58
+ #!/usr/bin/env bats
59
+
60
+ setup() {
61
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
62
+ setup_test_env
63
+
64
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
65
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
66
+
67
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
68
+ mkdir -p "$PROJECT_REPO"
69
+ git -C "$PROJECT_REPO" init -q
70
+ git -C "$PROJECT_REPO" config user.email t@example.com
71
+ git -C "$PROJECT_REPO" config user.name "Test"
72
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/fixture.git
73
+
74
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
75
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
76
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
77
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
78
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
79
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-promote.sh"
80
+ librarian_config_load "$PROJECT_REPO"
81
+
82
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
83
+ librarian_lesson_storage_init "$PROJECT_KEY"
84
+
85
+ # Promotion must spend nothing. Any invocation of this stub is a failure.
86
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
87
+ mkdir -p "$STUB_BIN"
88
+ cat > "${STUB_BIN}/claude" <<'STUB'
89
+ #!/usr/bin/env bash
90
+ echo "claude was invoked but promotion must spend no tokens" >&2
91
+ exit 99
92
+ STUB
93
+ chmod +x "${STUB_BIN}/claude"
94
+ export PATH="${STUB_BIN}:${PATH}"
95
+ }
96
+
97
+ _dir() { printf '%s' "$(librarian_lessons_dir "$PROJECT_KEY")"; }
98
+
99
+ # A judged proposal. $1 = id, $2 = visibility, $3 = status,
100
+ # $4 = verdict judges JSON array.
101
+ _seed_judged() {
102
+ local id="$1" visibility="$2" status="$3" judges="$4"
103
+ local now
104
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
105
+ jq -n --arg id "$id" --arg v "$visibility" --arg s "$status" \
106
+ --arg t "$now" --argjson j "$judges" \
107
+ '{ id: $id, artifact_id: "art-\($id)", status: $s, visibility: $v,
108
+ confirmed_at: $t, judged_at: $t,
109
+ candidate: { claim: "Prefer jq -c for compact output",
110
+ rationale: "Readable diffs",
111
+ evidence: { resolution: "Applied and verified" },
112
+ applies_to: { stack: ["bash"],
113
+ scope: { kind: "versioned", versions: ">=3.2" } } },
114
+ verdict: { rubric_id: "lesson-promotion", gate_policy: "majority",
115
+ score_threshold: 0.75, aggregate_score: 0.85,
116
+ passed: ($s == "approved"), reason: "gate_passed",
117
+ judges: $j } }' \
118
+ > "$(_dir)/proposals/${id}.json"
119
+ }
120
+
121
+ _two_passing() {
122
+ printf '%s' '[{"judge_type":"standard","score":0.9,"passed":true},{"judge_type":"adversarial","score":0.8,"passed":true}]'
123
+ }
124
+ _split() {
125
+ printf '%s' '[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.75,"passed":false}]'
126
+ }
127
+
128
+ @test "an approved org lesson becomes a pool entry with exactly ZLesson's keys" {
129
+ # ZLesson is a z.strictObject: an extra key fails ingest as surely as a
130
+ # missing one, so the key SET is the assertion, not a spot-check.
131
+ _seed_judged "org01" "org" "approved" "$(_two_passing)"
132
+ run librarian_lesson_promote "$PROJECT_KEY" "org01"
133
+ [ "$status" -eq 0 ]
134
+
135
+ local keys
136
+ keys=$(jq -r 'keys_unsorted | sort | join(" ")' "$(_dir)/approved/org01.json")
137
+ [ "$keys" = "applies_to author_key claim consensus evidence id promoted_at rationale schema_version source status superseded_by visibility" ]
138
+ }
139
+
140
+ @test "the pool entry carries the mapped source and a derived consensus" {
141
+ _seed_judged "org02" "org" "approved" "$(_two_passing)"
142
+ librarian_lesson_promote "$PROJECT_KEY" "org02"
143
+
144
+ local e
145
+ e=$(cat "$(_dir)/approved/org02.json")
146
+ [ "$(printf '%s' "$e" | jq -r '.source')" = "org" ]
147
+ [ "$(printf '%s' "$e" | jq -r '.visibility')" = "org" ]
148
+ [ "$(printf '%s' "$e" | jq -r '.schema_version')" = "2" ]
149
+ [ "$(printf '%s' "$e" | jq -r '.status')" = "active" ]
150
+ [ "$(printf '%s' "$e" | jq -r '.superseded_by')" = "null" ]
151
+ [ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "2" ]
152
+ [ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "2" ]
153
+ [ "$(printf '%s' "$e" | jq -r '.author_key')" != "null" ]
154
+ printf '%s' "$e" | jq -e '.author_key | test("^[0-9a-f]{32}$")' >/dev/null || return 1
155
+ }
156
+
157
+ @test "a private lesson maps to source local with zero judges" {
158
+ # Deliberately NOT ingest-valid: ZConsensus requires judges >= 1. A
159
+ # private lesson never syncs, so it never reaches the validator. Do not
160
+ # "fix" this by synthesizing a jury that never sat.
161
+ _seed_judged "priv01" "private" "approved" '[]'
162
+ run librarian_lesson_promote "$PROJECT_KEY" "priv01"
163
+ [ "$status" -eq 0 ]
164
+
165
+ local e
166
+ e=$(cat "$(_dir)/approved/priv01.json")
167
+ [ "$(printf '%s' "$e" | jq -r '.source')" = "local" ]
168
+ [ "$(printf '%s' "$e" | jq -r '.visibility')" = "private" ]
169
+ [ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "0" ]
170
+ [ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "0" ]
171
+ }
172
+
173
+ @test "a public lesson maps to source public" {
174
+ _seed_judged "pub01" "public" "approved" "$(_two_passing)"
175
+ librarian_lesson_promote "$PROJECT_KEY" "pub01"
176
+ [ "$(jq -r '.source' "$(_dir)/approved/pub01.json")" = "public" ]
177
+ }
178
+
179
+ @test "agreed never exceeds judges" {
180
+ # The contract's own ingest rule, which its schema deliberately cannot
181
+ # express (it would need .refine(), which z.toJSONSchema drops).
182
+ _seed_judged "cnt01" "org" "approved" "$(_split)"
183
+ librarian_lesson_promote "$PROJECT_KEY" "cnt01"
184
+
185
+ local e
186
+ e=$(cat "$(_dir)/approved/cnt01.json")
187
+ [ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "2" ]
188
+ [ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "1" ]
189
+ printf '%s' "$e" | jq -e '.consensus.agreed <= .consensus.judges' >/dev/null || return 1
190
+ }
191
+
192
+ @test "a rejected lesson writes a declined row with a NESTED verdict and no pool entry" {
193
+ # .verdict must be an object, not a serialized string. A --arg/--argjson
194
+ # mistake produces a row that looks right and is unusable to a consumer,
195
+ # so assert by indexing into it rather than matching a substring.
196
+ _seed_judged "rej01" "public" "rejected" "$(_split)"
197
+ run librarian_lesson_promote "$PROJECT_KEY" "rej01"
198
+ [ "$status" -eq 0 ]
199
+
200
+ [ ! -f "$(_dir)/approved/rej01.json" ]
201
+ local row
202
+ row=$(grep 'art-rej01' "$(_dir)/declined.jsonl")
203
+ [ "$(printf '%s' "$row" | jq -r '.artifact_id')" = "art-rej01" ]
204
+ [ "$(printf '%s' "$row" | jq -r '.verdict | type')" = "object" ]
205
+ [ "$(printf '%s' "$row" | jq -r '.verdict.judges | length')" = "2" ]
206
+ [ "$(printf '%s' "$row" | jq -r '.verdict.rubric_id')" = "lesson-promotion" ]
207
+ }
208
+
209
+ @test "a stage-5 style decline still writes cleanly and has no verdict key" {
210
+ librarian_lesson_append_declined "$PROJECT_KEY" "art-t5" "transform_invalid" "malformed JSON"
211
+ local row
212
+ row=$(grep 'art-t5' "$(_dir)/declined.jsonl")
213
+ [ "$(printf '%s' "$row" | jq -r '.reason')" = "transform_invalid" ]
214
+ [ "$(printf '%s' "$row" | jq -r 'has("verdict")')" = "false" ]
215
+ }
216
+
217
+ @test "promoting twice leaves one pool entry with an unchanged promoted_at" {
218
+ _seed_judged "idem01" "org" "approved" "$(_two_passing)"
219
+ librarian_lesson_promote "$PROJECT_KEY" "idem01"
220
+ local first
221
+ first=$(cat "$(_dir)/approved/idem01.json")
222
+
223
+ run librarian_lesson_promote "$PROJECT_KEY" "idem01"
224
+ [ "$status" -eq 0 ]
225
+ [ "$(cat "$(_dir)/approved/idem01.json")" = "$first" ]
226
+ [ "$(ls "$(_dir)/approved" | grep -c idem01)" -eq 1 ]
227
+ }
228
+
229
+ @test "promotion is refused before the lesson has been judged" {
230
+ _seed_judged "conf01" "org" "confirmed" '[]'
231
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "conf01"
232
+ [ "$status" -ne 0 ]
233
+ [ "$output" = "" ]
234
+ [[ "$stderr" == *"not been judged"* ]] || return 1
235
+ [ ! -f "$(_dir)/approved/conf01.json" ]
236
+ }
237
+
238
+ @test "promotion is refused from a passed lesson, naming the status" {
239
+ _seed_judged "pass01" "org" "passed" '[]'
240
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "pass01"
241
+ [ "$status" -ne 0 ]
242
+ [[ "$stderr" == *"passed"* ]] || return 1
243
+ }
244
+
245
+ @test "a failing author_key leaves nothing written and the lesson still approved" {
246
+ # THE reconcile property. Promotion fails for reasons judging does not —
247
+ # a malformed secret, absent node, a full disk — and the lesson must stay
248
+ # exactly where a standalone re-run can pick it up.
249
+ _seed_judged "ak01" "org" "approved" "$(_two_passing)"
250
+ local secret_path
251
+ secret_path=$(librarian_author_secret_path)
252
+ mkdir -p "$(dirname "$secret_path")"
253
+ printf 'not-a-valid-secret\n' > "$secret_path"
254
+ chmod 0600 "$secret_path"
255
+
256
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "ak01"
257
+ [ "$status" -ne 0 ]
258
+ [ ! -f "$(_dir)/approved/ak01.json" ]
259
+ [ "$(jq -r 'has("promoted_at")' "$(_dir)/proposals/ak01.json")" = "false" ]
260
+ [ "$(jq -r '.status' "$(_dir)/proposals/ak01.json")" = "approved" ]
261
+ }
262
+
263
+ @test "the proposal survives promotion and carries promoted_at" {
264
+ _seed_judged "keep01" "org" "approved" "$(_two_passing)"
265
+ librarian_lesson_promote "$PROJECT_KEY" "keep01"
266
+ [ -f "$(_dir)/proposals/keep01.json" ]
267
+ [ "$(jq -r '.status' "$(_dir)/proposals/keep01.json")" = "approved" ]
268
+ printf '%s' "$(jq -r '.promoted_at' "$(_dir)/proposals/keep01.json")" \
269
+ | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' || return 1
270
+ # The per-judge detail is why the proposal is kept: the pool entry has
271
+ # only consensus counts.
272
+ [ "$(jq -r '.verdict.judges | length' "$(_dir)/proposals/keep01.json")" = "2" ]
273
+ }
274
+
275
+ @test "librarian_lesson_seen reports the artifact handled after either path" {
276
+ _seed_judged "seen01" "org" "approved" "$(_two_passing)"
277
+ librarian_lesson_promote "$PROJECT_KEY" "seen01"
278
+ run librarian_lesson_seen "$PROJECT_KEY" "art-seen01"
279
+ [ "$status" -eq 0 ]
280
+
281
+ _seed_judged "seen02" "public" "rejected" "$(_split)"
282
+ librarian_lesson_promote "$PROJECT_KEY" "seen02"
283
+ run librarian_lesson_seen "$PROJECT_KEY" "art-seen02"
284
+ [ "$status" -eq 0 ]
285
+ }
286
+
287
+ @test "a missing lesson is refused" {
288
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "nope01"
289
+ [ "$status" -ne 0 ]
290
+ [[ "$stderr" == *"not found"* ]] || return 1
291
+ }
292
+ ```
293
+
294
+ - [ ] **Step 2: Run the tests to verify they fail**
295
+
296
+ Run: `bats test/bats/librarian-lesson-promote.bats`
297
+ Expected: every test FAILS — the lib does not exist, so `source` in `setup()` errors.
298
+
299
+ - [ ] **Step 3: Extend `librarian_lesson_append_declined`**
300
+
301
+ In `plugins/librarian/scripts/lib/librarian-lesson-storage.sh`, add an optional
302
+ 5th argument. Keep the two-branch shape rather than folding it — gating
303
+ `--argjson` behind a non-empty check keeps "absent" structural, so `--argjson`
304
+ never receives an empty string:
305
+
306
+ ```bash
307
+ # Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail] [verdict_json]
308
+ #
309
+ # `verdict` is emitted with --argjson so it lands as a nested object, not a
310
+ # serialized string: consumers read .verdict.judges[].score directly. Rows
311
+ # written by the transform have no verdict and simply lack the key — a format
312
+ # failure has no jury.
313
+ librarian_lesson_append_declined() {
314
+ local key="$1"
315
+ local artifact_id="$2"
316
+ local reason="$3"
317
+ local detail="${4:-}"
318
+ local verdict="${5:-}"
319
+ [[ -z "$key" || -z "$artifact_id" || -z "$reason" ]] && return 1
320
+
321
+ librarian_lesson_storage_init "$key" || return 1
322
+
323
+ local now line
324
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
325
+ if [[ -n "$verdict" ]]; then
326
+ line=$(jq -cn \
327
+ --arg artifact_id "$artifact_id" \
328
+ --arg reason "$reason" \
329
+ --arg detail "$detail" \
330
+ --arg at "$now" \
331
+ --argjson verdict "$verdict" \
332
+ '{
333
+ artifact_id: $artifact_id,
334
+ reason: $reason,
335
+ detail: (if $detail == "" then null else $detail end),
336
+ declined_at: $at,
337
+ verdict: $verdict
338
+ }') || return 1
339
+ else
340
+ line=$(jq -cn \
341
+ --arg artifact_id "$artifact_id" \
342
+ --arg reason "$reason" \
343
+ --arg detail "$detail" \
344
+ --arg at "$now" \
345
+ '{
346
+ artifact_id: $artifact_id,
347
+ reason: $reason,
348
+ detail: (if $detail == "" then null else $detail end),
349
+ declined_at: $at
350
+ }') || return 1
351
+ fi
352
+
353
+ printf '%s\n' "$line" >> "$(librarian_lessons_dir "$key")/declined.jsonl"
354
+ }
355
+ ```
356
+
357
+ - [ ] **Step 4: Write the promote lib**
358
+
359
+ Create `plugins/librarian/scripts/lib/librarian-lesson-promote.sh`:
360
+
361
+ ```bash
362
+ #!/usr/bin/env bash
363
+ # Terminal state for the lesson-promotion pipeline.
364
+ #
365
+ # A judged proposal becomes either a ZLesson-shaped pool entry awaiting sync,
366
+ # or a row in the declined ledger. Nothing crosses the network — the sync
367
+ # service that drains the pool does not exist yet.
368
+ #
369
+ # Requires librarian-lesson-storage.sh and librarian-author-key.sh.
370
+ #
371
+ # Exposes:
372
+ # librarian_lesson_promote <key> <lesson_id>
373
+
374
+ # Map a lesson's visibility to the contract's `source` enum.
375
+ #
376
+ # NOT a rename: ZSource is local|org|public while visibility is
377
+ # private|org|public. `private` maps to `local` — the tier that never leaves
378
+ # this machine maps to the source meaning "not from anywhere else". Emitting
379
+ # "private" would fail ingest.
380
+ _librarian_lesson_source_for_visibility() {
381
+ case "${1:-}" in
382
+ private) printf 'local' ;;
383
+ org) printf 'org' ;;
384
+ public) printf 'public' ;;
385
+ *) return 1 ;;
386
+ esac
387
+ return 0
388
+ }
389
+
390
+ # Write a file atomically: temp in the same directory, then mv.
391
+ #
392
+ # ecosystem-a3b is open against three existing `printf > path` sites in this
393
+ # plugin, each of which truncates before writing. These are new sites; adding
394
+ # a fourth instance of a known bug would be a choice, not an inheritance.
395
+ _librarian_lesson_write_atomic() {
396
+ local path="$1"
397
+ local content="$2"
398
+ local tmp
399
+ tmp=$(mktemp "${path}.XXXXXX" 2>/dev/null) || return 1
400
+ printf '%s\n' "$content" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
401
+ mv "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
402
+ }
403
+
404
+ # Promote one judged proposal to its terminal record.
405
+ #
406
+ # Returns 0 on success, including the already-promoted no-op. Returns 1 on
407
+ # refusal or failure, having written NOTHING — the proposal stays `approved`
408
+ # without `promoted_at`, which is the state a standalone re-run resolves.
409
+ #
410
+ # Ordering is load-bearing: the terminal record lands BEFORE the stamp. A
411
+ # stamp followed by a failed write would leave the lesson marked done, present
412
+ # nowhere, and invisible to a reconcile that keys on the stamp's absence.
413
+ #
414
+ # Usage: librarian_lesson_promote <key> <lesson_id>
415
+ librarian_lesson_promote() {
416
+ local key="$1"
417
+ local lesson_id="$2"
418
+ [[ -z "$key" || -z "$lesson_id" ]] && return 1
419
+
420
+ local dir path
421
+ dir="$(librarian_lessons_dir "$key")"
422
+ path="${dir}/proposals/${lesson_id}.json"
423
+ [[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
424
+
425
+ # Already promoted: a no-op success, so a reconcile loop is safe to run
426
+ # over everything. Same precedent as unconfirm on a pending lesson.
427
+ if [[ "$(jq -r 'has("promoted_at")' "$path" 2>/dev/null)" == "true" ]]; then
428
+ return 0
429
+ fi
430
+
431
+ local current_status visibility
432
+ current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
433
+ visibility=$(jq -r '.visibility // ""' "$path" 2>/dev/null)
434
+
435
+ case "$current_status" in
436
+ approved|rejected) ;;
437
+ confirmed)
438
+ printf 'Lesson %s has not been judged yet; nothing to promote.\n' "$lesson_id" >&2
439
+ return 1
440
+ ;;
441
+ *)
442
+ printf 'Lesson %s cannot be promoted from status: %s\n' "$lesson_id" "$current_status" >&2
443
+ return 1
444
+ ;;
445
+ esac
446
+
447
+ local now
448
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
449
+
450
+ if [[ "$current_status" == "approved" ]]; then
451
+ local source author_key entry pool_path
452
+ source=$(_librarian_lesson_source_for_visibility "$visibility") || {
453
+ printf 'Lesson %s has an unrecognized visibility: %s\n' "$lesson_id" "$visibility" >&2
454
+ return 1
455
+ }
456
+ author_key=$(librarian_author_key "$visibility") || {
457
+ printf 'Lesson %s: cannot derive an author key; nothing written.\n' "$lesson_id" >&2
458
+ return 1
459
+ }
460
+
461
+ # Exactly ZLesson's key set — it is a strictObject, so an extra key
462
+ # fails ingest as surely as a missing one. A private entry gets
463
+ # judges: 0 and is deliberately not ingest-valid; it never syncs.
464
+ entry=$(jq -cn \
465
+ --argjson p "$(cat "$path")" \
466
+ --arg ak "$author_key" \
467
+ --arg src "$source" \
468
+ --arg now "$now" \
469
+ '{
470
+ id: $p.id,
471
+ schema_version: 2,
472
+ claim: $p.candidate.claim,
473
+ rationale: $p.candidate.rationale,
474
+ evidence: $p.candidate.evidence,
475
+ applies_to: $p.candidate.applies_to,
476
+ visibility: $p.visibility,
477
+ consensus: {
478
+ judges: (($p.verdict.judges // []) | length),
479
+ agreed: ([($p.verdict.judges // [])[] | select(.passed == true)] | length),
480
+ decided_at: $p.judged_at
481
+ },
482
+ status: "active",
483
+ superseded_by: null,
484
+ source: $src,
485
+ author_key: $ak,
486
+ promoted_at: $now
487
+ }' 2>/dev/null) || {
488
+ printf 'Lesson %s: cannot build a pool entry.\n' "$lesson_id" >&2
489
+ return 1
490
+ }
491
+
492
+ pool_path="${dir}/approved/${lesson_id}.json"
493
+ if [[ ! -f "$pool_path" ]]; then
494
+ _librarian_lesson_write_atomic "$pool_path" "$entry" || {
495
+ printf 'Lesson %s: cannot write the pool entry.\n' "$lesson_id" >&2
496
+ return 1
497
+ }
498
+ fi
499
+ else
500
+ local artifact_id reason verdict
501
+ artifact_id=$(jq -r '.artifact_id // ""' "$path" 2>/dev/null)
502
+ reason=$(jq -r '.verdict.reason // "rejected"' "$path" 2>/dev/null)
503
+ verdict=$(jq -c '.verdict // {}' "$path" 2>/dev/null)
504
+ librarian_lesson_append_declined "$key" "$artifact_id" "$reason" "" "$verdict" || {
505
+ printf 'Lesson %s: cannot append to the declined ledger.\n' "$lesson_id" >&2
506
+ return 1
507
+ }
508
+ fi
509
+
510
+ # Stamp LAST. See the ordering note above.
511
+ local updated
512
+ updated=$(jq --arg t "$now" '.promoted_at = $t' "$path" 2>/dev/null) || return 1
513
+ [[ -z "$updated" || "$updated" == "null" ]] && return 1
514
+ _librarian_lesson_write_atomic "$path" "$updated"
515
+ }
516
+ ```
517
+
518
+ - [ ] **Step 5: Run the tests to verify they pass**
519
+
520
+ Run: `bats test/bats/librarian-lesson-promote.bats`
521
+ Expected: PASS, 14/14.
522
+
523
+ - [ ] **Step 6: Prove the three load-bearing guards discriminate**
524
+
525
+ In a throwaway `git worktree` only — never the shared working tree. Report each
526
+ result separately; if any leaves the suite green, say so plainly.
527
+
528
+ ```bash
529
+ git worktree add /tmp/ap-verify HEAD
530
+ # (a) change the private mapping from "local" to "private"
531
+ # Expected: "a private lesson maps to source local" FAILS
532
+ # (b) move the promoted_at stamp BEFORE the pool write
533
+ # Expected: "a failing author_key leaves nothing written" FAILS
534
+ # (c) pass the verdict with --arg instead of --argjson
535
+ # Expected: "a rejected lesson writes a declined row with a NESTED
536
+ # verdict" FAILS on the `.verdict | type` assertion
537
+ git worktree remove --force /tmp/ap-verify
538
+ ```
539
+
540
+ - [ ] **Step 7: Lint and commit**
541
+
542
+ ```bash
543
+ npm run lint:check
544
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-promote.sh
545
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-storage.sh
546
+ git add plugins/librarian/scripts/lib/librarian-lesson-promote.sh \
547
+ plugins/librarian/scripts/lib/librarian-lesson-storage.sh \
548
+ test/bats/librarian-lesson-promote.bats
549
+ ```
550
+
551
+ Read each exit code from `$?` directly — never through a pipe.
552
+
553
+ Commit subject: `feat(librarian): land a judged lesson in its terminal home :package:`
554
+
555
+ ---
556
+
557
+ ### Task 2: The CLI surface and the walk
558
+
559
+ **Files:**
560
+ - Modify: `plugins/librarian/scripts/lib/librarian-cli.sh`
561
+ - Modify: `plugins/librarian/skills/librarian/SKILL.md`
562
+ - Test: `test/bats/librarian-lesson-promote.bats` (append)
563
+
564
+ **Interfaces:**
565
+ - Consumes: `librarian_lesson_promote <key> <lesson_id>` (Task 1), returning 0 on success including the already-promoted no-op, 1 on refusal or failure.
566
+ - Produces: `librarian_cli lessons promote <id> [cwd]`, and promotion wired into `lessons judge`.
567
+
568
+ - [ ] **Step 1: Write the failing tests**
569
+
570
+ Append to `test/bats/librarian-lesson-promote.bats`. Add to `setup()`:
571
+
572
+ ```bash
573
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
574
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-rubric.sh"
575
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-judge.sh"
576
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
577
+ ```
578
+
579
+ ```bash
580
+ @test "lessons promote lands a pool entry through the CLI" {
581
+ _seed_judged "cli01" "org" "approved" "$(_two_passing)"
582
+ run librarian_cli lessons promote "cli01" "$PROJECT_REPO"
583
+ [ "$status" -eq 0 ]
584
+ [ -f "$(_dir)/approved/cli01.json" ]
585
+ [[ "$output" == *"cli01"* ]] || return 1
586
+ }
587
+
588
+ @test "lessons promote requires a lesson id" {
589
+ run librarian_cli lessons promote
590
+ [ "$status" -ne 0 ]
591
+ [[ "$output" == *"usage:"* && "$output" == *"promote"* ]] || return 1
592
+ }
593
+
594
+ @test "lessons promote rejects an unknown flag" {
595
+ _seed_judged "cli02" "org" "approved" "$(_two_passing)"
596
+ run librarian_cli lessons promote "cli02" --force
597
+ [ "$status" -ne 0 ]
598
+ [[ "$output" == *"unknown option"* && "$output" == *"--force"* ]] || return 1
599
+ }
600
+
601
+ @test "lessons judge promotes automatically after recording a verdict" {
602
+ # The ordinary path is one command: judge, record, promote.
603
+ _seed_judged "auto01" "org" "confirmed" '[]'
604
+ run librarian_cli lessons judge "auto01" "$(_two_passing)" "$PROJECT_REPO"
605
+ [ "$status" -eq 0 ]
606
+ [ "$(jq -r '.status' "$(_dir)/proposals/auto01.json")" = "approved" ]
607
+ [ -f "$(_dir)/approved/auto01.json" ]
608
+ }
609
+
610
+ @test "an unjudged candidate is neither recorded nor promoted" {
611
+ # The judge returns 2 for UNJUDGED and writes nothing; promotion must not
612
+ # run behind it and invent a terminal state for a lesson with no verdict.
613
+ _seed_judged "auto02" "org" "confirmed" '[]'
614
+ run librarian_cli lessons judge "auto02" '[{"judge_type":"standard","score":"bad","passed":true}]' "$PROJECT_REPO"
615
+ [ "$status" -eq 2 ]
616
+ [ "$(jq -r '.status' "$(_dir)/proposals/auto02.json")" = "confirmed" ]
617
+ [ ! -f "$(_dir)/approved/auto02.json" ]
618
+ }
619
+ ```
620
+
621
+ - [ ] **Step 2: Run the tests to verify they fail**
622
+
623
+ Run: `bats test/bats/librarian-lesson-promote.bats`
624
+ Expected: the five new tests FAIL — `promote` is an unknown lessons action.
625
+
626
+ - [ ] **Step 3: Add the CLI verb**
627
+
628
+ Add to `librarian-cli.sh`, matching the sibling verbs' shape:
629
+
630
+ ```bash
631
+ # Usage: librarian_cli_lessons_promote <lesson_id> [cwd]
632
+ #
633
+ # Runnable standalone on purpose: promotion fails for reasons judging does not
634
+ # — a malformed author secret, absent node, a full disk — and a standalone run
635
+ # is how a correctly-judged, not-yet-promoted lesson gets reconciled.
636
+ librarian_cli_lessons_promote() {
637
+ local lesson_id="" cwd=""
638
+ local positional=0
639
+ while [[ $# -gt 0 ]]; do
640
+ case "$1" in
641
+ --*)
642
+ printf 'unknown option: %s\n' "$1" >&2
643
+ return 1
644
+ ;;
645
+ *)
646
+ case "$positional" in
647
+ 0) lesson_id="$1" ;;
648
+ *) cwd="$1" ;;
649
+ esac
650
+ positional=$((positional + 1))
651
+ shift
652
+ ;;
653
+ esac
654
+ done
655
+
656
+ [[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons promote <lesson_id> [cwd]\n'; return 1; }
657
+
658
+ local key
659
+ key=$(_librarian_cli_project_key "$cwd")
660
+ [[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
661
+
662
+ librarian_lesson_promote "$key" "$lesson_id" || return 1
663
+ printf 'Lesson %s promoted.\n' "$lesson_id"
664
+ }
665
+ ```
666
+
667
+ Register it in `librarian_cli_lessons`'s `case`, after `judge`:
668
+
669
+ ```bash
670
+ promote) librarian_cli_lessons_promote "$@" ;;
671
+ ```
672
+
673
+ - [ ] **Step 4: Wire promotion into the judge verb**
674
+
675
+ In `librarian_cli_lessons_judge`, inside the `case "$rc"` arm for `0` only —
676
+ **not** for `2`. An unjudged candidate has no verdict, and promoting behind it
677
+ would invent a terminal state for a lesson the jury never decided:
678
+
679
+ ```bash
680
+ 0)
681
+ printf 'Lesson %s is now %s.\n' "$lesson_id" \
682
+ "$(jq -r '.status' "$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json")"
683
+ # Promotion failing does not undo a correct verdict: report it and
684
+ # leave the lesson promotable by a standalone `lessons promote`.
685
+ librarian_lesson_promote "$key" "$lesson_id" \
686
+ || printf 'Lesson %s was judged but not promoted; run `lessons promote %s` to retry.\n' \
687
+ "$lesson_id" "$lesson_id"
688
+ ;;
689
+ ```
690
+
691
+ Source `librarian-lesson-promote.sh` alongside the existing lesson libs in
692
+ `librarian-cli.sh`'s header comment list — that file deliberately sources
693
+ nothing itself; its callers do.
694
+
695
+ - [ ] **Step 5: Update SKILL.md**
696
+
697
+ Add `source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-promote.sh"`
698
+ to the existing source block, **before** the `librarian-cli.sh` line.
699
+
700
+ In the `lessons judge` walk, note that recording a verdict also promotes, and
701
+ that a lesson reported as judged-but-not-promoted should be retried with
702
+ `librarian_cli lessons promote <id>` rather than re-judged — re-judging would
703
+ spend tokens again for a verdict that already exists.
704
+
705
+ - [ ] **Step 6: Run the tests and the full suite**
706
+
707
+ ```bash
708
+ bats test/bats/librarian-lesson-promote.bats
709
+ bats test/bats/librarian-lesson-judge.bats
710
+ bats test/bats/librarian-cli.bats
711
+ npm run lint:check
712
+ npm run test:ci
713
+ ```
714
+
715
+ Read each exit code from `$?` directly, never through a pipe.
716
+
717
+ - [ ] **Step 7: Commit**
718
+
719
+ ```bash
720
+ git add plugins/librarian/scripts/lib/librarian-cli.sh \
721
+ plugins/librarian/skills/librarian/SKILL.md \
722
+ test/bats/librarian-lesson-promote.bats
723
+ ```
724
+
725
+ Commit subject: `feat(librarian): promote a lesson from the judge walk :package:`
726
+
727
+ ---
728
+
729
+ ## Spec coverage
730
+
731
+ | Spec requirement | Task |
732
+ |---|---|
733
+ | Pool entry's key set exactly equals `ZLesson`'s | 1 |
734
+ | `private` → `local`, `org` → `org`, `public` → `public` | 1 |
735
+ | `private` entry has `judges: 0`, deliberately not ingest-valid | 1 |
736
+ | `agreed <= judges` | 1 |
737
+ | Rejected → declined row with gate reason and a nested verdict, no pool entry | 1 |
738
+ | Stage-5 declines still write, without a `verdict` key | 1 |
739
+ | `confirmed` / `pending` / `passed` refused, naming the status | 1 |
740
+ | Already promoted is a no-op with one entry and unchanged `promoted_at` | 1 |
741
+ | Failing `author_key` writes nothing, lesson stays `approved` unstamped | 1 |
742
+ | `librarian_lesson_seen` reports handled after both paths | 1 |
743
+ | Terminal record before the stamp | 1 (proved by the Step 6b injection) |
744
+ | Atomic writes | 1 |
745
+ | Promotion spends nothing | 1 (the `claude` stub in `setup()`) |
746
+ | Standalone verb and automatic call from the judge walk | 2 |
747
+ | An unjudged candidate is neither recorded nor promoted | 2 |