@onlooker-community/ecosystem 0.33.0 → 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 (57) 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 +8 -8
  9. package/AGENTS.md +135 -0
  10. package/CHANGELOG.md +14 -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/archivist/scripts/lib/archivist-config.sh +10 -34
  17. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  18. package/plugins/assayer/CHANGELOG.md +7 -0
  19. package/plugins/assayer/scripts/lib/assayer-config.sh +39 -62
  20. package/plugins/cartographer/scripts/lib/cartographer-config.sh +11 -29
  21. package/plugins/compass/scripts/lib/compass-config.sh +16 -46
  22. package/plugins/counsel/scripts/lib/counsel-config.sh +15 -46
  23. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  24. package/plugins/curator/CHANGELOG.md +7 -0
  25. package/plugins/curator/scripts/lib/curator-config.sh +19 -44
  26. package/plugins/echo/scripts/lib/echo-config.sh +30 -59
  27. package/plugins/governor/scripts/lib/governor-config.sh +11 -41
  28. package/plugins/historian/scripts/lib/historian-config.sh +9 -33
  29. package/plugins/inspector/.claude-plugin/plugin.json +1 -1
  30. package/plugins/inspector/CHANGELOG.md +7 -0
  31. package/plugins/inspector/scripts/lib/inspector-config.sh +39 -63
  32. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  33. package/plugins/librarian/CHANGELOG.md +7 -0
  34. package/plugins/librarian/config.json +4 -0
  35. package/plugins/librarian/schema/PROVENANCE.json +7 -0
  36. package/plugins/librarian/schema/lesson-applies-to.subschema.json +74 -0
  37. package/plugins/librarian/schema/lesson-evidence.subschema.json +36 -0
  38. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +26 -0
  39. package/plugins/librarian/scripts/lib/librarian-config.sh +10 -34
  40. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +135 -0
  41. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +311 -0
  42. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +140 -0
  43. package/plugins/lineage/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lineage/CHANGELOG.md +7 -0
  45. package/plugins/lineage/scripts/lib/lineage-config.sh +17 -53
  46. package/plugins/scribe/.claude-plugin/plugin.json +1 -1
  47. package/plugins/scribe/CHANGELOG.md +7 -0
  48. package/plugins/scribe/scripts/lib/scribe-config.sh +17 -47
  49. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  50. package/plugins/tribunal/CHANGELOG.md +7 -0
  51. package/plugins/tribunal/scripts/lib/tribunal-config.sh +25 -63
  52. package/plugins/warden/scripts/lib/warden-config.sh +17 -54
  53. package/scripts/lib/config-loader.sh +8 -1
  54. package/scripts/lint/check-lesson-schema-drift.mjs +36 -0
  55. package/test/bats/librarian-lesson-transform.bats +609 -0
  56. package/test/node/lesson-schema-drift.test.mjs +28 -0
  57. package/test/node/lesson-validate-agreement.test.mjs +154 -0
