@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.
- 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 +8 -8
- package/AGENTS.md +135 -0
- package/CHANGELOG.md +14 -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/archivist/scripts/lib/archivist-config.sh +10 -34
- package/plugins/assayer/.claude-plugin/plugin.json +1 -1
- package/plugins/assayer/CHANGELOG.md +7 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +39 -62
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +11 -29
- package/plugins/compass/scripts/lib/compass-config.sh +16 -46
- package/plugins/counsel/scripts/lib/counsel-config.sh +15 -46
- package/plugins/curator/.claude-plugin/plugin.json +1 -1
- package/plugins/curator/CHANGELOG.md +7 -0
- package/plugins/curator/scripts/lib/curator-config.sh +19 -44
- package/plugins/echo/scripts/lib/echo-config.sh +30 -59
- package/plugins/governor/scripts/lib/governor-config.sh +11 -41
- package/plugins/historian/scripts/lib/historian-config.sh +9 -33
- package/plugins/inspector/.claude-plugin/plugin.json +1 -1
- package/plugins/inspector/CHANGELOG.md +7 -0
- package/plugins/inspector/scripts/lib/inspector-config.sh +39 -63
- 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-config.sh +10 -34
- 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/plugins/lineage/.claude-plugin/plugin.json +1 -1
- package/plugins/lineage/CHANGELOG.md +7 -0
- package/plugins/lineage/scripts/lib/lineage-config.sh +17 -53
- package/plugins/scribe/.claude-plugin/plugin.json +1 -1
- package/plugins/scribe/CHANGELOG.md +7 -0
- package/plugins/scribe/scripts/lib/scribe-config.sh +17 -47
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +7 -0
- package/plugins/tribunal/scripts/lib/tribunal-config.sh +25 -63
- package/plugins/warden/scripts/lib/warden-config.sh +17 -54
- package/scripts/lib/config-loader.sh +8 -1
- 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,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
|
|
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.
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
+
}
|