@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,900 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # Lesson confirmation: the human intent filter between the transform and the
4
+ # jury. Assertions use [ ] or `|| return 1` so every one gates under bash 3.2.
5
+
6
+ setup() {
7
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
8
+ setup_test_env
9
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
10
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
11
+ # shellcheck disable=SC1091
12
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
13
+ }
14
+
15
+ _evidence() {
16
+ printf '%s' '{"artifact_ids":["01KZ45MKAM734ZS7JK24D2DK0R"],"session_ids":["s1"],"project_key":"6a7678979e31","observed_at":"2026-08-03T15:59:48Z","resolution":"Pin vitest to 3.x."}'
17
+ }
18
+
19
+ # Usage: _candidate <scope_json>
20
+ _candidate() {
21
+ jq -cn --argjson ev "$(_evidence)" --argjson scope "$1" \
22
+ '{claim: "c", rationale: "r", evidence: $ev,
23
+ applies_to: {stack: ["vite"], scope: $scope, file_patterns: [], task_kinds: []}}'
24
+ }
25
+
26
+ _versioned() { printf '%s' '{"kind":"versioned","versions":{"vite":"<6"}}'; }
27
+ _indep() { printf '%s' '{"kind":"version_independent","justification":"git aborts checkout on a dirty tree regardless of version."}'; }
28
+
29
+ @test "confirmed validator accepts a versioned candidate" {
30
+ run librarian_lesson_validate_confirmed "$(_candidate "$(_versioned)")"
31
+ [ "$status" -eq 0 ]
32
+ }
33
+
34
+ @test "confirmed validator accepts version_independent with a justification" {
35
+ run librarian_lesson_validate_confirmed "$(_candidate "$(_indep)")"
36
+ [ "$status" -eq 0 ]
37
+ }
38
+
39
+ @test "confirmed validator rejects version_independent with an empty justification" {
40
+ run librarian_lesson_validate_confirmed \
41
+ "$(_candidate '{"kind":"version_independent","justification":""}')"
42
+ [ "$status" -eq 1 ]
43
+ }
44
+
45
+ @test "confirmed validator rejects version_independent with no justification key" {
46
+ run librarian_lesson_validate_confirmed \
47
+ "$(_candidate '{"kind":"version_independent"}')"
48
+ [ "$status" -eq 1 ]
49
+ }
50
+
51
+ @test "confirmed validator rejects an unknown scope kind" {
52
+ run librarian_lesson_validate_confirmed \
53
+ "$(_candidate '{"kind":"whenever","justification":"x"}')"
54
+ [ "$status" -eq 1 ]
55
+ }
56
+
57
+ @test "confirmed validator still enforces the range pattern on versioned scope" {
58
+ run librarian_lesson_validate_confirmed \
59
+ "$(_candidate '{"kind":"versioned","versions":{"vite":"^5.4.21"}}')"
60
+ [ "$status" -eq 1 ]
61
+ }
62
+
63
+ @test "confirmed validator still enforces provenance" {
64
+ candidate=$(_candidate "$(_versioned)" | jq -c '.evidence.session_ids = [""]')
65
+ run librarian_lesson_validate_confirmed "$candidate"
66
+ [ "$status" -eq 1 ]
67
+ }
68
+
69
+ @test "confirmed validator still enforces the versions-subset-of-stack rule" {
70
+ candidate=$(_candidate '{"kind":"versioned","versions":{"vite":"<6","vitest":">=4"}}')
71
+ run librarian_lesson_validate_confirmed "$candidate"
72
+ [ "$status" -eq 1 ]
73
+ }
74
+
75
+ @test "the transform validator still refuses version_independent" {
76
+ run librarian_lesson_validate_candidate "$(_candidate "$(_indep)")"
77
+ [ "$status" -eq 1 ]
78
+ }
79
+
80
+ _review_setup() {
81
+ # shellcheck disable=SC1091
82
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
83
+ # shellcheck disable=SC1091
84
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
85
+ # shellcheck disable=SC1091
86
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
87
+ # shellcheck disable=SC1091
88
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
89
+ # shellcheck disable=SC1091
90
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
91
+ # shellcheck disable=SC1091
92
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
93
+ # shellcheck disable=SC1091
94
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-rubric.sh"
95
+ # shellcheck disable=SC1091
96
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-judge.sh"
97
+
98
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
99
+ mkdir -p "$PROJECT_REPO"
100
+ git -C "$PROJECT_REPO" init -q
101
+ git -C "$PROJECT_REPO" config user.email t@example.com
102
+ git -C "$PROJECT_REPO" config user.name "Test"
103
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/lesson-review.git
104
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
105
+ [ -n "$PROJECT_KEY" ]
106
+ LESSONS_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}/lessons"
107
+ librarian_lesson_storage_init "$PROJECT_KEY"
108
+ librarian_config_load "$PROJECT_REPO"
109
+ }
110
+
111
+ # Seeds one pending proposal and prints its id.
112
+ _seed_pending() {
113
+ librarian_lesson_write_proposal "$PROJECT_KEY" \
114
+ "$(_candidate "$(_versioned)")" "01KZ45MKAM734ZS7JK24D2DK0R"
115
+ }
116
+
117
+ @test "list_pending returns an empty array when the queue is empty" {
118
+ _review_setup
119
+ run librarian_lesson_list_pending "$PROJECT_KEY"
120
+ [ "$status" -eq 0 ]
121
+ [ "$output" = "[]" ]
122
+ }
123
+
124
+ @test "list_pending returns a seeded pending proposal" {
125
+ _review_setup
126
+ id=$(_seed_pending)
127
+ run librarian_lesson_list_pending "$PROJECT_KEY"
128
+ [ "$status" -eq 0 ]
129
+ printf '%s' "$output" | jq -e --arg id "$id" 'length == 1 and .[0].id == $id' >/dev/null
130
+ }
131
+
132
+ @test "list_by_status selects on the status it is handed, not just pending" {
133
+ _review_setup
134
+ a=$(_seed_pending)
135
+ b=$(_seed_pending)
136
+ librarian_lesson_confirm "$PROJECT_KEY" "$a" "org"
137
+ run librarian_lesson_list_by_status "$PROJECT_KEY" "confirmed"
138
+ [ "$status" -eq 0 ]
139
+ printf '%s' "$output" \
140
+ | jq -e --arg a "$a" --arg b "$b" \
141
+ 'length == 1 and .[0].id == $a and .[0].id != $b' >/dev/null
142
+ }
143
+
144
+ @test "confirm records status and visibility on the proposal" {
145
+ _review_setup
146
+ id=$(_seed_pending)
147
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
148
+ [ "$status" -eq 0 ]
149
+ jq -e '.status == "confirmed" and .visibility == "org"' \
150
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
151
+ }
152
+
153
+ @test "confirm refuses an empty visibility and leaves the proposal pending" {
154
+ _review_setup
155
+ id=$(_seed_pending)
156
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" ""
157
+ [ "$status" -ne 0 ]
158
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
159
+ }
160
+
161
+ @test "confirm refuses an unknown visibility" {
162
+ _review_setup
163
+ id=$(_seed_pending)
164
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "everyone"
165
+ [ "$status" -ne 0 ]
166
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
167
+ }
168
+
169
+ @test "confirm with a justification rewrites scope to version_independent" {
170
+ _review_setup
171
+ id=$(_seed_pending)
172
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
173
+ [ "$status" -eq 0 ]
174
+ jq -e '.candidate.applies_to.scope.kind == "version_independent"
175
+ and (.candidate.applies_to.scope.justification | length) > 0' \
176
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
177
+ }
178
+
179
+ @test "confirm refuses version_independent at private visibility" {
180
+ _review_setup
181
+ id=$(_seed_pending)
182
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "private" "stable across versions."
183
+ [ "$status" -ne 0 ]
184
+ jq -e '.status == "pending" and .candidate.applies_to.scope.kind == "versioned"' \
185
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
186
+ }
187
+
188
+ @test "confirm refuses a candidate that already carries version_independent scope at private visibility" {
189
+ _review_setup
190
+ # Seeded directly with version_independent scope and no justification
191
+ # argument on this call — librarian_lesson_write_proposal performs no
192
+ # validation, so this is reachable without going through the input-side
193
+ # guard at all. The guard must key on the resulting state, not on
194
+ # whether THIS call passed a justification.
195
+ id=$(librarian_lesson_write_proposal "$PROJECT_KEY" \
196
+ "$(_candidate "$(_indep)")" "01KZ45MKAM734ZS7JK24D2DK0R")
197
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "private"
198
+ [ "$status" -ne 0 ]
199
+ jq -e '.status == "pending" and .candidate.applies_to.scope.kind == "version_independent"' \
200
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
201
+ }
202
+
203
+ @test "confirm accepts a candidate that already carries version_independent scope at org visibility" {
204
+ _review_setup
205
+ id=$(librarian_lesson_write_proposal "$PROJECT_KEY" \
206
+ "$(_candidate "$(_indep)")" "01KZ45MKAM734ZS7JK24D2DK0R")
207
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
208
+ [ "$status" -eq 0 ]
209
+ jq -e '.status == "confirmed" and .candidate.applies_to.scope.kind == "version_independent"' \
210
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
211
+ }
212
+
213
+ @test "confirm refuses a candidate that fails the confirmed validator" {
214
+ _review_setup
215
+ id=$(_seed_pending)
216
+ # Corrupt the stored candidate so validation must reject it.
217
+ tmp="${BATS_TEST_TMPDIR}/bad.json"
218
+ jq '.candidate.evidence.resolution = ""' "${LESSONS_DIR}/proposals/${id}.json" > "$tmp"
219
+ mv "$tmp" "${LESSONS_DIR}/proposals/${id}.json"
220
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
221
+ [ "$status" -ne 0 ]
222
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
223
+ }
224
+
225
+ @test "pass marks the proposal and appends one ledger line" {
226
+ _review_setup
227
+ id=$(_seed_pending)
228
+ run librarian_lesson_pass "$PROJECT_KEY" "$id" "not worth sharing"
229
+ [ "$status" -eq 0 ]
230
+ jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
231
+ [ "$(wc -l < "${LESSONS_DIR}/passed.jsonl")" -eq 1 ]
232
+ tail -n 1 "${LESSONS_DIR}/passed.jsonl" \
233
+ | jq -e --arg id "$id" '.lesson_id == $id and .reason == "not worth sharing"' >/dev/null
234
+ }
235
+
236
+ @test "a passed proposal keeps its file so the artifact stays seen" {
237
+ _review_setup
238
+ id=$(_seed_pending)
239
+ librarian_lesson_pass "$PROJECT_KEY" "$id"
240
+ [ -f "${LESSONS_DIR}/proposals/${id}.json" ]
241
+ run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
242
+ [ "$status" -eq 0 ]
243
+ }
244
+
245
+ @test "this stage never writes to declined.jsonl" {
246
+ _review_setup
247
+ id=$(_seed_pending)
248
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
249
+ id2=$(_seed_pending)
250
+ librarian_lesson_pass "$PROJECT_KEY" "$id2"
251
+ [ ! -f "${LESSONS_DIR}/declined.jsonl" ]
252
+ }
253
+
254
+ @test "confirming never invokes a model" {
255
+ _review_setup
256
+ # A claude on PATH that fails loudly if called at all.
257
+ stub="${BATS_TEST_TMPDIR}/bin"
258
+ mkdir -p "$stub"
259
+ printf '#!/usr/bin/env bash\necho "MODEL WAS INVOKED" >&2\nexit 42\n' > "${stub}/claude"
260
+ chmod +x "${stub}/claude"
261
+ PATH="${stub}:${PATH}"
262
+
263
+ id=$(_seed_pending)
264
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
265
+ [ "$status" -eq 0 ]
266
+ [[ "$output" != *"MODEL WAS INVOKED"* ]] || return 1
267
+ jq -e '.status == "confirmed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
268
+ }
269
+
270
+ @test "list_pending excludes confirmed and passed proposals" {
271
+ _review_setup
272
+ a=$(_seed_pending); b=$(_seed_pending); c=$(_seed_pending)
273
+ librarian_lesson_confirm "$PROJECT_KEY" "$a" "org"
274
+ librarian_lesson_pass "$PROJECT_KEY" "$b"
275
+ run librarian_lesson_list_pending "$PROJECT_KEY"
276
+ printf '%s' "$output" | jq -e --arg c "$c" 'length == 1 and .[0].id == $c' >/dev/null
277
+ }
278
+
279
+ @test "confirming an already-confirmed lesson with the same visibility succeeds and does not write twice" {
280
+ _review_setup
281
+ id=$(_seed_pending)
282
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
283
+ before=$(cat "${LESSONS_DIR}/proposals/${id}.json")
284
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
285
+ [ "$status" -eq 0 ]
286
+ after=$(cat "${LESSONS_DIR}/proposals/${id}.json")
287
+ [ "$before" = "$after" ]
288
+ }
289
+
290
+ @test "confirming an already-confirmed lesson with a different visibility is refused" {
291
+ _review_setup
292
+ id=$(_seed_pending)
293
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
294
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
295
+ [ "$status" -ne 0 ]
296
+ jq -e '.status == "confirmed" and .visibility == "org"' \
297
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
298
+ }
299
+
300
+ @test "confirming a passed lesson is refused" {
301
+ _review_setup
302
+ id=$(_seed_pending)
303
+ librarian_lesson_pass "$PROJECT_KEY" "$id"
304
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
305
+ [ "$status" -ne 0 ]
306
+ jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
307
+ }
308
+
309
+ @test "passing an already-passed lesson does not append a second ledger line" {
310
+ _review_setup
311
+ id=$(_seed_pending)
312
+ librarian_lesson_pass "$PROJECT_KEY" "$id" "not worth sharing"
313
+ run librarian_lesson_pass "$PROJECT_KEY" "$id" "still not worth sharing"
314
+ [ "$status" -eq 0 ]
315
+ [ "$(wc -l < "${LESSONS_DIR}/passed.jsonl")" -eq 1 ]
316
+ tail -n 1 "${LESSONS_DIR}/passed.jsonl" \
317
+ | jq -e --arg id "$id" '.lesson_id == $id and .reason == "not worth sharing"' >/dev/null
318
+ }
319
+
320
+ @test "passing a confirmed lesson is refused" {
321
+ _review_setup
322
+ id=$(_seed_pending)
323
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
324
+ run librarian_lesson_pass "$PROJECT_KEY" "$id"
325
+ [ "$status" -ne 0 ]
326
+ jq -e '.status == "confirmed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
327
+ [ ! -f "${LESSONS_DIR}/passed.jsonl" ]
328
+ }
329
+
330
+ @test "list_pending still returns the good entries when the directory also contains a truncated file, a bare-number file, and an empty file" {
331
+ _review_setup
332
+ id=$(_seed_pending)
333
+ printf '{"id":"bad","status":"pending"' > "${LESSONS_DIR}/proposals/truncated.json"
334
+ printf '123' > "${LESSONS_DIR}/proposals/barenum.json"
335
+ : > "${LESSONS_DIR}/proposals/empty.json"
336
+ run librarian_lesson_list_pending "$PROJECT_KEY"
337
+ [ "$status" -eq 0 ]
338
+ printf '%s' "$output" | jq -e --arg id "$id" 'length == 1 and .[0].id == $id' >/dev/null
339
+ }
340
+
341
+ @test "confirming with a justification stores scope with exactly kind and justification, no versions key surviving" {
342
+ _review_setup
343
+ id=$(_seed_pending)
344
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
345
+ [ "$status" -eq 0 ]
346
+ jq -e '.candidate.applies_to.scope | keys == ["justification", "kind"]' \
347
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
348
+ }
349
+
350
+ @test "the stored candidate still passes the confirmed validator after a version_independent confirm" {
351
+ _review_setup
352
+ id=$(_seed_pending)
353
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
354
+ stored=$(jq -c '.candidate' "${LESSONS_DIR}/proposals/${id}.json")
355
+ run librarian_lesson_validate_confirmed "$stored"
356
+ [ "$status" -eq 0 ]
357
+ }
358
+
359
+ @test "an identical repeat confirm with a justification is idempotent and succeeds" {
360
+ _review_setup
361
+ id=$(_seed_pending)
362
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
363
+ before=$(cat "${LESSONS_DIR}/proposals/${id}.json")
364
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
365
+ [ "$status" -eq 0 ]
366
+ after=$(cat "${LESSONS_DIR}/proposals/${id}.json")
367
+ [ "$before" = "$after" ]
368
+ }
369
+
370
+ @test "a repeat confirm with a different justification is refused" {
371
+ _review_setup
372
+ id=$(_seed_pending)
373
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
374
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "a completely different justification."
375
+ [ "$status" -ne 0 ]
376
+ jq -e '.candidate.applies_to.scope.justification == "git behavior is stable across versions."' \
377
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
378
+ }
379
+
380
+ _cli_setup() {
381
+ _review_setup
382
+ # shellcheck disable=SC1091
383
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
384
+ # shellcheck disable=SC1091
385
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
386
+ # shellcheck disable=SC1091
387
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
388
+ }
389
+
390
+ @test "lessons status reports zero on an empty queue" {
391
+ _cli_setup
392
+ run librarian_cli lessons status "$PROJECT_REPO"
393
+ [ "$status" -eq 0 ]
394
+ [[ "$output" == *"0"* ]] || return 1
395
+ }
396
+
397
+ @test "lessons list shows a pending lesson" {
398
+ _cli_setup
399
+ id=$(_seed_pending)
400
+ run librarian_cli lessons list "$PROJECT_REPO"
401
+ [ "$status" -eq 0 ]
402
+ [[ "$output" == *"$id"* ]] || return 1
403
+ }
404
+
405
+ @test "lessons list --confirmed shows a confirmed lesson the pending list hides" {
406
+ _cli_setup
407
+ id=$(_seed_pending)
408
+ librarian_cli lessons confirm "$id" public "$PROJECT_REPO" >/dev/null
409
+
410
+ run librarian_cli lessons list "$PROJECT_REPO"
411
+ [[ "$output" == *"No pending lessons."* ]] || return 1
412
+
413
+ run librarian_cli lessons list --confirmed "$PROJECT_REPO"
414
+ [ "$status" -eq 0 ]
415
+ [[ "$output" == *"$id"* ]] || return 1
416
+ }
417
+
418
+ @test "lessons list --confirmed reports an empty set of its own" {
419
+ _cli_setup
420
+ _seed_pending
421
+ # A pending lesson exists, so an implementation that ignored the flag
422
+ # would print that row instead of the empty state.
423
+ run librarian_cli lessons list --confirmed "$PROJECT_REPO"
424
+ [ "$status" -eq 0 ]
425
+ [[ "$output" == *"No confirmed lessons."* ]] || return 1
426
+ }
427
+
428
+ @test "lessons list rejects an unknown flag instead of reading it as cwd" {
429
+ _cli_setup
430
+ _seed_pending
431
+ cd "$PROJECT_REPO" || return 1
432
+ run librarian_cli lessons list --passed
433
+ [ "$status" -ne 0 ]
434
+ # cd'd into the repo first so the cwd fallback resolves a real key: without
435
+ # the guard this would print the pending queue and exit 0, not a path error.
436
+ [[ "$output" == *"unknown option"* && "$output" == *"--passed"* ]] || return 1
437
+ }
438
+
439
+ @test "lessons show renders the visibility a confirmed lesson sits at" {
440
+ _cli_setup
441
+ id=$(_seed_pending)
442
+ librarian_cli lessons confirm "$id" public "$PROJECT_REPO" >/dev/null
443
+ run librarian_cli lessons show "$id" "$PROJECT_REPO"
444
+ [ "$status" -eq 0 ]
445
+ [[ "$output" == *"visibility: public"* ]] || return 1
446
+ }
447
+
448
+ @test "lessons show renders a dash for a pending lesson's visibility" {
449
+ _cli_setup
450
+ id=$(_seed_pending)
451
+ run librarian_cli lessons show "$id" "$PROJECT_REPO"
452
+ [ "$status" -eq 0 ]
453
+ [[ "$output" == *"visibility: —"* ]] || return 1
454
+ [[ "$output" != *"null"* ]] || return 1
455
+ }
456
+
457
+ @test "lessons show prints the claim" {
458
+ _cli_setup
459
+ claim="pin vitest below six to avoid the esm regression"
460
+ candidate=$(_candidate "$(_versioned)" | jq -c --arg c "$claim" '.claim = $c')
461
+ id=$(librarian_lesson_write_proposal "$PROJECT_KEY" "$candidate" "01KZ45MKAM734ZS7JK24D2DK0R")
462
+ run librarian_cli lessons show "$id" "$PROJECT_REPO"
463
+ [ "$status" -eq 0 ]
464
+ [[ "$output" == *"claim: ${claim}"* ]] || return 1
465
+ }
466
+
467
+ @test "lessons confirm requires a visibility argument" {
468
+ _cli_setup
469
+ id=$(_seed_pending)
470
+ run librarian_cli lessons confirm "$id"
471
+ [ "$status" -ne 0 ]
472
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
473
+ }
474
+
475
+ @test "lessons confirm sets status and visibility" {
476
+ _cli_setup
477
+ id=$(_seed_pending)
478
+ run librarian_cli lessons confirm "$id" public "$PROJECT_REPO"
479
+ [ "$status" -eq 0 ]
480
+ jq -e '.status == "confirmed" and .visibility == "public"' \
481
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
482
+ }
483
+
484
+ @test "lessons confirm treats a bare trailing positional as cwd, not justification" {
485
+ _cli_setup
486
+ id=$(_seed_pending)
487
+ run librarian_cli lessons confirm "$id" org "$PROJECT_REPO"
488
+ [ "$status" -eq 0 ]
489
+ jq -e '.status == "confirmed" and .candidate.applies_to.scope.kind == "versioned"' \
490
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
491
+ }
492
+
493
+ @test "lessons confirm threads --justification through to a version_independent rewrite" {
494
+ _cli_setup
495
+ id=$(_seed_pending)
496
+ run librarian_cli lessons confirm "$id" org --justification "git behavior is stable across versions." "$PROJECT_REPO"
497
+ [ "$status" -eq 0 ]
498
+ jq -e '.candidate.applies_to.scope.kind == "version_independent"' \
499
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
500
+ }
501
+
502
+ @test "lessons confirm refuses a whitespace-only --justification instead of flipping scope" {
503
+ _cli_setup
504
+ id=$(_seed_pending)
505
+ run librarian_cli lessons confirm "$id" org --justification " " "$PROJECT_REPO"
506
+ [ "$status" -ne 0 ]
507
+ jq -e '.status == "pending" and .candidate.applies_to.scope.kind == "versioned"' \
508
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
509
+ }
510
+
511
+ @test "lessons confirm refuses an unknown flag instead of swallowing it" {
512
+ _cli_setup
513
+ id=$(_seed_pending)
514
+ run librarian_cli lessons confirm "$id" org --foo "$PROJECT_REPO"
515
+ [ "$status" -ne 0 ]
516
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
517
+ }
518
+
519
+ @test "lessons confirm refuses a typo'd --justifcation instead of confirming with an empty justification" {
520
+ _cli_setup
521
+ id=$(_seed_pending)
522
+ run librarian_cli lessons confirm "$id" org --justifcation "reason" "$PROJECT_REPO"
523
+ [ "$status" -ne 0 ]
524
+ jq -e '.status == "pending" and .candidate.applies_to.scope.kind == "versioned"' \
525
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
526
+ }
527
+
528
+ @test "lessons pass marks it passed" {
529
+ _cli_setup
530
+ id=$(_seed_pending)
531
+ run librarian_cli lessons pass "$id" "" "$PROJECT_REPO"
532
+ [ "$status" -eq 0 ]
533
+ jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
534
+ }
535
+
536
+ @test "lessons pass refuses a flag-shaped reason instead of storing it literally" {
537
+ _cli_setup
538
+ id=$(_seed_pending)
539
+ # The likeliest typo for confirm's --justification. Positionally this
540
+ # would otherwise land as the literal reason, with cwd falling back to
541
+ # $(pwd) — and passed.jsonl is append-only, so a bad reason here has no
542
+ # CLI path back (see librarian_lesson_pass's `passed) return 0` guard).
543
+ # cd into PROJECT_REPO first so a missing guard would actually resolve a
544
+ # real project key via the $(pwd) fallback, the same way the reported
545
+ # bug does — running this from an arbitrary bats tmpdir would mask a
546
+ # missing guard behind an unrelated "project key not found".
547
+ cd "$PROJECT_REPO" || return 1
548
+ run librarian_cli lessons pass "$id" --reason
549
+ [ "$status" -ne 0 ]
550
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
551
+ [ ! -f "${LESSONS_DIR}/passed.jsonl" ]
552
+ }
553
+
554
+ @test "lessons pass still works with a real reason and an explicit cwd" {
555
+ _cli_setup
556
+ id=$(_seed_pending)
557
+ run librarian_cli lessons pass "$id" "a real reason" "$PROJECT_REPO"
558
+ [ "$status" -eq 0 ]
559
+ jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
560
+ tail -n 1 "${LESSONS_DIR}/passed.jsonl" \
561
+ | jq -e --arg id "$id" '.lesson_id == $id and .reason == "a real reason"' >/dev/null
562
+ }
563
+
564
+ @test "lessons defer leaves it pending" {
565
+ _cli_setup
566
+ id=$(_seed_pending)
567
+ run librarian_cli lessons defer "$id" "$PROJECT_REPO"
568
+ [ "$status" -eq 0 ]
569
+ jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
570
+ }
571
+
572
+ @test "an unknown lessons verb is rejected" {
573
+ _cli_setup
574
+ run librarian_cli lessons frobnicate
575
+ [ "$status" -ne 0 ]
576
+ }
577
+
578
+ @test "memory verbs are unaffected by the lessons namespace" {
579
+ _cli_setup
580
+ run librarian_cli status "$PROJECT_REPO"
581
+ [ "$status" -eq 0 ]
582
+ }
583
+
584
+ @test "confirm without a justification writes no snapshot" {
585
+ _review_setup
586
+ id=$(_seed_pending)
587
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
588
+ run jq -e 'has("candidate_before_confirm")' "${LESSONS_DIR}/proposals/${id}.json"
589
+ [ "$status" -ne 0 ]
590
+ }
591
+
592
+ @test "confirm without a justification clears a snapshot the proposal arrived with" {
593
+ _review_setup
594
+ id=$(_seed_pending)
595
+ # Nothing in the shipped verbs writes this field onto a pending proposal
596
+ # today, so seed it directly. The invariant unconfirm leans on is "absence
597
+ # means the candidate was never mutated" — a stale snapshot surviving a
598
+ # plain confirm would make the next unconfirm silently swap .candidate for
599
+ # a rewrite that never happened, stranding the user in the dead end this
600
+ # verb exists to escape.
601
+ tmp="${BATS_TEST_TMPDIR}/stale.json"
602
+ jq --argjson cb "$(_candidate "$(_indep)")" '.candidate_before_confirm = $cb' \
603
+ "${LESSONS_DIR}/proposals/${id}.json" > "$tmp"
604
+ mv "$tmp" "${LESSONS_DIR}/proposals/${id}.json"
605
+
606
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
607
+ jq -e 'has("candidate_before_confirm") | not' \
608
+ "${LESSONS_DIR}/proposals/${id}.json" >/dev/null || return 1
609
+
610
+ # And the harm it would have caused: unconfirm must find nothing to restore.
611
+ librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
612
+ run jq -e '.candidate.applies_to.scope.kind == "versioned"' \
613
+ "${LESSONS_DIR}/proposals/${id}.json"
614
+ [ "$status" -eq 0 ]
615
+ }
616
+
617
+ @test "confirm with a justification snapshots the pre-rewrite candidate" {
618
+ _review_setup
619
+ id=$(_seed_pending)
620
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git aborts on a dirty tree regardless of version"
621
+ run jq -e '.candidate_before_confirm.applies_to.scope.kind == "versioned"' \
622
+ "${LESSONS_DIR}/proposals/${id}.json"
623
+ [ "$status" -eq 0 ]
624
+ }
625
+
626
+ @test "unconfirm returns a confirmed lesson to pending and clears the decision" {
627
+ _review_setup
628
+ id=$(_seed_pending)
629
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
630
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
631
+ [ "$status" -eq 0 ]
632
+ run jq -e '.status == "pending" and (has("visibility") | not) and (has("confirmed_at") | not)' \
633
+ "${LESSONS_DIR}/proposals/${id}.json"
634
+ [ "$status" -eq 0 ]
635
+ }
636
+
637
+ @test "the round trip leaves the proposal byte-identical to its pre-confirm state" {
638
+ _review_setup
639
+ id=$(_seed_pending)
640
+ before="${BATS_TEST_TMPDIR}/before.json"
641
+ cp "${LESSONS_DIR}/proposals/${id}.json" "$before"
642
+
643
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "public" "git behavior is stable across versions"
644
+ librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
645
+
646
+ run diff <(jq -S . "$before") <(jq -S . "${LESSONS_DIR}/proposals/${id}.json")
647
+ [ "$status" -eq 0 ]
648
+ }
649
+
650
+ @test "unconfirm restores versioned scope after a justification confirm" {
651
+ _review_setup
652
+ id=$(_seed_pending)
653
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "stable across versions"
654
+ librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
655
+ run jq -e '.candidate.applies_to.scope.kind == "versioned"
656
+ and (has("candidate_before_confirm") | not)' \
657
+ "${LESSONS_DIR}/proposals/${id}.json"
658
+ [ "$status" -eq 0 ]
659
+ }
660
+
661
+ @test "after unconfirm a fresh confirm at a different visibility succeeds" {
662
+ _review_setup
663
+ id=$(_seed_pending)
664
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
665
+ librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
666
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "private"
667
+ [ "$status" -eq 0 ]
668
+ run jq -e '.status == "confirmed" and .visibility == "private"' \
669
+ "${LESSONS_DIR}/proposals/${id}.json"
670
+ [ "$status" -eq 0 ]
671
+ }
672
+
673
+ @test "after unconfirm a justification confirm can be redone at private visibility" {
674
+ _review_setup
675
+ id=$(_seed_pending)
676
+ # The composite the design actually argues for. A justification rewrites
677
+ # scope to version_independent, and private refuses that scope — so without
678
+ # the snapshot restore this last confirm is refused for a rewrite the user
679
+ # already took back, trading one dead end for another. The plain-confirm
680
+ # case above still passes with the snapshot mechanism deleted entirely;
681
+ # this one does not.
682
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "public" \
683
+ "git aborts on a dirty tree regardless of version"
684
+ librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
685
+ run librarian_lesson_confirm "$PROJECT_KEY" "$id" "private"
686
+ [ "$status" -eq 0 ]
687
+ run jq -e '.status == "confirmed" and .visibility == "private"
688
+ and .candidate.applies_to.scope.kind == "versioned"' \
689
+ "${LESSONS_DIR}/proposals/${id}.json"
690
+ [ "$status" -eq 0 ]
691
+ }
692
+
693
+ @test "unconfirm from pending is a no-op success" {
694
+ _review_setup
695
+ id=$(_seed_pending)
696
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
697
+ [ "$status" -eq 0 ]
698
+ run jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json"
699
+ [ "$status" -eq 0 ]
700
+ }
701
+
702
+ @test "unconfirm refuses a passed lesson and leaves the ledger untouched" {
703
+ _review_setup
704
+ id=$(_seed_pending)
705
+ librarian_lesson_pass "$PROJECT_KEY" "$id" "not worth sharing"
706
+ before_lines=$(wc -l < "${LESSONS_DIR}/passed.jsonl")
707
+
708
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
709
+ [ "$status" -ne 0 ]
710
+ run jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json"
711
+ [ "$status" -eq 0 ]
712
+ [ "$(wc -l < "${LESSONS_DIR}/passed.jsonl")" -eq "$before_lines" ]
713
+ }
714
+
715
+ @test "unconfirm refuses an unrecognized status and names it" {
716
+ _review_setup
717
+ id=$(_seed_pending)
718
+ tmp="${BATS_TEST_TMPDIR}/mut.json"
719
+ jq '.status = "judging"' "${LESSONS_DIR}/proposals/${id}.json" > "$tmp"
720
+ mv "$tmp" "${LESSONS_DIR}/proposals/${id}.json"
721
+
722
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
723
+ [ "$status" -ne 0 ]
724
+ [[ "$output" == *"judging"* ]] || return 1
725
+ }
726
+
727
+ @test "unconfirm refuses a lesson that does not exist" {
728
+ _review_setup
729
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "01KZNOSUCHLESSON0000000000"
730
+ [ "$status" -ne 0 ]
731
+ }
732
+
733
+ @test "unconfirm never invokes a model" {
734
+ _review_setup
735
+ stub="${BATS_TEST_TMPDIR}/bin"
736
+ mkdir -p "$stub"
737
+ printf '#!/usr/bin/env bash\necho "MODEL WAS INVOKED" >&2\nexit 42\n' > "${stub}/claude"
738
+ chmod +x "${stub}/claude"
739
+ PATH="${stub}:${PATH}"
740
+
741
+ id=$(_seed_pending)
742
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
743
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
744
+ [ "$status" -eq 0 ]
745
+ [[ "$output" != *"MODEL WAS INVOKED"* ]] || return 1
746
+ }
747
+
748
+ @test "lessons unconfirm returns a confirmed lesson to pending" {
749
+ _cli_setup
750
+ id=$(_seed_pending)
751
+ librarian_cli lessons confirm "$id" public "$PROJECT_REPO" >/dev/null
752
+ run librarian_cli lessons unconfirm "$id" "$PROJECT_REPO"
753
+ [ "$status" -eq 0 ]
754
+ run jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json"
755
+ [ "$status" -eq 0 ]
756
+ }
757
+
758
+ @test "lessons unconfirm on a pending lesson does not claim it undid a confirmation" {
759
+ _cli_setup
760
+ id=$(_seed_pending)
761
+ run librarian_cli lessons unconfirm "$id" "$PROJECT_REPO"
762
+ [ "$status" -eq 0 ]
763
+ # The no-op is a success, but reporting "Unconfirmed <id>" for a lesson
764
+ # nobody confirmed asserts an action that never happened — and a user
765
+ # chasing a mistaken confirm would read it as proof they fixed it.
766
+ [[ "$output" == *"already pending"* ]] || return 1
767
+ [[ "$output" != *"Unconfirmed"* ]] || return 1
768
+ }
769
+
770
+ @test "lessons unconfirm still reports the action when it took one" {
771
+ _cli_setup
772
+ id=$(_seed_pending)
773
+ librarian_cli lessons confirm "$id" public "$PROJECT_REPO" >/dev/null
774
+ run librarian_cli lessons unconfirm "$id" "$PROJECT_REPO"
775
+ [ "$status" -eq 0 ]
776
+ [[ "$output" == *"Unconfirmed ${id}"* ]] || return 1
777
+ [[ "$output" != *"already pending"* ]] || return 1
778
+ }
779
+
780
+ @test "lessons unconfirm requires a lesson id" {
781
+ _cli_setup
782
+ run librarian_cli lessons unconfirm
783
+ [ "$status" -ne 0 ]
784
+ # With no lesson id, cwd is empty too, so _librarian_cli_project_key
785
+ # falls back to $(pwd) and resolves a valid key in this sandbox, and
786
+ # librarian_lesson_unconfirm's own empty-id guard also returns 1. Exit
787
+ # status alone can't tell the CLI's usage guard apart from that
788
+ # fallback path. Pin the message: without the CLI guard this test would
789
+ # pass while printing nothing useful.
790
+ [[ "$output" == *"usage:"* && "$output" == *"unconfirm"* ]] || return 1
791
+ }
792
+
793
+ @test "lessons unconfirm rejects an unknown flag" {
794
+ _cli_setup
795
+ id=$(_seed_pending)
796
+ librarian_cli lessons confirm "$id" public "$PROJECT_REPO" >/dev/null
797
+ run librarian_cli lessons unconfirm "$id" --force
798
+ [ "$status" -ne 0 ]
799
+ # An invalid cwd already fails project-key resolution on its own, so the
800
+ # exit code alone can't tell the guard's diagnostic apart from that
801
+ # fallback path. Pin the message too: without the guard this would read
802
+ # "No project key resolvable from this directory." instead.
803
+ [[ "$output" == *"unknown option"* && "$output" == *"--force"* ]] || return 1
804
+ run jq -e '.status == "confirmed"' "${LESSONS_DIR}/proposals/${id}.json"
805
+ [ "$status" -eq 0 ]
806
+ }
807
+
808
+ @test "unconfirm refuses an approved lesson, naming the status" {
809
+ _review_setup
810
+ local id
811
+ id=$(_seed_pending)
812
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
813
+ # criterion_scores must cover the rubric's floored criteria or the judge
814
+ # refuses the panel as UNJUDGED and this lesson never reaches `approved`.
815
+ librarian_lesson_judge "$PROJECT_KEY" "$id" \
816
+ '[{"judge_type":"standard","score":0.9,"passed":true,"criterion_scores":{"grounding":0.9,"scope_accuracy":0.9,"generality":0.9}},{"judge_type":"adversarial","score":0.8,"passed":true,"criterion_scores":{"grounding":0.8,"scope_accuracy":0.8,"generality":0.8}}]'
817
+
818
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
819
+ [ "$status" -ne 0 ]
820
+ [[ "$output" == *"unrecognized status"* && "$output" == *"approved"* ]] || return 1
821
+ }
822
+
823
+ @test "unconfirm refuses a rejected lesson, naming the status" {
824
+ _review_setup
825
+ local id
826
+ id=$(_seed_pending)
827
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
828
+ # As above: the public rubric floors disclosure too, so it must be scored
829
+ # for the panel to produce a verdict at all. The jury split is what rejects.
830
+ librarian_lesson_judge "$PROJECT_KEY" "$id" \
831
+ '[{"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}}]'
832
+
833
+ run librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
834
+ [ "$status" -ne 0 ]
835
+ [[ "$output" == *"unrecognized status"* && "$output" == *"rejected"* ]] || return 1
836
+ }
837
+
838
+ @test "confirm writes the proposal atomically, never truncating in place" {
839
+ # `printf > path` truncates first, so an interrupted write leaves a
840
+ # zero-byte proposal that every verb refuses and list_pending hides —
841
+ # unrecoverable even by unconfirm. A read-only-dir test would NOT catch
842
+ # this: it blocks the open entirely, so the truncating code also leaves
843
+ # the original intact. What distinguishes atomic from not is that the
844
+ # write lands somewhere else first, so spy on the rename.
845
+ _review_setup
846
+ local id
847
+ id=$(_seed_pending)
848
+
849
+ local marker="${BATS_TEST_TMPDIR}/mv-called"
850
+ rm -f "$marker"
851
+ mv() { printf '%s -> %s\n' "$1" "$2" >> "$marker"; command mv "$@"; }
852
+
853
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
854
+
855
+ [ -f "$marker" ]
856
+ grep -q "proposals/${id}.json" "$marker" || return 1
857
+ [ "$(jq -r '.status' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/${id}.json")" = "confirmed" ]
858
+ unset -f mv
859
+ }
860
+
861
+ @test "pass writes the proposal atomically, never truncating in place" {
862
+ # Same discriminator as confirm's atomic-write test: a read-only-dir test
863
+ # would not catch a reverted fix here either, since the truncating write
864
+ # also never opens the file. Spy on the rename instead.
865
+ _review_setup
866
+ local id
867
+ id=$(_seed_pending)
868
+
869
+ local marker="${BATS_TEST_TMPDIR}/mv-called"
870
+ rm -f "$marker"
871
+ mv() { printf '%s -> %s\n' "$1" "$2" >> "$marker"; command mv "$@"; }
872
+
873
+ librarian_lesson_pass "$PROJECT_KEY" "$id" "not worth sharing"
874
+
875
+ [ -f "$marker" ]
876
+ grep -q "proposals/${id}.json" "$marker" || return 1
877
+ [ "$(jq -r '.status' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/${id}.json")" = "passed" ]
878
+ unset -f mv
879
+ }
880
+
881
+ @test "unconfirm writes the proposal atomically, never truncating in place" {
882
+ # Same discriminator as confirm's atomic-write test. unconfirm is the
883
+ # recovery verb for a stuck proposal, so a truncating write here is the
884
+ # one that would leave it unrecoverable even from its own remedy.
885
+ _review_setup
886
+ local id
887
+ id=$(_seed_pending)
888
+ librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
889
+
890
+ local marker="${BATS_TEST_TMPDIR}/mv-called"
891
+ rm -f "$marker"
892
+ mv() { printf '%s -> %s\n' "$1" "$2" >> "$marker"; command mv "$@"; }
893
+
894
+ librarian_lesson_unconfirm "$PROJECT_KEY" "$id"
895
+
896
+ [ -f "$marker" ]
897
+ grep -q "proposals/${id}.json" "$marker" || return 1
898
+ [ "$(jq -r '.status' "$(librarian_lessons_dir "$PROJECT_KEY")/proposals/${id}.json")" = "pending" ]
899
+ unset -f mv
900
+ }