@@ -0,0 +1,74 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ "stack": {
5
+ "minItems": 1,
6
+ "type": "array",
7
+ "items": {
8
+ "type": "string",
9
+ "minLength": 1
10
+ }
11
+ },
12
+ "scope": {
13
+ "oneOf": [
14
+ {
15
+ "type": "object",
16
+ "properties": {
17
+ "kind": {
18
+ "type": "string",
19
+ "const": "versioned"
20
+ },
21
+ "versions": {
22
+ "type": "object",
23
+ "propertyNames": {
24
+ "type": "string",
25
+ "minLength": 1
26
+ },
27
+ "additionalProperties": {
28
+ "type": "string",
29
+ "pattern": "^((<|<=|=)\\d+(\\.\\d+)?(\\.\\d+)?|(>|>=)(\\d*[1-9]\\d*(\\.\\d+)?(\\.\\d+)?|0+\\.\\d*[1-9]\\d*(\\.\\d+)?|0+\\.0+\\.\\d*[1-9]\\d*)|(>|>=)\\d+(\\.\\d+)?(\\.\\d+)? (<|<=)\\d+(\\.\\d+)?(\\.\\d+)?)$"
30
+ },
31
+ "description": "Comparator-prefixed version ranges keyed by stack entry, for example {\"vite\": \"<6\"}. A two-sided range reads lower bound then upper bound. Multiple entries combine with AND: every entry must match for the lesson to still apply.",
32
+ "minProperties": 1
33
+ }
34
+ },
35
+ "required": ["kind", "versions"],
36
+ "additionalProperties": false
37
+ },
38
+ {
39
+ "type": "object",
40
+ "properties": {
41
+ "kind": {
42
+ "type": "string",
43
+ "const": "version_independent"
44
+ },
45
+ "justification": {
46
+ "type": "string",
47
+ "minLength": 1,
48
+ "description": "Why this lesson holds regardless of version. Judged, not assumed."
49
+ }
50
+ },
51
+ "required": ["kind", "justification"],
52
+ "additionalProperties": false
53
+ }
54
+ ]
55
+ },
56
+ "file_patterns": {
57
+ "type": "array",
58
+ "items": {
59
+ "type": "string",
60
+ "minLength": 1
61
+ }
62
+ },
63
+ "task_kinds": {
64
+ "type": "array",
65
+ "items": {
66
+ "type": "string",
67
+ "minLength": 1
68
+ }
69
+ }
70
+ },
71
+ "required": ["stack", "scope", "file_patterns", "task_kinds"],
72
+ "additionalProperties": false,
73
+ "description": "Every key of scope.versions must name an entry in stack. That rule is enforced at ingest, not by this schema, because JSON Schema cannot express a constraint spanning two fields."
74
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ "artifact_ids": {
5
+ "minItems": 1,
6
+ "type": "array",
7
+ "items": {
8
+ "type": "string",
9
+ "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$"
10
+ }
11
+ },
12
+ "session_ids": {
13
+ "minItems": 1,
14
+ "type": "array",
15
+ "items": {
16
+ "type": "string",
17
+ "minLength": 1
18
+ }
19
+ },
20
+ "project_key": {
21
+ "type": "string",
22
+ "pattern": "^[0-9a-f]{12}$"
23
+ },
24
+ "observed_at": {
25
+ "type": "string",
26
+ "format": "date-time",
27
+ "pattern": "^(?:(?:\\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))$"
28
+ },
29
+ "resolution": {
30
+ "type": "string",
31
+ "minLength": 1
32
+ }
33
+ },
34
+ "required": ["artifact_ids", "session_ids", "project_key", "observed_at", "resolution"],
35
+ "additionalProperties": false
36
+ }
@@ -59,6 +59,12 @@ source "${PLUGIN_ROOT}/scripts/lib/librarian-durability.sh"
59
59
  source "${PLUGIN_ROOT}/scripts/lib/librarian-classifier.sh"
60
60
  # shellcheck source=../lib/librarian-conflict-detector.sh
61
61
  source "${PLUGIN_ROOT}/scripts/lib/librarian-conflict-detector.sh"
62
+ # shellcheck source=../lib/librarian-lesson-validate.sh
63
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
64
+ # shellcheck source=../lib/librarian-lesson-storage.sh
65
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
66
+ # shellcheck source=../lib/librarian-lesson-transform.sh
67
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-transform.sh"
62
68
 
63
69
  librarian_now_ms() {
64
70
  local now_ms
@@ -424,6 +430,26 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
424
430
  }')"
425
431
  done
426
432
 
433
+ # ---------------------------------------------------------------------------
434
+ # Stage 5 — lesson transform.
435
+ #
436
+ # Runs over the same durability survivors the classifier saw. Each artifact is
437
+ # independent: a decline or an outage on one never stops the rest.
438
+ # ---------------------------------------------------------------------------
439
+ LESSON_PROPOSED=0
440
+ LESSON_DECLINED=0
441
+
442
+ for ((li = 0; li < KEPT_COUNT; li++)); do
443
+ LESSON_ARTIFACT=$(printf '%s' "$KEPT" | jq -c ".[$li]")
444
+ [[ -z "$LESSON_ARTIFACT" || "$LESSON_ARTIFACT" == "null" ]] && continue
445
+
446
+ LESSON_RESULT=$(librarian_lesson_transform_one "$PROJECT_KEY" "$LESSON_ARTIFACT")
447
+ case "$LESSON_RESULT" in
448
+ proposed:*) LESSON_PROPOSED=$((LESSON_PROPOSED + 1)) ;;
449
+ declined:*) LESSON_DECLINED=$((LESSON_DECLINED + 1)) ;;
450
+ esac
451
+ done
452
+
427
453
  # ----------------------------------------------------------------------------
428
454
  # Watermark advance + scan.complete.
429
455
  # ----------------------------------------------------------------------------
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env bash
2
2
  # Config resolution for Librarian.
3
3
  #
