@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,1537 @@
|
|
|
1
|
+
# Lesson Transform Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add librarian's fifth stage — a Haiku transform that turns a durable archivist artifact into a lesson candidate, declining anything it cannot ground.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three new bash libs with clean boundaries (pure validation, storage, orchestration), sourced by the existing `librarian-session-end.sh` chain after conflict/dup detection. A free bash pre-gate runs before any model call; survivors go to `claude -p`; output is validated by dependency-free `jq` against rules vendored from the published lesson schema. Candidates land in a `lessons/` subtree separate from librarian's existing memory proposals.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** bash, `jq`, `bats`, `claude` CLI, node (event emission and the drift-guard lint only).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-09-lesson-transform-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Hooks and libs are **bash**. No Python. Node only for event emission and lint scripts.
|
|
16
|
+
- Always use `${ONLOOKER_DIR:-$HOME/.onlooker}`, never a literal `~/.onlooker`.
|
|
17
|
+
- ULIDs, not UUIDs. Use the existing `librarian_ulid` from `librarian-ulid.sh`.
|
|
18
|
+
- Event types follow `<plugin>.<noun>.<verb>`.
|
|
19
|
+
- Config defaults live in `plugins/librarian/config.json`; overrides under the plugin namespace key (ADR-004).
|
|
20
|
+
- Every function fails soft. Hooks always exit 0 — they never block a session.
|
|
21
|
+
- Config is read via `librarian_config_get '<jq-path>'`.
|
|
22
|
+
- **The transform emits `versioned` scope only.** It must never emit `version_independent`.
|
|
23
|
+
- Valid version ranges: `<6`, `<=6`, `=6`, `>4`, `>=4`, `>=4 <6`. Invalid: `^5.4.21`, `~5`, `5.x`, bare `5.4.21`, `>=0`, `>=0.0`, `>=0.0.0`.
|
|
24
|
+
- American English in all comments and docs.
|
|
25
|
+
|
|
26
|
+
## File Structure
|
|
27
|
+
|
|
28
|
+
| File | Responsibility |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `plugins/librarian/schema/lesson-evidence.subschema.json` | Vendored `evidence` sub-schema (source of truth for the jq rules) |
|
|
31
|
+
| `plugins/librarian/schema/lesson-applies-to.subschema.json` | Vendored `applies_to` sub-schema |
|
|
32
|
+
| `plugins/librarian/schema/PROVENANCE.json` | Where the vendored copies came from, and their `schema_version` |
|
|
33
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-validate.sh` | Pure functions: pre-gate, range check, candidate validation. No I/O |
|
|
34
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-storage.sh` | `lessons/` paths, proposal write, declined append, idempotency lookup |
|
|
35
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-transform.sh` | Prompt building, `claude` call, orchestration |
|
|
36
|
+
| `plugins/librarian/scripts/hooks/librarian-session-end.sh` | Modified: source the libs, run the stage |
|
|
37
|
+
| `plugins/librarian/config.json` | Modified: `librarian.lesson_transform.*` defaults |
|
|
38
|
+
| `scripts/lint/check-lesson-schema-drift.mjs` | CI guard on the vendored copies |
|
|
39
|
+
| `test/bats/librarian-lesson-transform.bats` | All bats coverage for this stage |
|
|
40
|
+
|
|
41
|
+
**Note on decomposition:** the spec names a single `librarian-lesson-transform.sh`. This plan splits it three ways — pure logic, storage, orchestration — because the pure functions are where every high-risk rule lives and they are far easier to test without standing up a project key, a git repo, and a stubbed CLI. The spec's intent is unchanged.
|
|
42
|
+
|
|
43
|
+
**Task order note:** event emission is deliberately **last**. `librarian.lesson.proposed` and `librarian.lesson.declined` are not registered in `@onlooker-community/schema`, which is a separate published package. With a validator present (dev/CI) the emitter rejects an unregistered `event_type` and exits 1, so any test asserting on those events fails until the package publishes and is bumped here. Every other task is verified through on-disk artifacts and needs no schema change.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
### Task 1: Vendored sub-schemas and provenance
|
|
48
|
+
|
|
49
|
+
**Files:**
|
|
50
|
+
- Create: `plugins/librarian/schema/lesson-evidence.subschema.json`
|
|
51
|
+
- Create: `plugins/librarian/schema/lesson-applies-to.subschema.json`
|
|
52
|
+
- Create: `plugins/librarian/schema/PROVENANCE.json`
|
|
53
|
+
- Create: `scripts/lint/check-lesson-schema-drift.mjs`
|
|
54
|
+
- Modify: `package.json`
|
|
55
|
+
- Test: `test/node/lesson-schema-drift.test.mjs`
|
|
56
|
+
|
|
57
|
+
**Interfaces:**
|
|
58
|
+
- Consumes: nothing.
|
|
59
|
+
- Produces: two JSON Schema files that Task 2's jq rules must mirror. `npm run lint:lesson-schema` exits 0 when provenance is intact.
|
|
60
|
+
|
|
61
|
+
- [ ] **Step 1: Extract the two sub-schemas from the published contract**
|
|
62
|
+
|
|
63
|
+
The source is `packages/lesson-contract/schema/lesson.schema.json` in the [onlooker](https://github.com/onlooker-community/onlooker) repo. If a sibling checkout exists, extract directly:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ONL=../onlooker # adjust to your checkout
|
|
67
|
+
mkdir -p plugins/librarian/schema
|
|
68
|
+
jq '.properties.evidence' "$ONL/packages/lesson-contract/schema/lesson.schema.json" \
|
|
69
|
+
> plugins/librarian/schema/lesson-evidence.subschema.json
|
|
70
|
+
jq '.properties.applies_to' "$ONL/packages/lesson-contract/schema/lesson.schema.json" \
|
|
71
|
+
> plugins/librarian/schema/lesson-applies-to.subschema.json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If no checkout is available, copy the two objects from the spec's *Validation* section by hand. Verify afterward that `applies_to` contains a `scope.oneOf` with exactly two branches, and that the `versioned` branch's `additionalProperties.pattern` is present.
|
|
75
|
+
|
|
76
|
+
- [ ] **Step 2: Record provenance**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
jq -n '{
|
|
80
|
+
source_repo: "https://github.com/onlooker-community/onlooker",
|
|
81
|
+
source_path: "packages/lesson-contract/schema/lesson.schema.json",
|
|
82
|
+
schema_version: 2,
|
|
83
|
+
extracted: ["properties.evidence", "properties.applies_to"],
|
|
84
|
+
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."
|
|
85
|
+
}' > plugins/librarian/schema/PROVENANCE.json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 3: Write the failing drift test**
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
// test/node/lesson-schema-drift.test.mjs
|
|
92
|
+
import test from 'node:test';
|
|
93
|
+
import assert from 'node:assert/strict';
|
|
94
|
+
import { readFileSync } from 'node:fs';
|
|
95
|
+
|
|
96
|
+
const dir = 'plugins/librarian/schema';
|
|
97
|
+
const read = (f) => JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
|
|
98
|
+
|
|
99
|
+
test('provenance pins schema_version 2', () => {
|
|
100
|
+
assert.equal(read('PROVENANCE.json').schema_version, 2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('applies_to keeps a two-branch scope union', () => {
|
|
104
|
+
const appliesTo = read('lesson-applies-to.subschema.json');
|
|
105
|
+
const branches = appliesTo.properties.scope.oneOf;
|
|
106
|
+
assert.equal(branches.length, 2);
|
|
107
|
+
assert.deepEqual(
|
|
108
|
+
branches.map((b) => b.properties.kind.const).sort(),
|
|
109
|
+
['version_independent', 'versioned'],
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('the versioned branch requires at least one version and carries the range pattern', () => {
|
|
114
|
+
const appliesTo = read('lesson-applies-to.subschema.json');
|
|
115
|
+
const versioned = appliesTo.properties.scope.oneOf
|
|
116
|
+
.find((b) => b.properties.kind.const === 'versioned');
|
|
117
|
+
assert.equal(versioned.properties.versions.minProperties, 1);
|
|
118
|
+
assert.ok(versioned.properties.versions.additionalProperties.pattern);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('evidence requires a resolution', () => {
|
|
122
|
+
assert.ok(read('lesson-evidence.subschema.json').required.includes('resolution'));
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- [ ] **Step 4: Run it and watch it fail**
|
|
127
|
+
|
|
128
|
+
Run: `node --test test/node/lesson-schema-drift.test.mjs`
|
|
129
|
+
Expected: FAIL — files missing — until Steps 1-2 are done. If Steps 1-2 already ran, it passes; that is fine, the assertions still guard future edits.
|
|
130
|
+
|
|
131
|
+
- [ ] **Step 5: Wire the lint script**
|
|
132
|
+
|
|
133
|
+
```javascript
|
|
134
|
+
// scripts/lint/check-lesson-schema-drift.mjs
|
|
135
|
+
import { readFileSync } from 'node:fs';
|
|
136
|
+
|
|
137
|
+
const dir = 'plugins/librarian/schema';
|
|
138
|
+
let failures = 0;
|
|
139
|
+
const fail = (m) => { console.error(`check-lesson-schema: ${m}`); failures++; };
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const prov = JSON.parse(readFileSync(`${dir}/PROVENANCE.json`, 'utf8'));
|
|
143
|
+
if (prov.schema_version !== 2) fail(`expected schema_version 2, got ${prov.schema_version}`);
|
|
144
|
+
for (const f of ['lesson-evidence.subschema.json', 'lesson-applies-to.subschema.json']) {
|
|
145
|
+
JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
fail(err.message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (failures > 0) process.exit(1);
|
|
152
|
+
console.log('check-lesson-schema: ok');
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Add to `package.json` scripts:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
"lint:lesson-schema": "node scripts/lint/check-lesson-schema-drift.mjs"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
And append it to the `test:ci` chain, after `lint:references`.
|
|
162
|
+
|
|
163
|
+
- [ ] **Step 6: Verify**
|
|
164
|
+
|
|
165
|
+
Run: `node --test test/node/lesson-schema-drift.test.mjs && npm run lint:lesson-schema`
|
|
166
|
+
Expected: all tests PASS, lint prints `check-lesson-schema: ok`.
|
|
167
|
+
|
|
168
|
+
- [ ] **Step 7: Commit**
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
git add plugins/librarian/schema scripts/lint/check-lesson-schema-drift.mjs \
|
|
172
|
+
test/node/lesson-schema-drift.test.mjs package.json
|
|
173
|
+
git commit -m "feat(librarian): vendor the lesson sub-schemas the transform validates against :seedling:"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### Task 2: Pure validation library
|
|
179
|
+
|
|
180
|
+
**Files:**
|
|
181
|
+
- Create: `plugins/librarian/scripts/lib/librarian-lesson-validate.sh`
|
|
182
|
+
- Test: `test/bats/librarian-lesson-transform.bats`
|
|
183
|
+
|
|
184
|
+
**Interfaces:**
|
|
185
|
+
- Consumes: the vendored sub-schemas from Task 1 (as the specification the rules mirror; the code does not read them at runtime).
|
|
186
|
+
- Produces:
|
|
187
|
+
- `librarian_lesson_pregate <artifact_json>` → exit 0 to proceed, 1 to skip
|
|
188
|
+
- `librarian_lesson_valid_range <string>` → exit 0 when a valid version range
|
|
189
|
+
- `librarian_lesson_validate_candidate <candidate_json>` → prints `""` when valid, or a reason slug (`schema_invalid`) on stderr; exit 0 valid, 1 invalid
|
|
190
|
+
|
|
191
|
+
- [ ] **Step 1: Write the failing tests**
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
#!/usr/bin/env bats
|
|
195
|
+
#
|
|
196
|
+
# Pure validation rules for the lesson transform. No I/O, no CLI, no project
|
|
197
|
+
# key — these are the rules every candidate must satisfy before it is written.
|
|
198
|
+
|
|
199
|
+
setup() {
|
|
200
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
201
|
+
setup_test_env
|
|
202
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
|
|
203
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
204
|
+
# shellcheck disable=SC1091
|
|
205
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_artifact() {
|
|
209
|
+
jq -cn --arg s "$1" --arg d "$2" '{summary: $s, detail: $d}'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@test "pregate accepts an artifact carrying a dotted version" {
|
|
213
|
+
run librarian_lesson_pregate "$(_artifact "Vitest 4.1.9 breaks" "against Vite 5.4.21")"
|
|
214
|
+
[ "$status" -eq 0 ]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@test "pregate accepts a v-prefixed version" {
|
|
218
|
+
run librarian_lesson_pregate "$(_artifact "broken on v5" "see notes")"
|
|
219
|
+
[ "$status" -eq 0 ]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@test "pregate accepts an x-range" {
|
|
223
|
+
run librarian_lesson_pregate "$(_artifact "fails on 5.x" "see notes")"
|
|
224
|
+
[ "$status" -eq 0 ]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@test "pregate skips an artifact with no version token at all" {
|
|
228
|
+
run librarian_lesson_pregate "$(_artifact "Prefer functional patterns" "User said so during review.")"
|
|
229
|
+
[ "$status" -eq 1 ]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@test "valid_range accepts the four comparator forms and a two-sided range" {
|
|
233
|
+
for r in "<6" "<=6" "=6" ">4" ">=4" ">=4 <6"; do
|
|
234
|
+
run librarian_lesson_valid_range "$r"
|
|
235
|
+
[ "$status" -eq 0 ]
|
|
236
|
+
done
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
@test "valid_range rejects npm-style ranges a model reaches for by default" {
|
|
240
|
+
for r in "^5.4.21" "~5" "5.x" "5.4.21" "" "latest"; do
|
|
241
|
+
run librarian_lesson_valid_range "$r"
|
|
242
|
+
[ "$status" -eq 1 ]
|
|
243
|
+
done
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@test "valid_range rejects unbounded lower bounds that would never expire" {
|
|
247
|
+
for r in ">=0" ">=0.0" ">=0.0.0" ">0"; do
|
|
248
|
+
run librarian_lesson_valid_range "$r"
|
|
249
|
+
[ "$status" -eq 1 ]
|
|
250
|
+
done
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@test "valid_range anchors to the whole string, not to a line within it" {
|
|
254
|
+
# grep would match per line and accept this, diverging from the schema's
|
|
255
|
+
# ECMA262 pattern, which has no /m flag.
|
|
256
|
+
run librarian_lesson_valid_range "$(printf '<6\n>=999')"
|
|
257
|
+
[ "$status" -eq 1 ]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_candidate() {
|
|
261
|
+
jq -cn --argjson versions "$1" --argjson stack "$2" '{
|
|
262
|
+
claim: "Vitest 4 cannot import vite/module-runner on Vite 5",
|
|
263
|
+
rationale: "vite/module-runner ships in Vite 6; Vitest 4 assumes it exists.",
|
|
264
|
+
evidence: {
|
|
265
|
+
artifact_ids: ["01KZ45MKAM734ZS7JK24D2DK0R"],
|
|
266
|
+
session_ids: ["sess-1"],
|
|
267
|
+
project_key: "6a7678979e31",
|
|
268
|
+
observed_at: "2026-08-03T15:59:48Z",
|
|
269
|
+
resolution: "Pin vitest to 3.x until Vite 6 lands."
|
|
270
|
+
},
|
|
271
|
+
applies_to: {
|
|
272
|
+
stack: $stack,
|
|
273
|
+
scope: {kind: "versioned", versions: $versions},
|
|
274
|
+
file_patterns: [],
|
|
275
|
+
task_kinds: []
|
|
276
|
+
}
|
|
277
|
+
}'
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
@test "validate_candidate accepts a well-formed versioned candidate" {
|
|
281
|
+
run librarian_lesson_validate_candidate \
|
|
282
|
+
"$(_candidate '{"vite":"<6","vitest":">=4"}' '["vite","vitest"]')"
|
|
283
|
+
[ "$status" -eq 0 ]
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@test "validate_candidate rejects a versions key absent from stack" {
|
|
287
|
+
run librarian_lesson_validate_candidate \
|
|
288
|
+
"$(_candidate '{"vite":"<6","vitest":">=4"}' '["vite"]')"
|
|
289
|
+
[ "$status" -eq 1 ]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@test "validate_candidate rejects an invalid range inside an otherwise valid candidate" {
|
|
293
|
+
run librarian_lesson_validate_candidate \
|
|
294
|
+
"$(_candidate '{"vite":"^5.4.21"}' '["vite"]')"
|
|
295
|
+
[ "$status" -eq 1 ]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@test "validate_candidate rejects an empty versions object" {
|
|
299
|
+
run librarian_lesson_validate_candidate "$(_candidate '{}' '["vite"]')"
|
|
300
|
+
[ "$status" -eq 1 ]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@test "validate_candidate rejects an empty-string range" {
|
|
304
|
+
run librarian_lesson_validate_candidate "$(_candidate '{"vite":""}' '["vite"]')"
|
|
305
|
+
[ "$status" -eq 1 ]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@test "validate_candidate rejects a range value carrying an embedded newline" {
|
|
309
|
+
run librarian_lesson_validate_candidate \
|
|
310
|
+
"$(_candidate "$(jq -cn '{vite: "<6\n>=999"}')" '["vite"]')"
|
|
311
|
+
[ "$status" -eq 1 ]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@test "validate_candidate rejects a missing resolution" {
|
|
315
|
+
candidate=$(_candidate '{"vite":"<6"}' '["vite"]' | jq -c 'del(.evidence.resolution)')
|
|
316
|
+
run librarian_lesson_validate_candidate "$candidate"
|
|
317
|
+
[ "$status" -eq 1 ]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
@test "validate_candidate rejects an empty resolution" {
|
|
321
|
+
candidate=$(_candidate '{"vite":"<6"}' '["vite"]' | jq -c '.evidence.resolution = ""')
|
|
322
|
+
run librarian_lesson_validate_candidate "$candidate"
|
|
323
|
+
[ "$status" -eq 1 ]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
@test "validate_candidate rejects version_independent even when well-formed" {
|
|
327
|
+
candidate=$(_candidate '{"vite":"<6"}' '["vite"]' \
|
|
328
|
+
| jq -c '.applies_to.scope = {kind: "version_independent", justification: "git behavior is stable"}')
|
|
329
|
+
run librarian_lesson_validate_candidate "$candidate"
|
|
330
|
+
[ "$status" -eq 1 ]
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- [ ] **Step 2: Run them and watch them fail**
|
|
335
|
+
|
|
336
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
337
|
+
Expected: FAIL — `librarian_lesson_pregate: command not found`.
|
|
338
|
+
|
|
339
|
+
- [ ] **Step 3: Implement the library**
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
#!/usr/bin/env bash
|
|
343
|
+
# Pure validation rules for lesson candidates. No I/O, no network, no CLI.
|
|
344
|
+
#
|
|
345
|
+
# These rules mirror the vendored sub-schemas in plugins/librarian/schema/.
|
|
346
|
+
# ajv cannot run at runtime (installed plugins ship no node_modules, ADR-005),
|
|
347
|
+
# so enforcement here is jq. The two mechanisms have been proven able to
|
|
348
|
+
# disagree, so tests assert them separately.
|
|
349
|
+
|
|
350
|
+
# Version-shaped token check. Returns 0 when the artifact could plausibly
|
|
351
|
+
# yield a versioned scope, 1 when it definitionally cannot.
|
|
352
|
+
#
|
|
353
|
+
# This rejects only what is impossible, never what is merely low quality:
|
|
354
|
+
# the transform can emit `versioned` scope alone, so an artifact with no
|
|
355
|
+
# version token anywhere cannot produce a valid scope.versions.
|
|
356
|
+
#
|
|
357
|
+
# Usage: librarian_lesson_pregate <artifact_json>
|
|
358
|
+
librarian_lesson_pregate() {
|
|
359
|
+
local artifact="${1:-}"
|
|
360
|
+
[[ -z "$artifact" ]] && return 1
|
|
361
|
+
|
|
362
|
+
local text
|
|
363
|
+
text=$(printf '%s' "$artifact" | jq -r '((.summary // "") + " " + (.detail // ""))' 2>/dev/null) || return 1
|
|
364
|
+
[[ -z "$text" ]] && return 1
|
|
365
|
+
|
|
366
|
+
# Dotted (5.4.21), v-prefixed (v5), or x-range (5.x).
|
|
367
|
+
printf '%s' "$text" | grep -qE '([0-9]+\.[0-9]+)|(\bv[0-9]+)|([0-9]+\.x\b)'
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Version range check, mirroring the vendored pattern.
|
|
371
|
+
#
|
|
372
|
+
# Accepts: <6 <=6 =6 >4 >=4 ">=4 <6"
|
|
373
|
+
# Rejects: ^5.4.21 ~5 5.x 5.4.21 >=0 >=0.0.0
|
|
374
|
+
#
|
|
375
|
+
# The >= and > forms require a non-zero lower bound. An unbounded lower bound
|
|
376
|
+
# matches every session and would never expire — version independence in
|
|
377
|
+
# disguise, which this stage is not allowed to mint.
|
|
378
|
+
#
|
|
379
|
+
# Usage: librarian_lesson_valid_range <string>
|
|
380
|
+
librarian_lesson_valid_range() {
|
|
381
|
+
local r="${1:-}"
|
|
382
|
+
[[ -z "$r" ]] && return 1
|
|
383
|
+
|
|
384
|
+
# The integer-part alternative is [0-9]*[1-9][0-9]*, not [1-9][0-9]*: the
|
|
385
|
+
# vendored pattern's equivalent is \d*[1-9]\d*, which allows a leading
|
|
386
|
+
# zero digit (05, 007) as long as some digit is nonzero. [1-9][0-9]* only
|
|
387
|
+
# permits a nonzero *leading* digit, so it rejects >=05 while the vendored
|
|
388
|
+
# schema accepts it — a real jq/schema disagreement, not a style choice.
|
|
389
|
+
local nonzero='([0-9]*[1-9][0-9]*(\.[0-9]+)?(\.[0-9]+)?|0+\.[0-9]*[1-9][0-9]*(\.[0-9]+)?|0+\.0+\.[0-9]*[1-9][0-9]*)'
|
|
390
|
+
local any='[0-9]+(\.[0-9]+)?(\.[0-9]+)?'
|
|
391
|
+
local pattern="^((<|<=|=)${any}|(>|>=)${nonzero}|(>|>=)${any} (<|<=)${any})$"
|
|
392
|
+
|
|
393
|
+
# Use bash's own regex engine rather than grep: grep's ^/$ anchor to line
|
|
394
|
+
# boundaries, not string boundaries, so a value with an embedded newline
|
|
395
|
+
# could smuggle a valid line past an otherwise-rejected string. [[ =~ ]]
|
|
396
|
+
# anchors to the whole string. The pattern must stay unquoted here —
|
|
397
|
+
# quoting the right-hand side of =~ forces literal string matching.
|
|
398
|
+
#
|
|
399
|
+
# Do NOT escape the angle brackets as \< and \>. In GNU/glibc regex those
|
|
400
|
+
# are word-boundary assertions, not escaped literals, so an inlined
|
|
401
|
+
# escaped pattern behaves differently on Linux CI than on macOS. Build the
|
|
402
|
+
# pattern in a variable with plain < and > as above.
|
|
403
|
+
[[ "$r" =~ $pattern ]]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
# Validate a full candidate. Prints nothing on success; prints a reason slug
|
|
407
|
+
# to stderr on failure.
|
|
408
|
+
#
|
|
409
|
+
# Usage: librarian_lesson_validate_candidate <candidate_json>
|
|
410
|
+
librarian_lesson_validate_candidate() {
|
|
411
|
+
local candidate="${1:-}"
|
|
412
|
+
[[ -z "$candidate" ]] && { printf 'schema_invalid\n' >&2; return 1; }
|
|
413
|
+
|
|
414
|
+
# Structural shape, including the versioned-only rule and a non-empty
|
|
415
|
+
# resolution. `versions` must be a non-empty object. artifact_ids,
|
|
416
|
+
# session_ids, and observed_at are checked against the same patterns as
|
|
417
|
+
# the vendored lesson-evidence.subschema.json (ULID, non-empty string,
|
|
418
|
+
# RFC3339 date-time) — a provenance-less artifact (session_id/created_at
|
|
419
|
+
# stitched in as "") must fail here, not pass through and get buried
|
|
420
|
+
# permanently once librarian_lesson_seen marks it handled.
|
|
421
|
+
#
|
|
422
|
+
# The `keys - [...] | length == 0` checks mirror `additionalProperties:
|
|
423
|
+
# false` on the vendored `evidence` and `applies_to` sub-schemas
|
|
424
|
+
# (including the "versioned" scope branch), and the `all(type ==
|
|
425
|
+
# "string" and length > 0)` checks mirror their array items' `minLength:
|
|
426
|
+
# 1`. Neither is decorative: without them a model that "helpfully" adds
|
|
427
|
+
# an extra field, or emits an empty-string array entry, produces a
|
|
428
|
+
# proposal that passes here but fails ajv against the contract it claims
|
|
429
|
+
# to satisfy — and lessons are meant to be shared with other people. Each
|
|
430
|
+
# `keys` call is guarded by a preceding `type == "object"` check in the
|
|
431
|
+
# same `and` chain: jq's `and` short-circuits left to right, so `keys` on
|
|
432
|
+
# a missing/non-object value is never reached.
|
|
433
|
+
if ! printf '%s' "$candidate" | jq -e '
|
|
434
|
+
(.claim | type) == "string" and (.claim | length) > 0
|
|
435
|
+
and (.rationale | type) == "string" and (.rationale | length) > 0
|
|
436
|
+
and (.evidence | type) == "object"
|
|
437
|
+
and ((.evidence | keys) - ["artifact_ids", "session_ids", "project_key", "observed_at", "resolution"] | length) == 0
|
|
438
|
+
and (.evidence.artifact_ids | type) == "array" and (.evidence.artifact_ids | length) > 0
|
|
439
|
+
and (.evidence.artifact_ids | all(type == "string" and test("^[0-9A-HJKMNP-TV-Z]{26}$")))
|
|
440
|
+
and (.evidence.session_ids | type) == "array" and (.evidence.session_ids | length) > 0
|
|
441
|
+
and (.evidence.session_ids | all(type == "string" and length > 0))
|
|
442
|
+
and (.evidence.project_key | type) == "string"
|
|
443
|
+
and (.evidence.project_key | test("^[0-9a-f]{12}$"))
|
|
444
|
+
and (.evidence.observed_at | type) == "string"
|
|
445
|
+
and (.evidence.observed_at | test("^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"))
|
|
446
|
+
and (.evidence.resolution | type) == "string" and (.evidence.resolution | length) > 0
|
|
447
|
+
and (.applies_to | type) == "object"
|
|
448
|
+
and ((.applies_to | keys) - ["stack", "scope", "file_patterns", "task_kinds"] | length) == 0
|
|
449
|
+
and (.applies_to.stack | type) == "array" and (.applies_to.stack | length) > 0
|
|
450
|
+
and (.applies_to.stack | all(type == "string" and length > 0))
|
|
451
|
+
and (.applies_to.file_patterns | type) == "array"
|
|
452
|
+
and (.applies_to.file_patterns | all(type == "string" and length > 0))
|
|
453
|
+
and (.applies_to.task_kinds | type) == "array"
|
|
454
|
+
and (.applies_to.task_kinds | all(type == "string" and length > 0))
|
|
455
|
+
and .applies_to.scope.kind == "versioned"
|
|
456
|
+
and ((.applies_to.scope | keys) - ["kind", "versions"] | length) == 0
|
|
457
|
+
and (.applies_to.scope.versions | type) == "object"
|
|
458
|
+
and (.applies_to.scope.versions | length) > 0
|
|
459
|
+
' >/dev/null 2>&1; then
|
|
460
|
+
printf 'schema_invalid\n' >&2
|
|
461
|
+
return 1
|
|
462
|
+
fi
|
|
463
|
+
|
|
464
|
+
# Cross-field rule JSON Schema cannot express: every versions key must
|
|
465
|
+
# name an entry in stack.
|
|
466
|
+
if ! printf '%s' "$candidate" | jq -e '
|
|
467
|
+
(.applies_to.scope.versions | keys) - .applies_to.stack | length == 0
|
|
468
|
+
' >/dev/null 2>&1; then
|
|
469
|
+
printf 'schema_invalid\n' >&2
|
|
470
|
+
return 1
|
|
471
|
+
fi
|
|
472
|
+
|
|
473
|
+
# Every range must satisfy the vendored pattern. NUL-delimited, not
|
|
474
|
+
# newline-delimited: a range value with an embedded newline would
|
|
475
|
+
# otherwise split into two lines that can each pass individually even
|
|
476
|
+
# though the single value they came from is not a valid range. Do not
|
|
477
|
+
# skip empty reads either — jq never emits one for a non-empty object
|
|
478
|
+
# of strings, so an empty read means the range itself is empty, and
|
|
479
|
+
# librarian_lesson_valid_range already rejects that.
|
|
480
|
+
local range
|
|
481
|
+
while IFS= read -r -d '' range; do
|
|
482
|
+
librarian_lesson_valid_range "$range" || { printf 'schema_invalid\n' >&2; return 1; }
|
|
483
|
+
done < <(printf '%s' "$candidate" | jq --raw-output0 '.applies_to.scope.versions[]' 2>/dev/null)
|
|
484
|
+
|
|
485
|
+
return 0
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
- [ ] **Step 4: Run the tests**
|
|
490
|
+
|
|
491
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
492
|
+
Expected: all PASS.
|
|
493
|
+
|
|
494
|
+
- [ ] **Step 5: Shellcheck**
|
|
495
|
+
|
|
496
|
+
Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-validate.sh`
|
|
497
|
+
Expected: no output.
|
|
498
|
+
|
|
499
|
+
- [ ] **Step 6: Commit**
|
|
500
|
+
|
|
501
|
+
```bash
|
|
502
|
+
git add plugins/librarian/scripts/lib/librarian-lesson-validate.sh \
|
|
503
|
+
test/bats/librarian-lesson-transform.bats
|
|
504
|
+
git commit -m "feat(librarian): enforce lesson candidate rules without a schema validator :closed_lock_with_key:"
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
### Task 3: Lesson storage and idempotency
|
|
510
|
+
|
|
511
|
+
**Files:**
|
|
512
|
+
- Create: `plugins/librarian/scripts/lib/librarian-lesson-storage.sh`
|
|
513
|
+
- Test: `test/bats/librarian-lesson-transform.bats` (append)
|
|
514
|
+
|
|
515
|
+
**Interfaces:**
|
|
516
|
+
- Consumes: `librarian_project_dir <key>` and `librarian_ulid` from the existing libs.
|
|
517
|
+
- Produces:
|
|
518
|
+
- `librarian_lessons_dir <key>` → prints `<project_dir>/lessons`
|
|
519
|
+
- `librarian_lesson_storage_init <key>` → creates `lessons/proposals` and `lessons/approved`
|
|
520
|
+
- `librarian_lesson_write_proposal <key> <candidate_json> <artifact_id>` → prints the ULID written
|
|
521
|
+
- `librarian_lesson_append_declined <key> <artifact_id> <reason> [detail]` → appends one JSONL line
|
|
522
|
+
- `librarian_lesson_seen <key> <artifact_id>` → exit 0 when already handled, 1 when new
|
|
523
|
+
|
|
524
|
+
- [ ] **Step 1: Write the failing tests**
|
|
525
|
+
|
|
526
|
+
Append to `test/bats/librarian-lesson-transform.bats`. This block needs a project key, so it stands up its own git repo:
|
|
527
|
+
|
|
528
|
+
```bash
|
|
529
|
+
_storage_setup() {
|
|
530
|
+
# shellcheck disable=SC1091
|
|
531
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
|
|
532
|
+
# shellcheck disable=SC1091
|
|
533
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
|
|
534
|
+
# shellcheck disable=SC1091
|
|
535
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
536
|
+
# shellcheck disable=SC1091
|
|
537
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
538
|
+
|
|
539
|
+
PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
|
|
540
|
+
mkdir -p "$PROJECT_REPO"
|
|
541
|
+
git -C "$PROJECT_REPO" init -q
|
|
542
|
+
git -C "$PROJECT_REPO" config user.email t@example.com
|
|
543
|
+
git -C "$PROJECT_REPO" config user.name "Test"
|
|
544
|
+
git -C "$PROJECT_REPO" remote add origin git@github.com:org/lesson-fixture.git
|
|
545
|
+
PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
|
|
546
|
+
[ -n "$PROJECT_KEY" ]
|
|
547
|
+
LESSONS_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}/lessons"
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
@test "storage_init creates the proposals and approved directories" {
|
|
551
|
+
_storage_setup
|
|
552
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
553
|
+
[ -d "${LESSONS_DIR}/proposals" ]
|
|
554
|
+
[ -d "${LESSONS_DIR}/approved" ]
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
@test "write_proposal lands a ULID-keyed file carrying its artifact_id" {
|
|
558
|
+
_storage_setup
|
|
559
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
560
|
+
candidate=$(jq -cn '{claim: "c", rationale: "r"}')
|
|
561
|
+
id=$(librarian_lesson_write_proposal "$PROJECT_KEY" "$candidate" "01KZ45MKAM734ZS7JK24D2DK0R")
|
|
562
|
+
[ -n "$id" ]
|
|
563
|
+
[ -f "${LESSONS_DIR}/proposals/${id}.json" ]
|
|
564
|
+
jq -e '.artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R" and .candidate.claim == "c"' \
|
|
565
|
+
"${LESSONS_DIR}/proposals/${id}.json"
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
@test "append_declined writes one JSONL line per decline" {
|
|
569
|
+
_storage_setup
|
|
570
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
571
|
+
librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
|
|
572
|
+
librarian_lesson_append_declined "$PROJECT_KEY" "01KZEAF9EY4C6TTR0V7YFN9VYJ" "no_versions"
|
|
573
|
+
[ "$(wc -l < "${LESSONS_DIR}/declined.jsonl")" -eq 2 ]
|
|
574
|
+
head -n 1 "${LESSONS_DIR}/declined.jsonl" \
|
|
575
|
+
| jq -e '.artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R" and .reason == "no_resolution"'
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
@test "seen reports a fresh artifact as new" {
|
|
579
|
+
_storage_setup
|
|
580
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
581
|
+
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
582
|
+
[ "$status" -eq 1 ]
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
@test "seen finds an artifact recorded in declined.jsonl" {
|
|
586
|
+
_storage_setup
|
|
587
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
588
|
+
librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
|
|
589
|
+
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
590
|
+
[ "$status" -eq 0 ]
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
@test "seen survives a truncated trailing line in declined.jsonl" {
|
|
594
|
+
# What a process kill mid-append leaves behind. One bad line must not make
|
|
595
|
+
# every previously-declined artifact read as new.
|
|
596
|
+
_storage_setup
|
|
597
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
598
|
+
librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
|
|
599
|
+
printf '{"artifact_id":"01KZEAF9EY4C6TTR0V7YFN9VYJ","reason":"trunc' \
|
|
600
|
+
>> "${LESSONS_DIR}/declined.jsonl"
|
|
601
|
+
|
|
602
|
+
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
603
|
+
[ "$status" -eq 0 ]
|
|
604
|
+
|
|
605
|
+
# ...and the repair must not turn every lookup into a hit.
|
|
606
|
+
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKS84KPZQNWC02Z8FE0K"
|
|
607
|
+
[ "$status" -eq 1 ]
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
@test "seen finds an artifact already sitting in proposals" {
|
|
611
|
+
_storage_setup
|
|
612
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
613
|
+
librarian_lesson_write_proposal "$PROJECT_KEY" "$(jq -cn '{claim: "c"}')" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
614
|
+
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
615
|
+
[ "$status" -eq 0 ]
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
@test "seen finds an artifact already promoted into the approved pool" {
|
|
619
|
+
_storage_setup
|
|
620
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
621
|
+
jq -n '{artifact_id: "01KZ45MKAM734ZS7JK24D2DK0R"}' \
|
|
622
|
+
> "${LESSONS_DIR}/approved/01KZ45MKGQ7QZWMABQ4H12SHSV.json"
|
|
623
|
+
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
624
|
+
[ "$status" -eq 0 ]
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
629
|
+
|
|
630
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
631
|
+
Expected: the new tests FAIL with `librarian_lesson_storage_init: command not found`.
|
|
632
|
+
|
|
633
|
+
- [ ] **Step 3: Implement the library**
|
|
634
|
+
|
|
635
|
+
```bash
|
|
636
|
+
#!/usr/bin/env bash
|
|
637
|
+
# Storage for the lesson subtree.
|
|
638
|
+
#
|
|
639
|
+
# <project_dir>/lessons/proposals/<ulid>.json awaiting human confirmation
|
|
640
|
+
# <project_dir>/lessons/approved/<ulid>.json jury passed (written by 4z8.4)
|
|
641
|
+
# <project_dir>/lessons/declined.jsonl append-only, never re-judged
|
|
642
|
+
#
|
|
643
|
+
# Lessons live apart from librarian's memory `proposals/` on purpose: a memory
|
|
644
|
+
# promotion writes to this machine, a lesson proposal is a step toward
|
|
645
|
+
# publishing beyond it. Separate trees keep a confirmation surface from
|
|
646
|
+
# merging the two by accident.
|
|
647
|
+
#
|
|
648
|
+
# Requires librarian-storage.sh (librarian_project_dir) and librarian-ulid.sh.
|
|
649
|
+
|
|
650
|
+
librarian_lessons_dir() {
|
|
651
|
+
local key="$1"
|
|
652
|
+
printf '%s/lessons' "$(librarian_project_dir "$key")"
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
librarian_lesson_storage_init() {
|
|
656
|
+
local key="$1"
|
|
657
|
+
[[ -z "$key" ]] && return 1
|
|
658
|
+
local dir
|
|
659
|
+
dir=$(librarian_lessons_dir "$key")
|
|
660
|
+
mkdir -p "$dir/proposals" "$dir/approved" 2>/dev/null
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
# Write one candidate. Prints the ULID on success.
|
|
664
|
+
# Usage: librarian_lesson_write_proposal <key> <candidate_json> <artifact_id>
|
|
665
|
+
librarian_lesson_write_proposal() {
|
|
666
|
+
local key="$1"
|
|
667
|
+
local candidate="$2"
|
|
668
|
+
local artifact_id="$3"
|
|
669
|
+
[[ -z "$key" || -z "$candidate" || -z "$artifact_id" ]] && return 1
|
|
670
|
+
|
|
671
|
+
librarian_lesson_storage_init "$key" || return 1
|
|
672
|
+
|
|
673
|
+
local id now out
|
|
674
|
+
id=$(librarian_ulid) || return 1
|
|
675
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
676
|
+
out="$(librarian_lessons_dir "$key")/proposals/${id}.json"
|
|
677
|
+
|
|
678
|
+
jq -n \
|
|
679
|
+
--arg id "$id" \
|
|
680
|
+
--arg artifact_id "$artifact_id" \
|
|
681
|
+
--arg created "$now" \
|
|
682
|
+
--argjson candidate "$candidate" \
|
|
683
|
+
'{
|
|
684
|
+
id: $id,
|
|
685
|
+
artifact_id: $artifact_id,
|
|
686
|
+
created_at: $created,
|
|
687
|
+
status: "pending",
|
|
688
|
+
candidate: $candidate
|
|
689
|
+
}' > "$out" 2>/dev/null || return 1
|
|
690
|
+
|
|
691
|
+
printf '%s' "$id"
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
# Append one decline. Only ever called for real determinations — never for a
|
|
695
|
+
# missing CLI, a timeout, or an empty response. Recording an outage here would
|
|
696
|
+
# bury a good artifact permanently, because the watermark has already moved
|
|
697
|
+
# past it and declined entries are never re-read.
|
|
698
|
+
#
|
|
699
|
+
# Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail]
|
|
700
|
+
librarian_lesson_append_declined() {
|
|
701
|
+
local key="$1"
|
|
702
|
+
local artifact_id="$2"
|
|
703
|
+
local reason="$3"
|
|
704
|
+
local detail="${4:-}"
|
|
705
|
+
[[ -z "$key" || -z "$artifact_id" || -z "$reason" ]] && return 1
|
|
706
|
+
|
|
707
|
+
librarian_lesson_storage_init "$key" || return 1
|
|
708
|
+
|
|
709
|
+
local now line
|
|
710
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
711
|
+
line=$(jq -cn \
|
|
712
|
+
--arg artifact_id "$artifact_id" \
|
|
713
|
+
--arg reason "$reason" \
|
|
714
|
+
--arg detail "$detail" \
|
|
715
|
+
--arg at "$now" \
|
|
716
|
+
'{
|
|
717
|
+
artifact_id: $artifact_id,
|
|
718
|
+
reason: $reason,
|
|
719
|
+
detail: (if $detail == "" then null else $detail end),
|
|
720
|
+
declined_at: $at
|
|
721
|
+
}') || return 1
|
|
722
|
+
|
|
723
|
+
printf '%s\n' "$line" >> "$(librarian_lessons_dir "$key")/declined.jsonl"
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
# Returns 0 when this artifact has already been handled.
|
|
727
|
+
#
|
|
728
|
+
# The watermark cannot answer this: last_scan.json records only *when* we
|
|
729
|
+
# scanned, not which artifacts were considered. Idempotency is artifact-keyed
|
|
730
|
+
# and permanent, unlike tombstones (body-hash keyed, TTL'd).
|
|
731
|
+
#
|
|
732
|
+
# Usage: librarian_lesson_seen <key> <artifact_id>
|
|
733
|
+
librarian_lesson_seen() {
|
|
734
|
+
local key="$1"
|
|
735
|
+
local artifact_id="$2"
|
|
736
|
+
[[ -z "$key" || -z "$artifact_id" ]] && return 1
|
|
737
|
+
|
|
738
|
+
local dir
|
|
739
|
+
dir=$(librarian_lessons_dir "$key")
|
|
740
|
+
|
|
741
|
+
# -R reads each line as a raw string and fromjson? yields nothing for a
|
|
742
|
+
# line that fails to parse, instead of aborting the whole jq invocation.
|
|
743
|
+
# Without this, one truncated trailing line (e.g. a process killed
|
|
744
|
+
# mid-append) makes jq exit 5 for the entire file, and every artifact
|
|
745
|
+
# declined before that line reads back as "not seen."
|
|
746
|
+
#
|
|
747
|
+
# `objects` after fromjson? is load-bearing, not decorative: fromjson?
|
|
748
|
+
# only guards the *parse*, not what comes after it in the pipe. A line
|
|
749
|
+
# that is valid JSON but not an object (a bare `123`, `true`, `"str"`, or
|
|
750
|
+
# `[1,2,3]`) parses cleanly, then `.artifact_id` indexing on that
|
|
751
|
+
# non-object errors out the whole jq invocation — the same
|
|
752
|
+
# every-prior-decline-reads-as-unseen failure the -R/fromjson? guard
|
|
753
|
+
# above exists to prevent, just reached through a different door.
|
|
754
|
+
# `objects` filters those values out before `.artifact_id` ever runs.
|
|
755
|
+
if [[ -f "$dir/declined.jsonl" ]] \
|
|
756
|
+
&& jq -Re --arg a "$artifact_id" 'fromjson? | objects | select(.artifact_id == $a)' \
|
|
757
|
+
"$dir/declined.jsonl" >/dev/null 2>&1; then
|
|
758
|
+
return 0
|
|
759
|
+
fi
|
|
760
|
+
|
|
761
|
+
local f
|
|
762
|
+
for f in "$dir"/proposals/*.json "$dir"/approved/*.json; do
|
|
763
|
+
[[ -f "$f" ]] || continue
|
|
764
|
+
if jq -e --arg a "$artifact_id" '.artifact_id == $a' "$f" >/dev/null 2>&1; then
|
|
765
|
+
return 0
|
|
766
|
+
fi
|
|
767
|
+
done
|
|
768
|
+
|
|
769
|
+
return 1
|
|
770
|
+
}
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
- [ ] **Step 4: Run the tests**
|
|
774
|
+
|
|
775
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
776
|
+
Expected: all PASS.
|
|
777
|
+
|
|
778
|
+
- [ ] **Step 5: Shellcheck**
|
|
779
|
+
|
|
780
|
+
Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-storage.sh`
|
|
781
|
+
Expected: no output.
|
|
782
|
+
|
|
783
|
+
- [ ] **Step 6: Commit**
|
|
784
|
+
|
|
785
|
+
```bash
|
|
786
|
+
git add plugins/librarian/scripts/lib/librarian-lesson-storage.sh \
|
|
787
|
+
test/bats/librarian-lesson-transform.bats
|
|
788
|
+
git commit -m "feat(librarian): keep lesson state in its own subtree, keyed for idempotency :file_folder:"
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
---
|
|
792
|
+
|
|
793
|
+
### Task 4: The transform itself
|
|
794
|
+
|
|
795
|
+
**Files:**
|
|
796
|
+
- Create: `plugins/librarian/scripts/lib/librarian-lesson-transform.sh`
|
|
797
|
+
- Modify: `plugins/librarian/config.json`
|
|
798
|
+
- Test: `test/bats/librarian-lesson-transform.bats` (append)
|
|
799
|
+
|
|
800
|
+
**Interfaces:**
|
|
801
|
+
- Consumes: `librarian_lesson_pregate`, `librarian_lesson_validate_candidate`, `librarian_lesson_write_proposal`, `librarian_lesson_append_declined`, `librarian_lesson_seen`.
|
|
802
|
+
- Produces:
|
|
803
|
+
- `librarian_lesson_build_prompt <artifact_json>` → prints the prompt
|
|
804
|
+
- `librarian_lesson_call <artifact_json> <model>` → prints raw model JSON, or empty on any infrastructure failure
|
|
805
|
+
- `librarian_lesson_transform_one <key> <artifact_json>` → prints `proposed:<ulid>`, `declined:<reason>`, `skipped:pregate`, or `unavailable` (infrastructure). Exit 0 always.
|
|
806
|
+
|
|
807
|
+
- [ ] **Step 1: Add config defaults**
|
|
808
|
+
|
|
809
|
+
In `plugins/librarian/config.json`, add under the `librarian` key:
|
|
810
|
+
|
|
811
|
+
```json
|
|
812
|
+
"lesson_transform": {
|
|
813
|
+
"model": "claude-haiku-4-5-20251001",
|
|
814
|
+
"timeout_seconds": 20
|
|
815
|
+
}
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
There is no `enabled` flag — that option was removed repo-wide in #108.
|
|
819
|
+
|
|
820
|
+
**Only two keys, deliberately.** The spec listed `temperature` and
|
|
821
|
+
`max_output_tokens` as well, but `claude --help` exposes `--model` and no
|
|
822
|
+
sampling flags, so those values cannot reach the model through this CLI. The
|
|
823
|
+
existing `librarian-classifier.sh` reads
|
|
824
|
+
`.librarian.classifier.{temperature,max_output_tokens}` and passes them to a
|
|
825
|
+
function that ignores both — dead config that reads as if it works. Do not
|
|
826
|
+
reproduce it here. If sampling control is genuinely needed, it requires moving
|
|
827
|
+
off `claude -p` to the API, which is a separate decision.
|
|
828
|
+
|
|
829
|
+
- [ ] **Step 2: Write the failing tests**
|
|
830
|
+
|
|
831
|
+
Append to the bats file. This block extends `_storage_setup` with a stubbed `claude`:
|
|
832
|
+
|
|
833
|
+
```bash
|
|
834
|
+
_transform_setup() {
|
|
835
|
+
_storage_setup
|
|
836
|
+
# shellcheck disable=SC1091
|
|
837
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
|
|
838
|
+
# shellcheck disable=SC1091
|
|
839
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
|
|
840
|
+
librarian_config_load "$PROJECT_REPO"
|
|
841
|
+
# shellcheck disable=SC1091
|
|
842
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-transform.sh"
|
|
843
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
844
|
+
|
|
845
|
+
STUB_BIN="${BATS_TEST_TMPDIR}/bin"
|
|
846
|
+
mkdir -p "$STUB_BIN"
|
|
847
|
+
cat > "${STUB_BIN}/claude" <<'STUB'
|
|
848
|
+
#!/usr/bin/env bash
|
|
849
|
+
prompt=$(cat)
|
|
850
|
+
# Stub-selector markers are checked before the generic "module-runner"
|
|
851
|
+
# content match: several fixtures embed real vitest/vite prose (which
|
|
852
|
+
# contains "module-runner") alongside their marker, and the marker names
|
|
853
|
+
# the intended stub behavior.
|
|
854
|
+
if [[ "$prompt" == *"no-resolution-stub"* ]]; then
|
|
855
|
+
printf '%s' '{"eligible":false,"reason":"no_resolution"}'
|
|
856
|
+
elif [[ "$prompt" == *"no-versions-stub"* ]]; then
|
|
857
|
+
printf '%s' '{"eligible":false,"reason":"no_versions"}'
|
|
858
|
+
elif [[ "$prompt" == *"npm-range-stub"* ]]; then
|
|
859
|
+
printf '%s' '{"claim":"c","rationale":"r","evidence":{"resolution":"fix"},"applies_to":{"stack":["vite"],"scope":{"kind":"versioned","versions":{"vite":"^5.4.21"}},"file_patterns":[],"task_kinds":[]}}'
|
|
860
|
+
elif [[ "$prompt" == *"module-runner"* ]]; then
|
|
861
|
+
printf '%s' '{"claim":"Vitest 4 cannot import vite/module-runner on Vite 5","rationale":"vite/module-runner ships in Vite 6; Vitest 4 assumes it exists.","evidence":{"resolution":"Pin vitest to 3.x until Vite 6 lands."},"applies_to":{"stack":["vite","vitest"],"scope":{"kind":"versioned","versions":{"vite":"<6","vitest":">=4"}},"file_patterns":[],"task_kinds":[]}}'
|
|
862
|
+
else
|
|
863
|
+
printf '%s' 'not json at all'
|
|
864
|
+
fi
|
|
865
|
+
STUB
|
|
866
|
+
chmod +x "${STUB_BIN}/claude"
|
|
867
|
+
export PATH="${STUB_BIN}:${PATH}"
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
_seed() {
|
|
871
|
+
jq -cn --arg id "$1" --arg s "$2" --arg d "$3" --arg k "$PROJECT_KEY" \
|
|
872
|
+
'{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
|
|
873
|
+
created_at: "2026-08-03T15:59:48Z", summary: $s, detail: $d}'
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
@test "transform_one proposes a candidate for a groundable artifact" {
|
|
877
|
+
_transform_setup
|
|
878
|
+
art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
|
|
879
|
+
"Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.")
|
|
880
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
881
|
+
[ "$status" -eq 0 ]
|
|
882
|
+
[[ "$output" == proposed:* ]]
|
|
883
|
+
id="${output#proposed:}"
|
|
884
|
+
jq -e '.candidate.applies_to.scope.versions.vite == "<6"
|
|
885
|
+
and .candidate.applies_to.scope.versions.vitest == ">=4"' \
|
|
886
|
+
"${LESSONS_DIR}/proposals/${id}.json"
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
@test "transform_one declines the real vitest artifact for having no resolution" {
|
|
890
|
+
_transform_setup
|
|
891
|
+
# The artifact that motivated the pipeline. Its session ended on an open
|
|
892
|
+
# question — the fix was never found — so it cannot become a lesson.
|
|
893
|
+
art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" \
|
|
894
|
+
"no-resolution-stub: Vitest 4.1.9 / Vite 5.x mismatch confirmed as real, blocking bug." \
|
|
895
|
+
"Running pnpm test reproduces failures. Vitest 4.1.9 attempts to import vite/module-runner which does not exist in Vite 5.4.21.")
|
|
896
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
897
|
+
[ "$status" -eq 0 ]
|
|
898
|
+
[ "$output" = "declined:no_resolution" ]
|
|
899
|
+
tail -n 1 "${LESSONS_DIR}/declined.jsonl" | jq -e '.reason == "no_resolution"'
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
@test "transform_one declines when the model cannot infer versions" {
|
|
903
|
+
_transform_setup
|
|
904
|
+
art=$(_seed "01KZ45MKGQ7QZWMABQ4H12SHSV" "no-versions-stub: 5.4 something" "detail 1.2")
|
|
905
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
906
|
+
[ "$output" = "declined:no_versions" ]
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
@test "transform_one declines unparseable model output as transform_invalid" {
|
|
910
|
+
_transform_setup
|
|
911
|
+
art=$(_seed "01KZ45MKME229J0QK0690TREAB" "garbage 1.0" "detail 2.0")
|
|
912
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
913
|
+
[ "$output" = "declined:transform_invalid" ]
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
@test "transform_one declines an npm-style range as schema_invalid" {
|
|
917
|
+
_transform_setup
|
|
918
|
+
art=$(_seed "01KZ45MKS84KPZQNWC02Z8FE0K" "npm-range-stub 5.4" "detail 1.0")
|
|
919
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
920
|
+
[ "$output" = "declined:schema_invalid" ]
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
@test "transform_one skips a version-free artifact without touching the ledger" {
|
|
924
|
+
_transform_setup
|
|
925
|
+
art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Prefer functional patterns" "User said so.")
|
|
926
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
927
|
+
[ "$output" = "skipped:pregate" ]
|
|
928
|
+
[ ! -f "${LESSONS_DIR}/declined.jsonl" ]
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
@test "a missing claude CLI is not a verdict and writes nothing" {
|
|
932
|
+
_transform_setup
|
|
933
|
+
rm -f "${STUB_BIN}/claude"
|
|
934
|
+
art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x mismatch" \
|
|
935
|
+
"Vitest 4.1.9 imports vite/module-runner absent in Vite 5.4.21.")
|
|
936
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
937
|
+
[ "$status" -eq 0 ]
|
|
938
|
+
[ "$output" = "unavailable" ]
|
|
939
|
+
[ ! -f "${LESSONS_DIR}/declined.jsonl" ]
|
|
940
|
+
[ -z "$(ls -A "${LESSONS_DIR}/proposals")" ]
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
@test "an empty model response is not a verdict and writes nothing" {
|
|
944
|
+
_transform_setup
|
|
945
|
+
cat > "${STUB_BIN}/claude" <<'STUB'
|
|
946
|
+
#!/usr/bin/env bash
|
|
947
|
+
cat >/dev/null
|
|
948
|
+
printf ''
|
|
949
|
+
STUB
|
|
950
|
+
chmod +x "${STUB_BIN}/claude"
|
|
951
|
+
art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
|
|
952
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
953
|
+
[ "$output" = "unavailable" ]
|
|
954
|
+
[ ! -f "${LESSONS_DIR}/declined.jsonl" ]
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
@test "an already-declined artifact is not sent to the model a second time" {
|
|
958
|
+
_transform_setup
|
|
959
|
+
librarian_lesson_append_declined "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R" "no_resolution"
|
|
960
|
+
art=$(_seed "01KZ45MKAM734ZS7JK24D2DK0R" "Vitest 4.1.9 / Vite 5.x" "module-runner missing in 5.4.21")
|
|
961
|
+
run librarian_lesson_transform_one "$PROJECT_KEY" "$art"
|
|
962
|
+
[ "$output" = "skipped:seen" ]
|
|
963
|
+
[ "$(wc -l < "${LESSONS_DIR}/declined.jsonl")" -eq 1 ]
|
|
964
|
+
}
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
- [ ] **Step 3: Run and watch them fail**
|
|
968
|
+
|
|
969
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
970
|
+
Expected: the new tests FAIL with `librarian_lesson_transform_one: command not found`.
|
|
971
|
+
|
|
972
|
+
- [ ] **Step 4: Implement the transform**
|
|
973
|
+
|
|
974
|
+
```bash
|
|
975
|
+
#!/usr/bin/env bash
|
|
976
|
+
# Lesson transform — librarian's fifth stage.
|
|
977
|
+
#
|
|
978
|
+
# Reads one durable, classified, deduped archivist artifact and emits a lesson
|
|
979
|
+
# candidate: the four fields inferable from an artifact (claim, rationale,
|
|
980
|
+
# evidence, applies_to). The other nine required Lesson fields belong to later
|
|
981
|
+
# stages, so this never produces a schema-complete Lesson and cannot be
|
|
982
|
+
# validated against the full lesson schema.
|
|
983
|
+
#
|
|
984
|
+
# Requires librarian-lesson-validate.sh, librarian-lesson-storage.sh, and
|
|
985
|
+
# librarian-config.sh (librarian_config_get).
|
|
986
|
+
#
|
|
987
|
+
# Config inputs (read via librarian_config_get from librarian_lesson_call):
|
|
988
|
+
# librarian.lesson_transform.model Anthropic model id
|
|
989
|
+
# librarian.lesson_transform.timeout_seconds Hard wall-clock ceiling
|
|
990
|
+
|
|
991
|
+
# Fallback when config hasn't been loaded or leaves the key unset.
|
|
992
|
+
_LIBRARIAN_LESSON_DEFAULT_TIMEOUT_SECONDS=20
|
|
993
|
+
|
|
994
|
+
# Cap on how much of a response the JSON-object extractor will scan. The
|
|
995
|
+
# scanner is a per-character bash loop — effectively O(n^2) on long input,
|
|
996
|
+
# since bash string slicing on a long string isn't O(1) per call — and it
|
|
997
|
+
# runs after the claude call, uncapped, inside a SessionEnd hook that must
|
|
998
|
+
# not stall session end. `claude -p` has no output-size flag to bound the
|
|
999
|
+
# response itself, so the bound is enforced here instead. A response that
|
|
1000
|
+
# exceeds this without yielding valid JSON in the scanned prefix has not
|
|
1001
|
+
# followed the "output ONLY a single JSON object on one line" instruction
|
|
1002
|
+
# anyway, so declining it is correct, not just expedient.
|
|
1003
|
+
_LIBRARIAN_LESSON_EXTRACT_MAX_CHARS=8192
|
|
1004
|
+
|
|
1005
|
+
# Usage: librarian_lesson_build_prompt <artifact_json>
|
|
1006
|
+
librarian_lesson_build_prompt() {
|
|
1007
|
+
local artifact="$1"
|
|
1008
|
+
local summary detail files_list artifact_id session_id project_key created_at
|
|
1009
|
+
|
|
1010
|
+
summary=$(printf '%s' "$artifact" | jq -r '.summary // ""')
|
|
1011
|
+
detail=$(printf '%s' "$artifact" | jq -r '.detail // ""')
|
|
1012
|
+
files_list=$(printf '%s' "$artifact" | jq -r '(.files // []) | join(", ")')
|
|
1013
|
+
artifact_id=$(printf '%s' "$artifact" | jq -r '.id // ""')
|
|
1014
|
+
session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
|
|
1015
|
+
project_key=$(printf '%s' "$artifact" | jq -r '.project_key // ""')
|
|
1016
|
+
created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
|
|
1017
|
+
|
|
1018
|
+
cat <<EOF
|
|
1019
|
+
You are turning a session artifact into a shareable lesson, or refusing to.
|
|
1020
|
+
|
|
1021
|
+
A lesson states something that was learned, why it follows, and the exact
|
|
1022
|
+
version range in which it holds. It is shared with other people, so a wrong
|
|
1023
|
+
lesson actively misleads. Refusing is the safe answer.
|
|
1024
|
+
|
|
1025
|
+
Output ONLY one JSON object on one line. No markdown fences, no prose.
|
|
1026
|
+
|
|
1027
|
+
REFUSE when either is true, by outputting exactly:
|
|
1028
|
+
{ "eligible": false, "reason": "no_resolution" }
|
|
1029
|
+
{ "eligible": false, "reason": "no_versions" }
|
|
1030
|
+
|
|
1031
|
+
- "no_resolution": the artifact records a problem but not what resolved it.
|
|
1032
|
+
"This breaks" without "and this fixed it" is a warning, not a lesson.
|
|
1033
|
+
Never invent a resolution that is not in the artifact.
|
|
1034
|
+
- "no_versions": you cannot determine which versions the claim is bound to.
|
|
1035
|
+
|
|
1036
|
+
Otherwise output:
|
|
1037
|
+
{
|
|
1038
|
+
"claim": "<what was learned, one sentence>",
|
|
1039
|
+
"rationale": "<why the claim follows from the evidence>",
|
|
1040
|
+
"evidence": { "resolution": "<what actually resolved it, from the artifact>" },
|
|
1041
|
+
"applies_to": {
|
|
1042
|
+
"stack": ["<tool or package name>", "<another tool or package name>"],
|
|
1043
|
+
"scope": { "kind": "versioned", "versions": { "<stack entry>": "<range>" } },
|
|
1044
|
+
"file_patterns": [],
|
|
1045
|
+
"task_kinds": []
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
VERSION RANGE RULES — these are strict and a violation is discarded:
|
|
1050
|
+
- Allowed: "<6", "<=6", "=6", ">4", ">=4", or two-sided ">=4 <6".
|
|
1051
|
+
- FORBIDDEN: npm syntax. Never "^5.4.21", "~5", "5.x", or a bare "5.4.21".
|
|
1052
|
+
- FORBIDDEN: ">=0", ">=0.0", ">=0.0.0". An unbounded lower bound matches
|
|
1053
|
+
everything and would never expire.
|
|
1054
|
+
- Every key in versions MUST also appear in stack.
|
|
1055
|
+
- Generalize honestly. Observing a break on vite 5.4.21 with vitest 4.1.9
|
|
1056
|
+
supports {"vite": "<6", "vitest": ">=4"} only if the cause is the missing
|
|
1057
|
+
API rather than that exact build.
|
|
1058
|
+
|
|
1059
|
+
There is no version-independent option. If the claim is not bound to a
|
|
1060
|
+
version range, refuse with "no_versions".
|
|
1061
|
+
|
|
1062
|
+
<artifact>
|
|
1063
|
+
id: ${artifact_id}
|
|
1064
|
+
summary: ${summary}
|
|
1065
|
+
detail: ${detail}
|
|
1066
|
+
files: ${files_list}
|
|
1067
|
+
project_key: ${project_key}
|
|
1068
|
+
session_id: ${session_id}
|
|
1069
|
+
created_at: ${created_at}
|
|
1070
|
+
</artifact>
|
|
1071
|
+
EOF
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
# Extract the first balanced top-level JSON object from a string that may
|
|
1075
|
+
# carry surrounding prose ("Here is the JSON: {...}"). Prints the substring
|
|
1076
|
+
# on success, prints nothing and returns 1 on failure. Depth-tracks braces
|
|
1077
|
+
# while skipping ones inside string literals (honoring backslash escapes),
|
|
1078
|
+
# so a claim like `{"claim": "uses \"quotes\" and { in prose"}` still
|
|
1079
|
+
# extracts correctly.
|
|
1080
|
+
#
|
|
1081
|
+
# Prose wrapping is not the same failure as unparseable output: a model that
|
|
1082
|
+
# added a sentence around otherwise-valid JSON would very likely produce
|
|
1083
|
+
# clean JSON on a resample, so declining it as transform_invalid would bury
|
|
1084
|
+
# a good artifact over formatting noise rather than a real judgment problem.
|
|
1085
|
+
#
|
|
1086
|
+
# Usage: _librarian_lesson_extract_json_object <text>
|
|
1087
|
+
_librarian_lesson_extract_json_object() {
|
|
1088
|
+
local text="$1"
|
|
1089
|
+
local start=-1 depth=0 in_string=0 escape=0
|
|
1090
|
+
local i len ch
|
|
1091
|
+
|
|
1092
|
+
len=${#text}
|
|
1093
|
+
for (( i = 0; i < len; i++ )); do
|
|
1094
|
+
ch="${text:i:1}"
|
|
1095
|
+
if [[ $start -eq -1 ]]; then
|
|
1096
|
+
[[ "$ch" == "{" ]] && { start=$i; depth=1; }
|
|
1097
|
+
continue
|
|
1098
|
+
fi
|
|
1099
|
+
if [[ $escape -eq 1 ]]; then
|
|
1100
|
+
escape=0
|
|
1101
|
+
continue
|
|
1102
|
+
fi
|
|
1103
|
+
case "$ch" in
|
|
1104
|
+
'\') [[ $in_string -eq 1 ]] && escape=1 ;;
|
|
1105
|
+
'"') in_string=$((1 - in_string)) ;;
|
|
1106
|
+
'{') [[ $in_string -eq 0 ]] && depth=$((depth + 1)) ;;
|
|
1107
|
+
'}')
|
|
1108
|
+
if [[ $in_string -eq 0 ]]; then
|
|
1109
|
+
depth=$((depth - 1))
|
|
1110
|
+
if [[ $depth -eq 0 ]]; then
|
|
1111
|
+
printf '%s' "${text:start:i-start+1}"
|
|
1112
|
+
return 0
|
|
1113
|
+
fi
|
|
1114
|
+
fi
|
|
1115
|
+
;;
|
|
1116
|
+
esac
|
|
1117
|
+
done
|
|
1118
|
+
|
|
1119
|
+
return 1
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
# Call the model. Prints raw output, or empty string on ANY infrastructure
|
|
1123
|
+
# failure — missing CLI, timeout, empty response. Empty means "could not
|
|
1124
|
+
# judge", which is not a verdict.
|
|
1125
|
+
#
|
|
1126
|
+
# Usage: librarian_lesson_call <artifact_json> <model>
|
|
1127
|
+
librarian_lesson_call() {
|
|
1128
|
+
local artifact="$1"
|
|
1129
|
+
local model="${2:-}"
|
|
1130
|
+
|
|
1131
|
+
command -v claude >/dev/null 2>&1 || return 0
|
|
1132
|
+
[[ -z "$artifact" ]] && return 0
|
|
1133
|
+
|
|
1134
|
+
local prompt_file
|
|
1135
|
+
prompt_file=$(mktemp -t librarian-lesson.XXXXXX 2>/dev/null) \
|
|
1136
|
+
|| prompt_file="/tmp/librarian-lesson.$$"
|
|
1137
|
+
# shellcheck disable=SC2064
|
|
1138
|
+
trap "rm -f '$prompt_file'" EXIT
|
|
1139
|
+
|
|
1140
|
+
librarian_lesson_build_prompt "$artifact" > "$prompt_file" || return 0
|
|
1141
|
+
|
|
1142
|
+
local args=(-p --max-turns 1)
|
|
1143
|
+
[[ -n "$model" ]] && args+=(--model "$model")
|
|
1144
|
+
|
|
1145
|
+
local timeout_seconds
|
|
1146
|
+
timeout_seconds=$(librarian_config_get '.librarian.lesson_transform.timeout_seconds' 2>/dev/null)
|
|
1147
|
+
[[ -z "$timeout_seconds" || "$timeout_seconds" == "null" ]] \
|
|
1148
|
+
&& timeout_seconds="$_LIBRARIAN_LESSON_DEFAULT_TIMEOUT_SECONDS"
|
|
1149
|
+
|
|
1150
|
+
local response=""
|
|
1151
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
1152
|
+
response=$(timeout "$timeout_seconds" \
|
|
1153
|
+
claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
1154
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
1155
|
+
response=$(gtimeout "$timeout_seconds" \
|
|
1156
|
+
claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
1157
|
+
else
|
|
1158
|
+
response=$(claude "${args[@]}" < "$prompt_file" 2>/dev/null) || response=""
|
|
1159
|
+
fi
|
|
1160
|
+
|
|
1161
|
+
rm -f "$prompt_file"
|
|
1162
|
+
trap - EXIT
|
|
1163
|
+
|
|
1164
|
+
[[ -z "$response" ]] && return 0
|
|
1165
|
+
|
|
1166
|
+
local cleaned
|
|
1167
|
+
cleaned=$(printf '%s' "$response" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
|
|
1168
|
+
|
|
1169
|
+
# Fast path: the response is already valid JSON on its own.
|
|
1170
|
+
if printf '%s' "$cleaned" | jq -e . >/dev/null 2>&1; then
|
|
1171
|
+
printf '%s' "$cleaned"
|
|
1172
|
+
return 0
|
|
1173
|
+
fi
|
|
1174
|
+
|
|
1175
|
+
# Slow path: pull the first balanced JSON object out of surrounding
|
|
1176
|
+
# prose. Bounded to a fixed prefix (see _LIBRARIAN_LESSON_EXTRACT_MAX_CHARS)
|
|
1177
|
+
# so a rambling, arbitrarily long response can't turn the O(n^2) scan
|
|
1178
|
+
# into an unbounded stall. Only used when it actually recovers valid
|
|
1179
|
+
# JSON — otherwise fall through to the original text so the
|
|
1180
|
+
# unparseable case still declines.
|
|
1181
|
+
local extracted
|
|
1182
|
+
extracted=$(_librarian_lesson_extract_json_object \
|
|
1183
|
+
"${cleaned:0:_LIBRARIAN_LESSON_EXTRACT_MAX_CHARS}")
|
|
1184
|
+
if [[ -n "$extracted" ]] && printf '%s' "$extracted" | jq -e . >/dev/null 2>&1; then
|
|
1185
|
+
printf '%s' "$extracted"
|
|
1186
|
+
return 0
|
|
1187
|
+
fi
|
|
1188
|
+
|
|
1189
|
+
printf '%s' "$cleaned"
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
# Transform one artifact. Always exits 0. Prints exactly one of:
|
|
1193
|
+
# proposed:<ulid> candidate written
|
|
1194
|
+
# declined:<reason> a real verdict, recorded in declined.jsonl
|
|
1195
|
+
# skipped:pregate no version token; free to redo, nothing recorded
|
|
1196
|
+
# skipped:seen already handled
|
|
1197
|
+
# unavailable infrastructure failure; nothing recorded
|
|
1198
|
+
#
|
|
1199
|
+
# Usage: librarian_lesson_transform_one <key> <artifact_json>
|
|
1200
|
+
librarian_lesson_transform_one() {
|
|
1201
|
+
local key="$1"
|
|
1202
|
+
local artifact="$2"
|
|
1203
|
+
[[ -z "$key" || -z "$artifact" ]] && { printf 'unavailable'; return 0; }
|
|
1204
|
+
|
|
1205
|
+
local artifact_id session_id project_key created_at
|
|
1206
|
+
artifact_id=$(printf '%s' "$artifact" | jq -r '.id // ""')
|
|
1207
|
+
session_id=$(printf '%s' "$artifact" | jq -r '.session_id // ""')
|
|
1208
|
+
project_key=$(printf '%s' "$artifact" | jq -r '.project_key // ""')
|
|
1209
|
+
created_at=$(printf '%s' "$artifact" | jq -r '.created_at // ""')
|
|
1210
|
+
[[ -z "$artifact_id" ]] && { printf 'unavailable'; return 0; }
|
|
1211
|
+
|
|
1212
|
+
if librarian_lesson_seen "$key" "$artifact_id"; then
|
|
1213
|
+
printf 'skipped:seen'
|
|
1214
|
+
return 0
|
|
1215
|
+
fi
|
|
1216
|
+
|
|
1217
|
+
if ! librarian_lesson_pregate "$artifact"; then
|
|
1218
|
+
printf 'skipped:pregate'
|
|
1219
|
+
return 0
|
|
1220
|
+
fi
|
|
1221
|
+
|
|
1222
|
+
local model raw
|
|
1223
|
+
model=$(librarian_config_get '.librarian.lesson_transform.model')
|
|
1224
|
+
|
|
1225
|
+
raw=$(librarian_lesson_call "$artifact" "$model")
|
|
1226
|
+
|
|
1227
|
+
# Empty means infrastructure, not verdict. Leave the artifact untouched.
|
|
1228
|
+
if [[ -z "$raw" ]]; then
|
|
1229
|
+
printf 'unavailable'
|
|
1230
|
+
return 0
|
|
1231
|
+
fi
|
|
1232
|
+
|
|
1233
|
+
if ! printf '%s' "$raw" | jq -e . >/dev/null 2>&1; then
|
|
1234
|
+
librarian_lesson_append_declined "$key" "$artifact_id" "transform_invalid"
|
|
1235
|
+
printf 'declined:transform_invalid'
|
|
1236
|
+
return 0
|
|
1237
|
+
fi
|
|
1238
|
+
|
|
1239
|
+
# An explicit refusal is a real answer. Checked with jq -e rather than a
|
|
1240
|
+
# `// empty` string capture: jq's // operator treats JSON `false` as
|
|
1241
|
+
# falsy, same as null, so `.eligible // empty` silently discards a real
|
|
1242
|
+
# `"eligible": false` refusal instead of reporting it.
|
|
1243
|
+
local reason
|
|
1244
|
+
if printf '%s' "$raw" | jq -e '.eligible == false' >/dev/null 2>&1; then
|
|
1245
|
+
reason=$(printf '%s' "$raw" | jq -r '.reason // "transform_invalid"')
|
|
1246
|
+
case "$reason" in
|
|
1247
|
+
no_resolution|no_versions) ;;
|
|
1248
|
+
*) reason="transform_invalid" ;;
|
|
1249
|
+
esac
|
|
1250
|
+
librarian_lesson_append_declined "$key" "$artifact_id" "$reason"
|
|
1251
|
+
printf 'declined:%s' "$reason"
|
|
1252
|
+
return 0
|
|
1253
|
+
fi
|
|
1254
|
+
|
|
1255
|
+
# Stitch in the provenance the model is not asked to produce.
|
|
1256
|
+
local candidate
|
|
1257
|
+
candidate=$(printf '%s' "$raw" | jq -c \
|
|
1258
|
+
--arg aid "$artifact_id" \
|
|
1259
|
+
--arg sid "$session_id" \
|
|
1260
|
+
--arg pk "$project_key" \
|
|
1261
|
+
--arg at "$created_at" \
|
|
1262
|
+
'.evidence.artifact_ids = [$aid]
|
|
1263
|
+
| .evidence.session_ids = [$sid]
|
|
1264
|
+
| .evidence.project_key = $pk
|
|
1265
|
+
| .evidence.observed_at = $at' 2>/dev/null) || candidate=""
|
|
1266
|
+
|
|
1267
|
+
if [[ -z "$candidate" ]]; then
|
|
1268
|
+
librarian_lesson_append_declined "$key" "$artifact_id" "transform_invalid"
|
|
1269
|
+
printf 'declined:transform_invalid'
|
|
1270
|
+
return 0
|
|
1271
|
+
fi
|
|
1272
|
+
|
|
1273
|
+
if ! librarian_lesson_validate_candidate "$candidate" 2>/dev/null; then
|
|
1274
|
+
librarian_lesson_append_declined "$key" "$artifact_id" "schema_invalid"
|
|
1275
|
+
printf 'declined:schema_invalid'
|
|
1276
|
+
return 0
|
|
1277
|
+
fi
|
|
1278
|
+
|
|
1279
|
+
local id
|
|
1280
|
+
id=$(librarian_lesson_write_proposal "$key" "$candidate" "$artifact_id") || {
|
|
1281
|
+
printf 'unavailable'
|
|
1282
|
+
return 0
|
|
1283
|
+
}
|
|
1284
|
+
printf 'proposed:%s' "$id"
|
|
1285
|
+
}
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
- [ ] **Step 5: Run the tests**
|
|
1289
|
+
|
|
1290
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
1291
|
+
Expected: all PASS.
|
|
1292
|
+
|
|
1293
|
+
`_transform_setup` sources `librarian-config.sh` and calls `librarian_config_load "$PROJECT_REPO"` unconditionally (see Step 2) — not doing so is a real failure mode worth naming: `librarian_lesson_transform_one` calls `librarian_config_get`, which reads the `_LIBRARIAN_CONFIG` global, and without a load that global is `{}` and the model lookup returns empty. The call still succeeds (an empty model just omits `--model`), so skipping the load would leave the config path silently unexercised rather than failing loudly.
|
|
1294
|
+
|
|
1295
|
+
- [ ] **Step 6: Shellcheck**
|
|
1296
|
+
|
|
1297
|
+
Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-transform.sh`
|
|
1298
|
+
Expected: no output.
|
|
1299
|
+
|
|
1300
|
+
- [ ] **Step 7: Commit**
|
|
1301
|
+
|
|
1302
|
+
```bash
|
|
1303
|
+
git add plugins/librarian/scripts/lib/librarian-lesson-transform.sh \
|
|
1304
|
+
plugins/librarian/config.json test/bats/librarian-lesson-transform.bats
|
|
1305
|
+
git commit -m "feat(librarian): transform artifacts into lesson candidates, refusing what it cannot ground :microscope:"
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
---
|
|
1309
|
+
|
|
1310
|
+
### Task 5: Wire the stage into SessionEnd
|
|
1311
|
+
|
|
1312
|
+
**Files:**
|
|
1313
|
+
- Modify: `plugins/librarian/scripts/hooks/librarian-session-end.sh`
|
|
1314
|
+
- Test: `test/bats/librarian-lesson-transform.bats` (append)
|
|
1315
|
+
|
|
1316
|
+
**Interfaces:**
|
|
1317
|
+
- Consumes: `librarian_lesson_transform_one <key> <artifact_json>` from Task 4.
|
|
1318
|
+
- Produces: no new functions. The hook runs the stage over the same filtered artifact set the classifier sees.
|
|
1319
|
+
|
|
1320
|
+
- [ ] **Step 1: Write the failing end-to-end test**
|
|
1321
|
+
|
|
1322
|
+
```bash
|
|
1323
|
+
@test "SessionEnd runs the lesson stage and lands a candidate on disk" {
|
|
1324
|
+
_transform_setup
|
|
1325
|
+
|
|
1326
|
+
HOOK="${PLUGIN_ROOT}/scripts/hooks/librarian-session-end.sh"
|
|
1327
|
+
ARCHIVIST_DIR="${ONLOOKER_DIR}/archivist/${PROJECT_KEY}"
|
|
1328
|
+
mkdir -p "${ARCHIVIST_DIR}/decisions"
|
|
1329
|
+
|
|
1330
|
+
created_at=$(relative_iso_days_ago 1)
|
|
1331
|
+
jq -n --arg id "01KZ45MKAM734ZS7JK24D2DK0R" --arg at "$created_at" --arg k "$PROJECT_KEY" \
|
|
1332
|
+
'{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
|
|
1333
|
+
created_at: $at, updated_at: $at,
|
|
1334
|
+
summary: "Vitest 4.1.9 / Vite 5.x mismatch, decided to pin",
|
|
1335
|
+
detail: "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.",
|
|
1336
|
+
files: ["packages/db"]}' \
|
|
1337
|
+
> "${ARCHIVIST_DIR}/decisions/01KZ45MKAM734ZS7JK24D2DK0R.json"
|
|
1338
|
+
|
|
1339
|
+
input=$(jq -cn --arg cwd "$PROJECT_REPO" \
|
|
1340
|
+
'{cwd: $cwd, session_id: "sess-1", hook_event_name: "SessionEnd"}')
|
|
1341
|
+
run bash -c "printf '%s' '$input' | '$HOOK'"
|
|
1342
|
+
|
|
1343
|
+
[ "$status" -eq 0 ]
|
|
1344
|
+
[ -n "$(ls -A "${LESSONS_DIR}/proposals" 2>/dev/null)" ]
|
|
1345
|
+
}
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
1349
|
+
|
|
1350
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
1351
|
+
Expected: FAIL — the proposals directory is empty, because the hook does not run the stage yet.
|
|
1352
|
+
|
|
1353
|
+
- [ ] **Step 3: Source the new libs in the hook**
|
|
1354
|
+
|
|
1355
|
+
In `plugins/librarian/scripts/hooks/librarian-session-end.sh`, after the existing `librarian-conflict-detector.sh` source line (around line 61), add:
|
|
1356
|
+
|
|
1357
|
+
```bash
|
|
1358
|
+
# shellcheck source=../lib/librarian-lesson-validate.sh
|
|
1359
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
|
|
1360
|
+
# shellcheck source=../lib/librarian-lesson-storage.sh
|
|
1361
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
1362
|
+
# shellcheck source=../lib/librarian-lesson-transform.sh
|
|
1363
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-transform.sh"
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
- [ ] **Step 4: Run the stage over the filtered artifacts**
|
|
1367
|
+
|
|
1368
|
+
Iterate `$KEPT`, **not** `$FILTERED`. `librarian_durability_filter` returns an
|
|
1369
|
+
object `{kept: [...], dropped: [...]}` (hook line 151); `KEPT` is the array
|
|
1370
|
+
extracted from it at line 152, and `KEPT_COUNT` is already computed at line 208.
|
|
1371
|
+
Iterating `FILTERED` directly would yield the two arrays, not artifacts.
|
|
1372
|
+
|
|
1373
|
+
Match the classifier loop's idiom — a C-style index loop with `jq -c ".[$i]"` —
|
|
1374
|
+
rather than introducing a `while read` pattern the file does not use. Use
|
|
1375
|
+
distinct variable names so nothing from the classifier loop is clobbered.
|
|
1376
|
+
|
|
1377
|
+
Add after the classifier loop completes, before the watermark is written:
|
|
1378
|
+
|
|
1379
|
+
```bash
|
|
1380
|
+
# ---------------------------------------------------------------------------
|
|
1381
|
+
# Stage 5 — lesson transform.
|
|
1382
|
+
#
|
|
1383
|
+
# Runs over the same durability survivors the classifier saw. Each artifact is
|
|
1384
|
+
# independent: a decline or an outage on one never stops the rest.
|
|
1385
|
+
# ---------------------------------------------------------------------------
|
|
1386
|
+
LESSON_PROPOSED=0
|
|
1387
|
+
LESSON_DECLINED=0
|
|
1388
|
+
|
|
1389
|
+
for ((li = 0; li < KEPT_COUNT; li++)); do
|
|
1390
|
+
LESSON_ARTIFACT=$(printf '%s' "$KEPT" | jq -c ".[$li]")
|
|
1391
|
+
[[ -z "$LESSON_ARTIFACT" || "$LESSON_ARTIFACT" == "null" ]] && continue
|
|
1392
|
+
|
|
1393
|
+
LESSON_RESULT=$(librarian_lesson_transform_one "$PROJECT_KEY" "$LESSON_ARTIFACT")
|
|
1394
|
+
case "$LESSON_RESULT" in
|
|
1395
|
+
proposed:*) LESSON_PROPOSED=$((LESSON_PROPOSED + 1)) ;;
|
|
1396
|
+
declined:*) LESSON_DECLINED=$((LESSON_DECLINED + 1)) ;;
|
|
1397
|
+
esac
|
|
1398
|
+
done
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
- [ ] **Step 5: Run the tests**
|
|
1402
|
+
|
|
1403
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
1404
|
+
Expected: all PASS.
|
|
1405
|
+
|
|
1406
|
+
- [ ] **Step 6: Confirm nothing upstream regressed**
|
|
1407
|
+
|
|
1408
|
+
Run: `bats test/bats/librarian-session-end.bats && bats test/bats/librarian-session-start.bats && bats test/bats/librarian-cli.bats`
|
|
1409
|
+
Expected: all PASS. The existing suite must be unaffected — the new stage only appends.
|
|
1410
|
+
|
|
1411
|
+
- [ ] **Step 7: Full check**
|
|
1412
|
+
|
|
1413
|
+
Run: `npm run test:ci`
|
|
1414
|
+
Expected: exit 0.
|
|
1415
|
+
|
|
1416
|
+
- [ ] **Step 8: Commit**
|
|
1417
|
+
|
|
1418
|
+
```bash
|
|
1419
|
+
git add plugins/librarian/scripts/hooks/librarian-session-end.sh \
|
|
1420
|
+
test/bats/librarian-lesson-transform.bats
|
|
1421
|
+
git commit -m "feat(librarian): run the lesson stage at session end :link:"
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
---
|
|
1425
|
+
|
|
1426
|
+
### Task 6: Emit the stage's events
|
|
1427
|
+
|
|
1428
|
+
**Blocked on a cross-repo change.** `librarian.lesson.proposed` and `librarian.lesson.declined` must first be registered in `@onlooker-community/schema` (a separate published package) and the `devDependency` bumped here. Until then the emitter rejects both types in dev and CI and exits 1. Verify with:
|
|
1429
|
+
|
|
1430
|
+
```bash
|
|
1431
|
+
printf '%s' '{"plugin":"librarian","session_id":"probe","event_type":"librarian.lesson.proposed","payload":{}}' \
|
|
1432
|
+
| node scripts/lib/onlooker-event.mjs emit
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
Expected once unblocked: a JSON event on stdout rather than a `/event_type must be equal to one of the allowed values` error.
|
|
1436
|
+
|
|
1437
|
+
**Files:**
|
|
1438
|
+
- Modify: `plugins/librarian/scripts/hooks/librarian-session-end.sh`
|
|
1439
|
+
- Test: `test/bats/librarian-lesson-transform.bats` (append)
|
|
1440
|
+
|
|
1441
|
+
**Interfaces:**
|
|
1442
|
+
- Consumes: `librarian_emit <event_type> <session_id> <payload_json>` from `librarian-emit.sh`.
|
|
1443
|
+
- Produces: two event types on the bus.
|
|
1444
|
+
|
|
1445
|
+
- [ ] **Step 1: Write the failing test**
|
|
1446
|
+
|
|
1447
|
+
```bash
|
|
1448
|
+
@test "SessionEnd emits a schema-valid lesson.proposed event" {
|
|
1449
|
+
_transform_setup
|
|
1450
|
+
|
|
1451
|
+
HOOK="${PLUGIN_ROOT}/scripts/hooks/librarian-session-end.sh"
|
|
1452
|
+
ARCHIVIST_DIR="${ONLOOKER_DIR}/archivist/${PROJECT_KEY}"
|
|
1453
|
+
mkdir -p "${ARCHIVIST_DIR}/decisions"
|
|
1454
|
+
|
|
1455
|
+
created_at=$(relative_iso_days_ago 1)
|
|
1456
|
+
jq -n --arg id "01KZ45MKAM734ZS7JK24D2DK0R" --arg at "$created_at" --arg k "$PROJECT_KEY" \
|
|
1457
|
+
'{id: $id, kind: "decision", project_key: $k, session_id: "sess-1",
|
|
1458
|
+
created_at: $at, updated_at: $at,
|
|
1459
|
+
summary: "Vitest 4.1.9 / Vite 5.x mismatch, decided to pin",
|
|
1460
|
+
detail: "Vitest 4.1.9 imports vite/module-runner which is absent in Vite 5.4.21.",
|
|
1461
|
+
files: ["packages/db"]}' \
|
|
1462
|
+
> "${ARCHIVIST_DIR}/decisions/01KZ45MKAM734ZS7JK24D2DK0R.json"
|
|
1463
|
+
|
|
1464
|
+
input=$(jq -cn --arg cwd "$PROJECT_REPO" \
|
|
1465
|
+
'{cwd: $cwd, session_id: "sess-1", hook_event_name: "SessionEnd"}')
|
|
1466
|
+
run bash -c "printf '%s' '$input' | '$HOOK'"
|
|
1467
|
+
[ "$status" -eq 0 ]
|
|
1468
|
+
|
|
1469
|
+
grep '"event_type":"librarian.lesson.proposed"' "$ONLOOKER_EVENTS_LOG" \
|
|
1470
|
+
| jq -e '.payload.source_artifact_id == "01KZ45MKAM734ZS7JK24D2DK0R"' >/dev/null
|
|
1471
|
+
|
|
1472
|
+
grep '"event_type":"librarian.lesson.proposed"' "$ONLOOKER_EVENTS_LOG" | tail -n 1 \
|
|
1473
|
+
| ONLOOKER_DIR="$ONLOOKER_DIR" node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
|
|
1474
|
+
}
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
- [ ] **Step 2: Run it and watch it fail**
|
|
1478
|
+
|
|
1479
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
1480
|
+
Expected: FAIL — no matching line in the event log.
|
|
1481
|
+
|
|
1482
|
+
- [ ] **Step 3: Emit from the stage loop**
|
|
1483
|
+
|
|
1484
|
+
Extend the `case` added in Task 5:
|
|
1485
|
+
|
|
1486
|
+
```bash
|
|
1487
|
+
case "$LESSON_RESULT" in
|
|
1488
|
+
proposed:*)
|
|
1489
|
+
LESSON_PROPOSED=$((LESSON_PROPOSED + 1))
|
|
1490
|
+
librarian_emit "librarian.lesson.proposed" "$SESSION_ID" "$(jq -cn \
|
|
1491
|
+
--arg lesson_id "${LESSON_RESULT#proposed:}" \
|
|
1492
|
+
--arg src "$(printf '%s' "$LESSON_ARTIFACT" | jq -r '.id // ""')" \
|
|
1493
|
+
'{ lesson_id: $lesson_id, source_artifact_id: $src }')"
|
|
1494
|
+
;;
|
|
1495
|
+
declined:*)
|
|
1496
|
+
LESSON_DECLINED=$((LESSON_DECLINED + 1))
|
|
1497
|
+
librarian_emit "librarian.lesson.declined" "$SESSION_ID" "$(jq -cn \
|
|
1498
|
+
--arg reason "${LESSON_RESULT#declined:}" \
|
|
1499
|
+
--arg src "$(printf '%s' "$LESSON_ARTIFACT" | jq -r '.id // ""')" \
|
|
1500
|
+
'{ reason: $reason, source_artifact_id: $src }')"
|
|
1501
|
+
;;
|
|
1502
|
+
esac
|
|
1503
|
+
```
|
|
1504
|
+
|
|
1505
|
+
`SESSION_ID` is already set at `librarian-session-end.sh:76` from the hook payload, defaulting to `"unknown"`, and is the same variable the existing `librarian_emit` calls use.
|
|
1506
|
+
|
|
1507
|
+
- [ ] **Step 4: Run the tests**
|
|
1508
|
+
|
|
1509
|
+
Run: `bats test/bats/librarian-lesson-transform.bats`
|
|
1510
|
+
Expected: all PASS.
|
|
1511
|
+
|
|
1512
|
+
- [ ] **Step 5: Full check**
|
|
1513
|
+
|
|
1514
|
+
Run: `npm run test:ci`
|
|
1515
|
+
Expected: exit 0.
|
|
1516
|
+
|
|
1517
|
+
- [ ] **Step 6: Commit**
|
|
1518
|
+
|
|
1519
|
+
```bash
|
|
1520
|
+
git add plugins/librarian/scripts/hooks/librarian-session-end.sh \
|
|
1521
|
+
test/bats/librarian-lesson-transform.bats
|
|
1522
|
+
git commit -m "feat(librarian): put lesson proposals and declines on the event bus :satellite:"
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
---
|
|
1526
|
+
|
|
1527
|
+
## Self-review notes
|
|
1528
|
+
|
|
1529
|
+
**Spec coverage.** Every section of the spec maps to a task: placement and storage → Tasks 3 and 5; the three steps → Tasks 2 and 4; validation and the vendored sub-schemas → Tasks 1 and 2; the failure taxonomy → Task 4's tests, with all eight rows covered; idempotency → Task 3; testing → tests inside each task; events → Task 6.
|
|
1530
|
+
|
|
1531
|
+
**Deliberately deferred.** The spec's *Boundary changes* section notes that `4z8.2` gains the `version_independent` human path. That is not in this plan — it belongs to that issue. `4z8.4` reusing `librarian_lesson_append_declined` is satisfied by Task 3 creating it here.
|
|
1532
|
+
|
|
1533
|
+
**Known gap.** The CI drift guard in Task 1 is weaker than the event-schema equivalent, because `schema.onlooker.dev` currently serves no lesson schema. It pins provenance and structure but cannot detect an upstream contract change. File a follow-up to upgrade it once lesson schemas are published.
|
|
1534
|
+
|
|
1535
|
+
**Two corrections to the spec, folded in here and back into the spec itself.** The spec claimed the CI drift guard could fetch from `schema.onlooker.dev`; it cannot, and the note above replaces that. The spec also listed four config keys, but `claude -p` accepts no sampling flags, so `temperature` and `max_output_tokens` would be dead config — Task 4 ships two keys instead.
|
|
1536
|
+
|
|
1537
|
+
**Adjacent bug found, not fixed here.** `librarian-classifier.sh` reads `.librarian.classifier.temperature` and `.max_output_tokens` and passes them to `librarian_classifier_call`, which ignores both. The config reads as though it tunes the classifier and does not. Out of scope for this plan — worth its own issue.
|