@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,261 @@
1
+ # Lesson Transform — Design
2
+
3
+ **Status:** Approved, not started.
4
+ **Tracked by:** `ecosystem-4z8.1`, under epic `ecosystem-4z8`.
5
+ **Parent design:** `docs/superpowers/specs/2026-08-08-promotion-pipeline-design.md` in the
6
+ [onlooker](https://github.com/onlooker-community/onlooker) repo, Sections 2–4.
7
+ That document governs the pipeline. This one covers stage five only, and where
8
+ the two disagree, the disagreements are called out explicitly below.
9
+
10
+ ---
11
+
12
+ ## What this stage is
13
+
14
+ Librarian's fifth stage. It reads one durable, classified, deduped archivist
15
+ artifact and emits a **lesson candidate** — the subset of a Lesson that can be
16
+ inferred from an artifact.
17
+
18
+ It is not a Lesson. `lesson.schema.json` v2 requires thirteen fields; this stage
19
+ owns four:
20
+
21
+ | Field | Owner |
22
+ |---|---|
23
+ | `claim`, `rationale`, `evidence`, `applies_to` | **this stage** |
24
+ | `visibility` | the human, at confirmation (`4z8.2`) |
25
+ | `consensus` | the jury (`4z8.3`) |
26
+ | `id`, `schema_version`, `source`, `status`, `superseded_by`, `author_key`, `promoted_at` | the pool write (`4z8.4`) |
27
+
28
+ Keeping that split explicit is what stops a stage inventing a field it does not
29
+ own. It also answers the open question the epic carried: the transform *cannot*
30
+ validate against `lesson.schema.json`, because the object is deliberately
31
+ incomplete at this point.
32
+
33
+ ## Decisions
34
+
35
+ Four decisions were settled during design. The first and third depart from the
36
+ parent spec; both departures are argued below rather than assumed.
37
+
38
+ **1. No recoverable resolution means declined.** `evidence.resolution` is
39
+ required with `minLength: 1`, and the archivist artifact shape has no resolution
40
+ field — it must be inferred from prose. When it is not there, the artifact is
41
+ declined. This preserves the contract's stance that "this breaks" without "and
42
+ this fixed it" is a warning, not a lesson.
43
+
44
+ **2. Validation is against vendored sub-schemas**, not the full lesson schema,
45
+ and with no network access at runtime. See *Validation* below.
46
+
47
+ **3. The transform emits `versioned` only.** It can never emit
48
+ `version_independent`. If it cannot infer versions, the artifact is declined.
49
+
50
+ The parent spec allows either branch and relies on the jury's `scope_accuracy`
51
+ criterion to catch a lazy justification — "the schema stops the accident, the
52
+ jury stops the lazy excuse." That defense has a hole: **private lessons run no
53
+ jury at all.** A weakly-justified `version_independent` candidate marked private
54
+ reaches the pool with nothing checking it, and a lesson with no version bound
55
+ never expires.
56
+
57
+ That is precisely the motivating failure. The stale vitest artifact was a
58
+ private, local memory that misled its own author across sessions. Closing the
59
+ branch at the transform makes the guarantee structural at every visibility tier
60
+ instead of relying on a gate the private tier skips. `version_independent`
61
+ becomes an explicit human choice at `4z8.2`, made by someone with the context to
62
+ write a justification that is actually true.
63
+
64
+ **4. A cheap pre-gate runs before the model.** Mirrors librarian's existing
65
+ pre-LLM durability filter.
66
+
67
+ ## Architecture
68
+
69
+ New file: `plugins/librarian/scripts/lib/librarian-lesson-transform.sh`, sourced
70
+ by the existing `librarian-session-end.sh` chain and running after conflict/dup
71
+ detection. No new hook. No new plugin — librarian already owns the watermark,
72
+ and a second copy of that state would be free to drift.
73
+
74
+ ### Storage
75
+
76
+ ```
77
+ ~/.onlooker/librarian/<project-key>/lessons/
78
+ proposals/<ulid>.json this stage
79
+ approved/<ulid>.json 4z8.4
80
+ declined.jsonl 4z8.4 (but see Boundary changes)
81
+ ```
82
+
83
+ Lesson state lives in its own subtree rather than reusing librarian's existing
84
+ `proposals/` directory. The two carry different consequences: a memory promotion
85
+ writes to your machine, a lesson proposal is a step toward publishing beyond it.
86
+ `4z8.2`'s open question is specifically the risk of users confusing the two, and
87
+ separate trees mean a confirmation surface cannot merge them by accident.
88
+
89
+ Each candidate carries its source `artifact_id` at the top level, which is
90
+ load-bearing for idempotency.
91
+
92
+ Use `$ONLOOKER_DIR`, never a hardcoded `~/.onlooker`, so the test suite's
93
+ isolated temp home is respected.
94
+
95
+ ## The three steps
96
+
97
+ ### 1. Pre-gate (bash, free)
98
+
99
+ `librarian_lesson_pregate <artifact_json>` scans `summary` and `detail` for a
100
+ version-shaped token (`\d+\.\d+`, `v5`, `5.x`).
101
+
102
+ Because `versioned` is the only branch this stage can emit, an artifact with no
103
+ version token anywhere cannot produce a valid `scope.versions`. Rejecting it is
104
+ a fact about the output shape, not a quality heuristic. The pre-gate rejects
105
+ nothing else — "does this have a resolution" is semantic and stays with the
106
+ model, because a keyword regex would silently drop good candidates.
107
+
108
+ Pre-gate rejections are silent: no event, no ledger entry. `declined.jsonl`
109
+ exists to make expensive drops auditable and to keep re-runs cheap. Re-running a
110
+ regex is free, so recording it would dilute the signal `4z8.4` wants for rubric
111
+ tuning.
112
+
113
+ ### 2. Model call
114
+
115
+ Mirrors `librarian-classifier.sh`: `claude -p --max-turns 1`, model, temperature
116
+ and max-tokens from config, a hard wall-clock timeout so SessionEnd cannot hang,
117
+ fail-soft to empty string, markdown fences stripped before parsing. The timeout
118
+ follows the classifier's ceiling of 20 seconds per call, and like the classifier
119
+ it falls back through `timeout`, `gtimeout`, then no timeout when neither exists.
120
+
121
+ The prompt supplies the artifact in a delimited block and requires one of two
122
+ responses: a candidate object, or an explicit refusal carrying a reason
123
+ (`no_resolution` or `no_versions`). A refusal is a real answer and must be
124
+ distinguishable from a failed call.
125
+
126
+ Config lives in the plugin's `config.json` under
127
+ `librarian.lesson_transform.{model, timeout_seconds}`, read via
128
+ `librarian_config_get` from the caller, with user overrides under the plugin
129
+ namespace key per ADR-004. There is no `enabled` flag — that option was removed
130
+ repo-wide in #108.
131
+
132
+ Two keys only. `claude --help` exposes `--model` and no sampling flags, so
133
+ `temperature` and `max_output_tokens` cannot reach the model through this CLI.
134
+ `librarian-classifier.sh` currently reads both and passes them to a function
135
+ that ignores them — dead config that reads as though it works. Sampling control
136
+ would require moving off `claude -p` to the API, which is a separate decision.
137
+
138
+ ### 3. Validation
139
+
140
+ Two layers, because **ajv is not available at runtime**. It resolves in dev and
141
+ CI only; installed marketplace plugins ship no `node_modules` (ADR-005).
142
+
143
+ - **Runtime:** dependency-free `jq`, which works everywhere.
144
+ - **Source of truth:** sub-schemas vendored at
145
+ `plugins/librarian/schema/lesson-evidence.subschema.json` and
146
+ `lesson-applies-to.subschema.json`, extracted from the published
147
+ `lesson.schema.json`.
148
+ - **Tests:** assert the `jq` validator and the vendored sub-schema agree over a
149
+ corpus. The parent spec sets this precedent directly — the two mechanisms were
150
+ "proven able to disagree," so they are asserted separately.
151
+ - **CI:** a drift guard asserts the vendored copies carry recorded provenance
152
+ (source path and `schema_version: 2`) and have not been edited by hand.
153
+
154
+ Note that this is weaker than the guard used for *event* schemas, which
155
+ compares against `schema.onlooker.dev`. That endpoint currently serves no
156
+ lesson schema — `lesson.schema.json`, `/`, and `lesson/v2.json` all return
157
+ 404 — so a fetch-and-compare guard is not available yet. When lesson schemas
158
+ are published, the guard should be upgraded to fetch-and-compare; until then
159
+ a cross-repo drift can only be caught by a human. Tracked separately.
160
+
161
+ The version-range pattern is where this earns its keep. The schema accepts `<6`,
162
+ `<=6`, `=6`, `>4`, `>=4`, and two-sided `>=4 <6`. It **rejects** npm-style
163
+ `^5.4.21`, `~5`, `5.x`, and bare `5.4.21` — which are exactly what a model
164
+ reaches for by default.
165
+
166
+ It also rejects `>=0`, `>=0.0`, and `>=0.0.0` by construction. An unbounded
167
+ lower bound is version-independence wearing a costume: it matches every session
168
+ and never expires. That closes the same hole decision 3 closes, one layer down.
169
+
170
+ Plus the cross-field rule JSON Schema cannot express: every key of
171
+ `scope.versions` must name an entry in `applies_to.stack`.
172
+
173
+ ## Failure taxonomy
174
+
175
+ Two categories look identical from the call site — both produce no candidate —
176
+ and conflating them is the sharpest risk in this stage.
177
+
178
+ | Condition | Outcome |
179
+ |---|---|
180
+ | pre-gate: no version token | silent skip, nothing written |
181
+ | model returns unparseable JSON | declined, `transform_invalid` |
182
+ | model refuses: no resolution | declined, `no_resolution` |
183
+ | model refuses: cannot infer versions | declined, `no_versions` |
184
+ | candidate fails sub-schema validation | declined, `schema_invalid` |
185
+ | `versions` key not named in `stack` | declined, `schema_invalid` |
186
+ | `claude` CLI absent | **untouched**, no ledger entry |
187
+ | timeout, or empty response | **untouched**, no ledger entry |
188
+
189
+ A verdict says "this artifact cannot become a lesson." An outage says "we could
190
+ not tell." Recording an outage as a verdict buries a good artifact permanently,
191
+ because the watermark has moved past it and declined entries are never re-read.
192
+
193
+ ## Idempotency
194
+
195
+ **The watermark cannot provide it.** `last_scan.json` is `{scanned_at: <ISO>}` —
196
+ a wall-clock timestamp stamped at scan time, not an artifact-id set. The parent
197
+ spec's failure table states that the watermark, ledger and pool are "all keyed
198
+ by `artifact_id`"; that is true of the ledger and pool but not the watermark.
199
+
200
+ Before spending a model call, skip when the `artifact_id` already appears in
201
+ `lessons/proposals/`, `lessons/declined.jsonl`, or `lessons/approved/`. That
202
+ check is what actually delivers "the same artifact cannot be promoted twice" and
203
+ "re-runs are cheap."
204
+
205
+ Librarian's existing tombstones do not help here: they are keyed by body hash
206
+ with a 180-day TTL, built for "the user rejected this memory, stop offering it."
207
+ Lesson idempotency must be artifact-keyed and permanent.
208
+
209
+ ## Testing
210
+
211
+ bats, using the repo's isolated-temp-home helpers.
212
+
213
+ **The parent spec's acceptance test inverts.** It designates artifact
214
+ `01KZ45MKAM734ZS7JK24D2DK0R` — the real vitest/vite mismatch — and expects it to
215
+ transform to `kind: versioned` with `{vite: "<6", vitest: ">=4"}`. Under decision
216
+ 1 it cannot: the artifact records only that the mismatch is real, and its session
217
+ ended with an open question titled "What is the correct fix for the vitest/vite
218
+ version mismatch?" No resolution was ever found. Neither does its later
219
+ refutation artifact (`01KZEAF9EY4C6TTR0V7YFN9VYJ`), which says the claim was
220
+ disproven without stating what is true.
221
+
222
+ So it becomes the **negative** test: declined, `no_resolution`. The artifact that
223
+ motivated the whole design proves the resolution rule fires.
224
+
225
+ The positive test uses a synthetic fixture, since no artifact on disk carries
226
+ both a resolution and versions. Shape it like the vitest case so it still
227
+ asserts `{vite: "<6", vitest: ">=4"}`.
228
+
229
+ Also:
230
+
231
+ - validator agreement over a range corpus — `^5.4.21`, `~5`, `5.x`, `5.4.21`,
232
+ `>=0`, `>=0.0.0` must all fail; `<6`, `>=4`, `>=4 <6`, `=6` must pass
233
+ - infrastructure is not a verdict: with `claude` absent, nothing is written to
234
+ `declined.jsonl` or anywhere else. This is the test most likely to regress
235
+ silently
236
+ - idempotency: an `artifact_id` already in `declined.jsonl` triggers no model call
237
+ - cross-field: a `versions` key absent from `stack` is declined
238
+ - pre-gate: an artifact with no version token produces no ledger entry
239
+
240
+ ## Events
241
+
242
+ `librarian.lesson.proposed` and `librarian.lesson.declined`, emitted through
243
+ `scripts/lib/onlooker-event.mjs`. Both must be registered in
244
+ `@onlooker-community/schema` before they are emitted.
245
+
246
+ ## Boundary changes to sibling issues
247
+
248
+ Building this stage first has two consequences for the issues around it:
249
+
250
+ - **`4z8.4`** currently owns `declined.jsonl` and its append function. The
251
+ transform produces declines, so the storage primitive moves here and `4z8.4`
252
+ reuses it.
253
+ - **`4z8.2`** gains the `version_independent` path from decision 3, including
254
+ where a human writes and edits the justification.
255
+
256
+ ## Out of scope
257
+
258
+ Version inference from anything but artifact prose. Reading the project's
259
+ current `package.json` or lockfile would report today's versions, not the
260
+ versions in effect when the problem was observed — and the gap between those two
261
+ is the entire staleness problem this pipeline exists to solve.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -25,10 +25,11 @@
25
25
  "test:shellcheck": "shellcheck -S error -x install.sh scripts/common.sh scripts/hooks/*.sh scripts/lib/*.sh plugins/archivist/scripts/hooks/*.sh plugins/archivist/scripts/lib/*.sh plugins/tribunal/scripts/hooks/*.sh plugins/tribunal/scripts/lib/*.sh plugins/echo/scripts/hooks/*.sh plugins/echo/scripts/lib/*.sh plugins/governor/scripts/hooks/*.sh plugins/governor/scripts/lib/*.sh plugins/compass/scripts/hooks/*.sh plugins/compass/scripts/lib/*.sh plugins/scribe/scripts/hooks/*.sh plugins/scribe/scripts/lib/*.sh plugins/counsel/scripts/hooks/*.sh plugins/counsel/scripts/lib/*.sh plugins/warden/scripts/hooks/*.sh plugins/warden/scripts/lib/*.sh plugins/librarian/scripts/hooks/*.sh plugins/librarian/scripts/lib/*.sh plugins/curator/scripts/hooks/*.sh plugins/curator/scripts/lib/*.sh plugins/historian/scripts/hooks/*.sh plugins/historian/scripts/lib/*.sh plugins/assayer/scripts/hooks/*.sh plugins/assayer/scripts/lib/*.sh plugins/cartographer/scripts/hooks/*.sh plugins/cartographer/scripts/lib/*.sh plugins/bursar/scripts/hooks/*.sh plugins/bursar/scripts/lib/*.sh plugins/lineage/scripts/hooks/*.sh plugins/lineage/scripts/lib/*.sh plugins/inspector/scripts/hooks/*.sh plugins/inspector/scripts/lib/*.sh",
26
26
  "lint:references": "node scripts/lint/check-references.mjs",
27
27
  "lint:manifests": "node scripts/lint/check-manifests.mjs",
28
+ "lint:lesson-schema": "node scripts/lint/check-lesson-schema-drift.mjs",
28
29
  "coverage:node": "node scripts/coverage/run-coverage.mjs",
29
30
  "coverage:bash": "node scripts/coverage/bash-coverage.mjs",
30
31
  "coverage": "npm run coverage:node && npm run coverage:bash",
31
- "test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run lint:check && npm run lint:manifests && npm run lint:references",
32
+ "test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run lint:check && npm run lint:manifests && npm run lint:references && npm run lint:lesson-schema",
32
33
  "lint:check": "biome check . && markdownlint '**/*.md'",
33
34
  "lint": "biome lint --write && markdownlint --fix '**/*.md'",
34
35
  "format": "biome format --write && markdownlint --fix '**/*.md'",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "librarian",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Consolidation layer between archivist's per-session artifacts and the user's durable typed memory store. Detects which session decisions, dead-ends, and open questions deserve to live across sessions, classifies them into the user/feedback/project/reference types, and queues them as proposals for explicit confirmation. Auto-promotion is opt-in. Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/onlooker-community/ecosystem/compare/librarian-v0.6.1...librarian-v0.7.0) (2026-08-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * **librarian:** transform artifacts into shareable lesson candidates :microscope: ([#132](https://github.com/onlooker-community/ecosystem/issues/132)) ([48adbc7](https://github.com/onlooker-community/ecosystem/commit/48adbc7ec572b96b47f1be5f92810088c98702c6))
9
+
3
10
  ## [0.6.1](https://github.com/onlooker-community/ecosystem/compare/librarian-v0.6.0...librarian-v0.6.1) (2026-08-02)
4
11
 
5
12
 
@@ -16,6 +16,10 @@
16
16
  "max_output_tokens": 256,
17
17
  "min_classifier_confidence": 0.6
18
18
  },
19
+ "lesson_transform": {
20
+ "model": "claude-haiku-4-5-20251001",
21
+ "timeout_seconds": 20
22
+ },
19
23
  "durability_filter": {
20
24
  "marker_phrases": [
21
25
  "always",
@@ -0,0 +1,7 @@
1
+ {
2
+ "source_repo": "https://github.com/onlooker-community/onlooker",
3
+ "source_path": "packages/lesson-contract/schema/lesson.schema.json",
4
+ "schema_version": 2,
5
+ "extracted": ["properties.evidence", "properties.applies_to"],
6
+ "note": "Vendored because ajv is unavailable at runtime (ADR-005). Runtime enforcement is jq in librarian-lesson-validate.sh; these files are the source of truth those rules mirror. Upgrade to a fetch-and-compare guard once lesson schemas are published — schema.onlooker.dev serves none today."
7
+ }
@@ -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
  # ----------------------------------------------------------------------------
@@ -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
+ }