4
- # Reads three layers, latest wins:
4
+ # Uses the shared config loader from ecosystem. Reads five layers, latest wins:
5
5
  # 1. plugins/librarian/config.json (defaults shipped with the plugin)
6
6
  # 2. ~/.claude/settings.json
7
- # 3. <repo>/.claude/settings.json
7
+ # 3. ~/.claude/settings.local.json (local overrides user)
8
+ # 4. <repo>/.claude/settings.json
9
+ # 5. <repo>/.claude/settings.local.json (local overrides project)
8
10
  #
9
11
  # Exposes:
10
12
  # librarian_config_load <repo_root> # populates _LIBRARIAN_CONFIG (JSON)
@@ -14,48 +16,22 @@
14
16
  # Settings overlay only touches the `librarian.*` subtree of settings.json so
15
17
  # it coexists with other plugins' configuration.
16
18
 
19
+ # shellcheck source=../../../scripts/lib/config-loader.sh
20
+ source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
21
+
17
22
  _LIBRARIAN_CONFIG="{}"
18
23
 
19
24
  librarian_config_load() {
20
25
  local repo_root="${1:-}"
21
- local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
22
- local home_dir="${HOME:-}"
23
-
24
- local merged="{}"
25
- local file
26
-
27
- file="${plugin_root}/config.json"
28
- if [[ -f "$file" ]]; then
29
- local defaults
30
- defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
31
- merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
32
- || merged="$defaults"
33
- fi
34
-
35
- for file in "${home_dir}/.claude/settings.json" "${repo_root}/.claude/settings.json"; do
36
- [[ -n "$file" && -f "$file" ]] || continue
37
- local overlay
38
- overlay=$(jq '{ librarian: (.librarian // {}) }' "$file" 2>/dev/null) || continue
39
- [[ -z "$overlay" ]] && continue
40
- merged=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
41
- def deepmerge($a; $b):
42
- if ($a|type) == "object" and ($b|type) == "object" then
43
- reduce (($a|keys) + ($b|keys) | unique)[] as $k
44
- ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
45
- elif $b == null then $a
46
- else $b end;
47
- deepmerge($a; $b)
48
- ' 2>/dev/null) || true
49
- done
50
-
51
- _LIBRARIAN_CONFIG="$merged"
26
+ config_load_plugin "librarian" "$repo_root" "_LIBRARIAN_CONFIG"
27
+ return 0
52
28
  }
53
29
 
54
30
  # Read a value from the loaded config. Usage:
55
31
  # librarian_config_get '.librarian.surfacer.max_pending_for_inject'
56
32
  librarian_config_get() {
57
33
  local path="$1"
58
- printf '%s' "$_LIBRARIAN_CONFIG" | jq -r "${path} // empty" 2>/dev/null
34
+ config_get "_LIBRARIAN_CONFIG" "${path}"
59
35
  }
60
36
 
