@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.
- package/.agents/skills/beads/SKILL.md +80 -0
- package/.agents/skills/beads/agents/openai.yaml +4 -0
- package/.claude/settings.json +13 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.codex/config.toml +2 -0
- package/.codex/hooks.json +51 -0
- package/.markdownlint.json +3 -0
- package/.release-please-manifest.json +2 -2
- package/AGENTS.md +135 -0
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +56 -1
- package/docs/lesson-promotion-pipeline.md +210 -0
- package/docs/superpowers/plans/2026-08-09-lesson-transform.md +1537 -0
- package/docs/superpowers/specs/2026-08-09-lesson-transform-design.md +261 -0
- package/package.json +3 -2
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +7 -0
- package/plugins/librarian/config.json +4 -0
- package/plugins/librarian/schema/PROVENANCE.json +7 -0
- package/plugins/librarian/schema/lesson-applies-to.subschema.json +74 -0
- package/plugins/librarian/schema/lesson-evidence.subschema.json +36 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +26 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +135 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +311 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +140 -0
- package/scripts/lint/check-lesson-schema-drift.mjs +36 -0
- package/test/bats/librarian-lesson-transform.bats +609 -0
- package/test/node/lesson-schema-drift.test.mjs +28 -0
- 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');
|