@onlooker-community/ecosystem 0.33.1 → 0.34.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 (29) hide show
  1. package/.agents/skills/beads/SKILL.md +80 -0
  2. package/.agents/skills/beads/agents/openai.yaml +4 -0
  3. package/.claude/settings.json +13 -0
  4. package/.claude-plugin/plugin.json +1 -1
  5. package/.codex/config.toml +2 -0
  6. package/.codex/hooks.json +51 -0
  7. package/.markdownlint.json +3 -0
  8. package/.release-please-manifest.json +2 -2
  9. package/AGENTS.md +135 -0
  10. package/CHANGELOG.md +7 -0
  11. package/CLAUDE.md +56 -1
  12. package/docs/lesson-promotion-pipeline.md +210 -0
  13. package/docs/superpowers/plans/2026-08-09-lesson-transform.md +1537 -0
  14. package/docs/superpowers/specs/2026-08-09-lesson-transform-design.md +261 -0
  15. package/package.json +3 -2
  16. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  17. package/plugins/librarian/CHANGELOG.md +7 -0
  18. package/plugins/librarian/config.json +4 -0
  19. package/plugins/librarian/schema/PROVENANCE.json +7 -0
  20. package/plugins/librarian/schema/lesson-applies-to.subschema.json +74 -0
  21. package/plugins/librarian/schema/lesson-evidence.subschema.json +36 -0
  22. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +26 -0
  23. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +135 -0
  24. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +311 -0
  25. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +140 -0
  26. package/scripts/lint/check-lesson-schema-drift.mjs +36 -0
  27. package/test/bats/librarian-lesson-transform.bats +609 -0
  28. package/test/node/lesson-schema-drift.test.mjs +28 -0
  29. package/test/node/lesson-validate-agreement.test.mjs +154 -0
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env bash
2
+ # Lesson transform — librarian's fifth stage.
3
+ #
4
+ # Reads one durable, classified, deduped archivist artifact and emits a lesson
5
+ # candidate: the four fields inferable from an artifact (claim, rationale,
6
+ # evidence, applies_to). The other nine required Lesson fields belong to later
7
+ # stages, so this never produces a schema-complete Lesson and cannot be
8
+ # validated against the full lesson schema.
9
+ #
10
+ # Requires librarian-lesson-validate.sh, librarian-lesson-storage.sh, and
11
+ # librarian-config.sh (librarian_config_get).
12
+ #
13
+ # Config inputs (read via librarian_config_get from librarian_lesson_call):
14
+ # librarian.lesson_transform.model Anthropic model id
15
+ # librarian.lesson_transform.timeout_seconds Hard wall-clock ceiling
16
+
17
+ # Fallback when config hasn't been loaded or leaves the key unset.
18
+ _LIBRARIAN_LESSON_DEFAULT_TIMEOUT_SECONDS=20
19
+
20
+ # Cap on how much of a response the JSON-object extractor will scan. The
21
+ # scanner is a per-character bash loop — effectively O(n^2) on long input,
22
+ # since bash string slicing on a long string isn't O(1) per call — and it
23
+ # runs after the claude call, uncapped, inside a SessionEnd hook that must
24
+ # not stall session end. `claude -p` has no output-size flag to bound the
25
+ # response itself, so the bound is enforced here instead. A response that
26
+ # exceeds this without yielding valid JSON in the scanned prefix has not
27
+ # followed the "output ONLY a single JSON object on one line" instruction
28
+ # anyway, so declining it is correct, not just expedient.
29
+ _LIBRARIAN_LESSON_EXTRACT_MAX_CHARS=8192
30
+
31
+ # Usage: librarian_lesson_build_prompt <artifact_json>
32
+ librarian_lesson_build_prompt() {
33
+ local artifact="$1"
34
+ local summary detail files_list artifact_id session_id project_key created_at
35
+
36
+ summary=$(printf '%s' "$artifact" | jq -r '.summary // ""')
37
+ detail=$(printf '%s' "$artifact" | jq -r '.detail // ""')
38
+ files_list=$(printf '%s' "$artifact" | jq -r '(.files // []) | join(", ")')
39
+ artifact_id=$(printf '%s' "$artifact" | jq -r '.id // ""')
40
+ session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
41
+ project_key=$(printf '%s' "$artifact" | jq -r '.project_key // ""')
42
+ created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
43
+
44
+ cat <<EOF
45
+ You are turning a session artifact into a shareable lesson, or refusing to.
46
+
47
+ A lesson states something that was learned, why it follows, and the exact
48
+ version range in which it holds. It is shared with other people, so a wrong
49
+ lesson actively misleads. Refusing is the safe answer.
50
+
51
+ Output ONLY one JSON object on one line. No markdown fences, no prose.
52
+
53
+ REFUSE when either is true, by outputting exactly:
54
+ { "eligible": false, "reason": "no_resolution" }
55
+ { "eligible": false, "reason": "no_versions" }
56
+
57
+ - "no_resolution": the artifact records a problem but not what resolved it.
58
+ "This breaks" without "and this fixed it" is a warning, not a lesson.
59
+ Never invent a resolution that is not in the artifact.
60
+ - "no_versions": you cannot determine which versions the claim is bound to.
61
+
62
+ Otherwise output:
63
+ {
64
+ "claim": "<what was learned, one sentence>",
65
+ "rationale": "<why the claim follows from the evidence>",
66
+ "evidence": { "resolution": "<what actually resolved it, from the artifact>" },
67
+ "applies_to": {
68
+ "stack": ["<tool or package name>", "<another tool or package name>"],
69
+ "scope": { "kind": "versioned", "versions": { "<stack entry>": "<range>" } },
70
+ "file_patterns": [],
71
+ "task_kinds": []
72
+ }
73
+ }
74
+
75
+ VERSION RANGE RULES — these are strict and a violation is discarded:
76
+ - Allowed: "<6", "<=6", "=6", ">4", ">=4", or two-sided ">=4 <6".
77
+ - FORBIDDEN: npm syntax. Never "^5.4.21", "~5", "5.x", or a bare "5.4.21".
78
+ - FORBIDDEN: ">=0", ">=0.0", ">=0.0.0". An unbounded lower bound matches
79
+ everything and would never expire.
80
+ - Every key in versions MUST also appear in stack.
81
+ - Generalize honestly. Observing a break on vite 5.4.21 with vitest 4.1.9
82
+ supports {"vite": "<6", "vitest": ">=4"} only if the cause is the missing
83
+ API rather than that exact build.
84
+
85
+ There is no version-independent option. If the claim is not bound to a
86
+ version range, refuse with "no_versions".
87
+
88
+ <artifact>
89
+ id: ${artifact_id}
90
+ summary: ${summary}
91
+ detail: ${detail}
92
+ files: ${files_list}
93
+ project_key: ${project_key}
94
+ session_id: ${session_id}
95
+ created_at: ${created_at}
96
+ </artifact>
97
+ EOF
98
+ }
99
+
100
+ # Extract the first balanced top-level JSON object from a string that may
101
+ # carry surrounding prose ("Here is the JSON: {...}"). Prints the substring
102
+ # on success, prints nothing and returns 1 on failure. Depth-tracks braces
103
+ # while skipping ones inside string literals (honoring backslash escapes),
104
+ # so a claim like `{"claim": "uses \"quotes\" and { in prose"}` still
105
+ # extracts correctly.
106
+ #
107
+ # Prose wrapping is not the same failure as unparseable output: a model that
108
+ # added a sentence around otherwise-valid JSON would very likely produce
109
+ # clean JSON on a resample, so declining it as transform_invalid would bury
110
+ # a good artifact over formatting noise rather than a real judgment problem.
111
+ #
112
+ # Usage: _librarian_lesson_extract_json_object <text>
113
+ _librarian_lesson_extract_json_object() {
114
+ local text="$1"
115
+ local start=-1 depth=0 in_string=0 escape=0
116
+ local i len ch
117
+
118
+ len=${#text}
119
+ for (( i = 0; i < len; i++ )); do
120
+ ch="${text:i:1}"
121
+ if [[ $start -eq -1 ]]; then
122
+ [[ "$ch" == "{" ]] && { start=$i; depth=1; }
123
+ continue
124
+ fi
125
+ if [[ $escape -eq 1 ]]; then
126
+ escape=0
127
+ continue
128
+ fi
129
+ case "$ch" in
130
+ '\') [[ $in_string -eq 1 ]] && escape=1 ;;
131
+ '"') in_string=$((1 - in_string)) ;;
132
+ '{') [[ $in_string -eq 0 ]] && depth=$((depth + 1)) ;;
133
+ '}')
134
+ if [[ $in_string -eq 0 ]]; then
135
+ depth=$((depth - 1))
136
+ if [[ $depth -eq 0 ]]; then
137
+ printf '%s' "${text:start:i-start+1}"
138
+ return 0
139
+ fi
140
+ fi
141
+ ;;
142
+ esac
143
+ done
144
+
145
+ return 1
146
+ }
147
+
148
+ # Call the model. Prints raw output, or empty string on ANY infrastructure
149
+ # failure — missing CLI, timeout, empty response. Empty means "could not
150
+ # judge", which is not a verdict.
151
+ #
152
+ # Usage: librarian_lesson_call <artifact_json> <model>
153
+ librarian_lesson_call() {
154
+ local artifact="$1"
155
+ local model="${2:-}"
156
+
157
+ command -v claude >/dev/null 2>&1 || return 0
158
+ [[ -z "$artifact" ]] && return 0
159
+
160
+ local prompt_file
161
+ prompt_file=$(mktemp -t librarian-lesson.XXXXXX 2>/dev/null) \
162
+ || prompt_file="/tmp/librarian-lesson.$$"
163
+ # shellcheck disable=SC2064
164
+ trap "rm -f '$prompt_file'" EXIT
165
+
166
+ librarian_lesson_build_prompt "$artifact" > "$prompt_file" || return 0
167
+
168
+ local args=(-p --max-turns 1)
169
+ [[ -n "$model" ]] && args+=(--model "$model")
170
+
171
+ local timeout_seconds
172
+ timeout_seconds=$(librarian_config_get '.librarian.lesson_transform.timeout_seconds' 2>/dev/null)
173
+ [[ -z "$timeout_seconds" || "$timeout_seconds" == "null" ]] \
174
+ && timeout_seconds="$_LIBRARIAN_LESSON_DEFAULT_TIMEOUT_SECONDS"
175
+
176
+ local response=""
177
+ if command -v timeout >/dev/null 2>&1; then
178
+ response=$(timeout "$timeout_seconds" \
179
+ claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
180
+ elif command -v gtimeout >/dev/null 2>&1; then
181
+ response=$(gtimeout "$timeout_seconds" \
182
+ claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
183
+ else
184
+ response=$(claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
185
+ fi
186
+
187
+ rm -f "$prompt_file"
188
+ trap - EXIT
189
+
190
+ [[ -z "$response" ]] && return 0
191
+
192
+ local cleaned
193
+ cleaned=$(printf '%s' "$response" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
194
+
195
+ # Fast path: the response is already valid JSON on its own.
196
+ if printf '%s' "$cleaned" | jq -e . >/dev/null 2>&1; then
197
+ printf '%s' "$cleaned"
198
+ return 0
199
+ fi
200
+
201
+ # Slow path: pull the first balanced JSON object out of surrounding
202
+ # prose. Bounded to a fixed prefix (see _LIBRARIAN_LESSON_EXTRACT_MAX_CHARS)
203
+ # so a rambling, arbitrarily long response can't turn the O(n^2) scan
204
+ # into an unbounded stall. Only used when it actually recovers valid
205
+ # JSON — otherwise fall through to the original text so the
206
+ # unparseable case still declines.
207
+ local extracted
208
+ extracted=$(_librarian_lesson_extract_json_object \
209
+ "${cleaned:0:_LIBRARIAN_LESSON_EXTRACT_MAX_CHARS}")
210
+ if [[ -n "$extracted" ]] && printf '%s' "$extracted" | jq -e . >/dev/null 2>&1; then
211
+ printf '%s' "$extracted"
212
+ return 0
213
+ fi
214
+
215
+ printf '%s' "$cleaned"
216
+ }
217
+
218
+ # Transform one artifact. Always exits 0. Prints exactly one of:
219
+ # proposed:<ulid> candidate written
220
+ # declined:<reason> a real verdict, recorded in declined.jsonl
221
+ # skipped:pregate no version token; free to redo, nothing recorded
222
+ # skipped:seen already handled
223
+ # unavailable infrastructure failure; nothing recorded
224
+ #
225
+ # Usage: librarian_lesson_transform_one <key> <artifact_json>
226
+ librarian_lesson_transform_one() {
227
+ local key="$1"
228
+ local artifact="$2"
229
+ [[ -z "$key" || -z "$artifact" ]] && { printf 'unavailable'; return 0; }
230
+
231
+ local artifact_id session_id project_key created_at
232
+ artifact_id=$(printf '%s' "$artifact" | jq -r '.id // ""')
233
+ session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
234
+ project_key=$(printf '%s' "$artifact" | jq -r '.project_key // ""')
235
+ created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
236
+ [[ -z "$artifact_id" ]] && { printf 'unavailable'; return 0; }
237
+
238
+ if librarian_lesson_seen "$key" "$artifact_id"; then
239
+ printf 'skipped:seen'
240
+ return 0
241
+ fi
242
+
243
+ if ! librarian_lesson_pregate "$artifact"; then
244
+ printf 'skipped:pregate'
245
+ return 0
246
+ fi
247
+
248
+ local model raw
249
+ model=$(librarian_config_get '.librarian.lesson_transform.model')
250
+
251
+ raw=$(librarian_lesson_call "$artifact" "$model")
252
+
253
+ # Empty means infrastructure, not verdict. Leave the artifact untouched.
254
+ if [[ -z "$raw" ]]; then
255
+ printf 'unavailable'
256
+ return 0
257
+ fi
258
+
259
+ if ! printf '%s' "$raw" | jq -e . >/dev/null 2>&1; then
260
+ librarian_lesson_append_declined "$key" "$artifact_id" "transform_invalid"
261
+ printf 'declined:transform_invalid'
262
+ return 0
263
+ fi
264
+
265
+ # An explicit refusal is a real answer. Checked with jq -e rather than a
266
+ # `// empty` string capture: jq's // operator treats JSON `false` as
267
+ # falsy, same as null, so `.eligible // empty` silently discards a real
268
+ # `"eligible": false` refusal instead of reporting it.
269
+ local reason
270
+ if printf '%s' "$raw" | jq -e '.eligible == false' >/dev/null 2>&1; then
271
+ reason=$(printf '%s' "$raw" | jq -r '.reason // "transform_invalid"')
272
+ case "$reason" in
273
+ no_resolution|no_versions) ;;
274
+ *) reason="transform_invalid" ;;
275
+ esac
276
+ librarian_lesson_append_declined "$key" "$artifact_id" "$reason"
277
+ printf 'declined:%s' "$reason"
278
+ return 0
279
+ fi
280
+
281
+ # Stitch in the provenance the model is not asked to produce.
282
+ local candidate
283
+ candidate=$(printf '%s' "$raw" | jq -c \
284
+ --arg aid "$artifact_id" \
285
+ --arg sid "$session_id" \
286
+ --arg pk "$project_key" \
287
+ --arg at "$created_at" \
288
+ '.evidence.artifact_ids = [$aid]
289
+ | .evidence.session_ids = [$sid]
290
+ | .evidence.project_key = $pk
291
+ | .evidence.observed_at = $at' 2>/dev/null) || candidate=""
292
+
293
+ if [[ -z "$candidate" ]]; then
294
+ librarian_lesson_append_declined "$key" "$artifact_id" "transform_invalid"
295
+ printf 'declined:transform_invalid'
296
+ return 0
297
+ fi
298
+
299
+ if ! librarian_lesson_validate_candidate "$candidate" 2>/dev/null; then
300
+ librarian_lesson_append_declined "$key" "$artifact_id" "schema_invalid"
301
+ printf 'declined:schema_invalid'
302
+ return 0
303
+ fi
304
+
305
+ local id
306
+ id=$(librarian_lesson_write_proposal "$key" "$candidate" "$artifact_id") || {
307
+ printf 'unavailable'
308
+ return 0
309
+ }
310
+ printf 'proposed:%s' "$id"
311
+ }
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bash
2
+ # Pure validation rules for lesson candidates. No I/O, no network, no CLI.
3
+ #
4
+ # These rules mirror the vendored sub-schemas in plugins/librarian/schema/.
5
+ # ajv cannot run at runtime (installed plugins ship no node_modules, ADR-005),
6
+ # so enforcement here is jq. The two mechanisms have been proven able to
7
+ # disagree, so tests assert them separately.
8
+
9
+ # Version-shaped token check. Returns 0 when the artifact could plausibly
10
+ # yield a versioned scope, 1 when it definitionally cannot.
11
+ #
12
+ # This rejects only what is impossible, never what is merely low quality:
13
+ # the transform can emit `versioned` scope alone, so an artifact with no
14
+ # version token anywhere cannot produce a valid scope.versions.
15
+ #
16
+ # Usage: librarian_lesson_pregate <artifact_json>
17
+ librarian_lesson_pregate() {
18
+ local artifact="${1:-}"
19
+ [[ -z "$artifact" ]] && return 1
20
+
21
+ local text
22
+ text=$(printf '%s' "$artifact" | jq -r '((.summary // "") + " " + (.detail // ""))' 2>/dev/null) || return 1
23
+ [[ -z "$text" ]] && return 1
24
+
25
+ # Dotted (5.4.21), v-prefixed (v5), or x-range (5.x).
26
+ printf '%s' "$text" | grep -qE '([0-9]+\.[0-9]+)|(\bv[0-9]+)|([0-9]+\.x\b)'
27
+ }
28
+
29
+ # Version range check, mirroring the vendored pattern.
30
+ #
31
+ # Accepts: <6 <=6 =6 >4 >=4 ">=4 <6"
32
+ # Rejects: ^5.4.21 ~5 5.x 5.4.21 >=0 >=0.0.0
33
+ #
34
+ # The >= and > forms require a non-zero lower bound. An unbounded lower bound
35
+ # matches every session and would never expire — version independence in
36
+ # disguise, which this stage is not allowed to mint.
37
+ #
38
+ # Usage: librarian_lesson_valid_range <string>
39
+ librarian_lesson_valid_range() {
40
+ local r="${1:-}"
41
+ [[ -z "$r" ]] && return 1
42
+
43
+ # The integer-part alternative is [0-9]*[1-9][0-9]*, not [1-9][0-9]*: the
44
+ # vendored pattern's equivalent is \d*[1-9]\d*, which allows a leading
45
+ # zero digit (05, 007) as long as some digit is nonzero. [1-9][0-9]* only
46
+ # permits a nonzero *leading* digit, so it rejects >=05 while the vendored
47
+ # schema accepts it — a real jq/schema disagreement, not a style choice.
48
+ local nonzero='([0-9]*[1-9][0-9]*(\.[0-9]+)?(\.[0-9]+)?|0+\.[0-9]*[1-9][0-9]*(\.[0-9]+)?|0+\.0+\.[0-9]*[1-9][0-9]*)'
49
+ local any='[0-9]+(\.[0-9]+)?(\.[0-9]+)?'
50
+ local pattern="^((<|<=|=)${any}|(>|>=)${nonzero}|(>|>=)${any} (<|<=)${any})$"
51
+
52
+ # Use bash's own regex engine rather than grep: grep's ^/$ anchor to line
53
+ # boundaries, not string boundaries, so a value with an embedded newline
54
+ # could smuggle a valid line past an otherwise-rejected string. [[ =~ ]]
55
+ # anchors to the whole string. The pattern must stay unquoted here —
56
+ # quoting the right-hand side of =~ forces literal string matching.
57
+ [[ "$r" =~ $pattern ]]
58
+ }
59
+
60
+ # Validate a full candidate. Prints nothing on success; prints a reason slug
61
+ # to stderr on failure.
62
+ #
63
+ # Usage: librarian_lesson_validate_candidate <candidate_json>
64
+ librarian_lesson_validate_candidate() {
65
+ local candidate="${1:-}"
66
+ [[ -z "$candidate" ]] && { printf 'schema_invalid\n' >&2; return 1; }
67
+
68
+ # Structural shape, including the versioned-only rule and a non-empty
69
+ # resolution. `versions` must be a non-empty object. artifact_ids,
70
+ # session_ids, and observed_at are checked against the same patterns as
71
+ # the vendored lesson-evidence.subschema.json (ULID, non-empty string,
72
+ # RFC3339 date-time) — a provenance-less artifact (session_id/created_at
73
+ # stitched in as "") must fail here, not pass through and get buried
74
+ # permanently once librarian_lesson_seen marks it handled.
75
+ #
76
+ # The `keys - [...] | length == 0` checks mirror `additionalProperties:
77
+ # false` on the vendored `evidence` and `applies_to` sub-schemas
78
+ # (including the "versioned" scope branch), and the `all(type ==
79
+ # "string" and length > 0)` checks mirror their array items' `minLength:
80
+ # 1`. Neither is decorative: without them a model that "helpfully" adds
81
+ # an extra field, or emits an empty-string array entry, produces a
82
+ # proposal that passes here but fails ajv against the contract it claims
83
+ # to satisfy — and lessons are meant to be shared with other people. Each
84
+ # `keys` call is guarded by a preceding `type == "object"` check in the
85
+ # same `and` chain: jq's `and` short-circuits left to right, so `keys` on
86
+ # a missing/non-object value is never reached.
87
+ if ! printf '%s' "$candidate" | jq -e '
88
+ (.claim | type) == "string" and (.claim | length) > 0
89
+ and (.rationale | type) == "string" and (.rationale | length) > 0
90
+ and (.evidence | type) == "object"
91
+ and ((.evidence | keys) - ["artifact_ids", "session_ids", "project_key", "observed_at", "resolution"] | length) == 0
92
+ and (.evidence.artifact_ids | type) == "array" and (.evidence.artifact_ids | length) > 0
93
+ and (.evidence.artifact_ids | all(type == "string" and test("^[0-9A-HJKMNP-TV-Z]{26}$")))
94
+ and (.evidence.session_ids | type) == "array" and (.evidence.session_ids | length) > 0
95
+ and (.evidence.session_ids | all(type == "string" and length > 0))
96
+ and (.evidence.project_key | type) == "string"
97
+ and (.evidence.project_key | test("^[0-9a-f]{12}$"))
98
+ and (.evidence.observed_at | type) == "string"
99
+ and (.evidence.observed_at | test("^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"))
100
+ and (.evidence.resolution | type) == "string" and (.evidence.resolution | length) > 0
101
+ and (.applies_to | type) == "object"
102
+ and ((.applies_to | keys) - ["stack", "scope", "file_patterns", "task_kinds"] | length) == 0
103
+ and (.applies_to.stack | type) == "array" and (.applies_to.stack | length) > 0
104
+ and (.applies_to.stack | all(type == "string" and length > 0))
105
+ and (.applies_to.file_patterns | type) == "array"
106
+ and (.applies_to.file_patterns | all(type == "string" and length > 0))
107
+ and (.applies_to.task_kinds | type) == "array"
108
+ and (.applies_to.task_kinds | all(type == "string" and length > 0))
109
+ and .applies_to.scope.kind == "versioned"
110
+ and ((.applies_to.scope | keys) - ["kind", "versions"] | length) == 0
111
+ and (.applies_to.scope.versions | type) == "object"
112
+ and (.applies_to.scope.versions | length) > 0
113
+ ' >/dev/null 2>&1; then
114
+ printf 'schema_invalid\n' >&2
115
+ return 1
116
+ fi
117
+
118
+ # Cross-field rule JSON Schema cannot express: every versions key must
119
+ # name an entry in stack.
120
+ if ! printf '%s' "$candidate" | jq -e '
121
+ (.applies_to.scope.versions | keys) - .applies_to.stack | length == 0
122
+ ' >/dev/null 2>&1; then
123
+ printf 'schema_invalid\n' >&2
124
+ return 1
125
+ fi
126
+
127
+ # Every range must satisfy the vendored pattern. NUL-delimited, not
128
+ # newline-delimited: a range value with an embedded newline would
129
+ # otherwise split into two lines that can each pass individually even
130
+ # though the single value they came from is not a valid range. Do not
131
+ # skip empty reads either — jq never emits one for a non-empty object
132
+ # of strings, so an empty read means the range itself is empty, and
133
+ # librarian_lesson_valid_range already rejects that.
134
+ local range
135
+ while IFS= read -r -d '' range; do
136
+ librarian_lesson_valid_range "$range" || { printf 'schema_invalid\n' >&2; return 1; }
137
+ done < <(printf '%s' "$candidate" | jq --raw-output0 '.applies_to.scope.versions[]' 2>/dev/null)
138
+
139
+ return 0
140
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // Guards the vendored lesson sub-schemas against silent drift.
3
+ //
4
+ // plugins/librarian/schema/*.subschema.json are copied out of the
5
+ // published lesson contract (see PROVENANCE.json) because ajv is
6
+ // unavailable at runtime (ADR-005); the jq rules in
7
+ // librarian-lesson-validate.sh mirror these files by hand. This check
8
+ // only confirms the provenance pin and JSON validity survive edits — it
9
+ // cannot compare against the upstream schema until schema.onlooker.dev
10
+ // publishes lesson schemas.
11
+ //
12
+ // Exit codes:
13
+ // 0 no problems
14
+ // 1 provenance pin changed or a vendored file is missing/invalid
15
+
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ const dir = 'plugins/librarian/schema';
19
+ let failures = 0;
20
+ const fail = (m) => {
21
+ console.error(`check-lesson-schema: ${m}`);
22
+ failures++;
23
+ };
24
+
25
+ try {
26
+ const prov = JSON.parse(readFileSync(`${dir}/PROVENANCE.json`, 'utf8'));
27
+ if (prov.schema_version !== 2) fail(`expected schema_version 2, got ${prov.schema_version}`);
28
+ for (const f of ['lesson-evidence.subschema.json', 'lesson-applies-to.subschema.json']) {
29
+ JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
30
+ }
31
+ } catch (err) {
32
+ fail(err.message);
33
+ }
34
+
35
+ if (failures > 0) process.exit(1);
36
+ console.log('check-lesson-schema: ok');