61
37
  # Returns 0 if librarian.auto_promote is true, 1 otherwise.
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # Storage for the lesson subtree.
3
+ #
4
+ # <project_dir>/lessons/proposals/<ulid>.json awaiting human confirmation
5
+ # <project_dir>/lessons/approved/<ulid>.json jury passed (written by 4z8.4)
6
+ # <project_dir>/lessons/declined.jsonl append-only, never re-judged
7
+ #
8
+ # Lessons live apart from librarian's memory `proposals/` on purpose: a memory
9
+ # promotion writes to this machine, a lesson proposal is a step toward
10
+ # publishing beyond it. Separate trees keep a confirmation surface from
11
+ # merging the two by accident.
12
+ #
13
+ # Requires librarian-storage.sh (librarian_project_dir) and librarian-ulid.sh.
14
+
15
+ librarian_lessons_dir() {
16
+ local key="$1"
17
+ printf '%s/lessons' "$(librarian_project_dir "$key")"
18
+ }
19
+
20
+ librarian_lesson_storage_init() {
21
+ local key="$1"
22
+ [[ -z "$key" ]] && return 1
23
+ local dir
24
+ dir=$(librarian_lessons_dir "$key")
25
+ mkdir -p "$dir/proposals" "$dir/approved" 2>/dev/null
26
+ }
27
+
28
+ # Write one candidate. Prints the ULID on success.
29
+ # Usage: librarian_lesson_write_proposal <key> <candidate_json> <artifact_id>
30
+ librarian_lesson_write_proposal() {
31
+ local key="$1"
32
+ local candidate="$2"
33
+ local artifact_id="$3"
34
+ [[ -z "$key" || -z "$candidate" || -z "$artifact_id" ]] && return 1
35
+
36
+ librarian_lesson_storage_init "$key" || return 1
37
+
38
+ local id now out
39
+ id=$(librarian_ulid) || return 1
40
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
41
+ out="$(librarian_lessons_dir "$key")/proposals/${id}.json"
42
+
43
+ jq -n \
44
+ --arg id "$id" \
45
+ --arg artifact_id "$artifact_id" \
46
+ --arg created "$now" \
47
+ --argjson candidate "$candidate" \
48
+ '{
49
+ id: $id,
50
+ artifact_id: $artifact_id,
51
+ created_at: $created,
52
+ status: "pending",
53
+ candidate: $candidate
54
+ }' > "$out" 2>/dev/null || return 1
55
+
56
+ printf '%s' "$id"
57
+ }
58
+
59
+ # Append one decline. Only ever called for real determinations — never for a
60
+ # missing CLI, a timeout, or an empty response. Recording an outage here would
61
+ # bury a good artifact permanently, because the watermark has already moved
62
+ # past it and declined entries are never re-read.
63
+ #
64
+ # Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail]
65
+ librarian_lesson_append_declined() {
66
+ local key="$1"
67
+ local artifact_id="$2"
68
+ local reason="$3"
69
+ local detail="${4:-}"
70
+ [[ -z "$key" || -z "$artifact_id" || -z "$reason" ]] && return 1
71
+
72
+ librarian_lesson_storage_init "$key" || return 1
73
+
74
+ local now line
75
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
76
+ line=$(jq -cn \
77
+ --arg artifact_id "$artifact_id" \
78
+ --arg reason "$reason" \
79
+ --arg detail "$detail" \
80
+ --arg at "$now" \
81
+ '{
82
+ artifact_id: $artifact_id,
83
+ reason: $reason,
84
+ detail: (if $detail == "" then null else $detail end),
85
+ declined_at: $at
86
+ }') || return 1
87
+
88
+ printf '%s\n' "$line" >> "$(librarian_lessons_dir "$key")/declined.jsonl"
89
+ }
90
+
91
+ # Returns 0 when this artifact has already been handled.
92
+ #
93
+ # The watermark cannot answer this: last_scan.json records only *when* we
94
+ # scanned, not which artifacts were considered. Idempotency is artifact-keyed
95
+ # and permanent, unlike tombstones (body-hash keyed, TTL'd).
96
+ #
97
+ # Usage: librarian_lesson_seen <key> <artifact_id>
98
+ librarian_lesson_seen() {
99
+ local key="$1"
100
+ local artifact_id="$2"
101
+ [[ -z "$key" || -z "$artifact_id" ]] && return 1
102
+
103
+ local dir
104
+ dir=$(librarian_lessons_dir "$key")
105
+
106
+ # -R reads each line as a raw string and fromjson? yields nothing for a
107
+ # line that fails to parse, instead of aborting the whole jq invocation.
108
+ # Without this, one truncated trailing line (e.g. a process killed
109
+ # mid-append) makes jq exit 5 for the entire file, and every artifact
110
+ # declined before that line reads back as "not seen."
111
+ #
112
+ # `objects` after fromjson? is load-bearing, not decorative: fromjson?
113
+ # only guards the *parse*, not what comes after it in the pipe. A line
114
+ # that is valid JSON but not an object (a bare `123`, `true`, `"str"`, or
115
+ # `[1,2,3]`) parses cleanly, then `.artifact_id` indexing on that
116
+ # non-object errors out the whole jq invocation — the same
117
+ # every-prior-decline-reads-as-unseen failure the -R/fromjson? guard
118
+ # above exists to prevent, just reached through a different door.
119
+ # `objects` filters those values out before `.artifact_id` ever runs.
120
+ if [[ -f "$dir/declined.jsonl" ]] \
121
+ && jq -Re --arg a "$artifact_id" 'fromjson? | objects | select(.artifact_id == $a)' \
122
+ "$dir/declined.jsonl" >/dev/null 2>&1; then
123
+ return 0
124
+ fi
125
+
126
+ local f
127
+ for f in "$dir"/proposals/*.json "$dir"/approved/*.json; do
128
+ [[ -f "$f" ]] || continue
129
+ if jq -e --arg a "$artifact_id" '.artifact_id == $a' "$f" >/dev/null 2>&1; then
130
+ return 0
131
+ fi
132
+ done
133
+
134
+ return 1
135
+ }
@@ -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
+ }