@onlooker-community/ecosystem 0.34.0 → 0.43.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/.claude/skills/writing-tests/SKILL.md +27 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.github/workflows/release.yml +14 -14
- package/.release-please-manifest.json +7 -7
- package/AGENTS.md +116 -0
- package/CHANGELOG.md +175 -0
- package/CLAUDE.md +5 -0
- package/docs/lesson-promotion-pipeline.md +6 -6
- package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
- package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
- package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
- package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
- package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
- package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
- package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
- package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
- package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
- package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
- package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
- package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
- package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
- package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
- package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
- package/package.json +2 -2
- package/plugins/assayer/.claude-plugin/plugin.json +1 -1
- package/plugins/assayer/CHANGELOG.md +7 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
- package/plugins/cartographer/CHANGELOG.md +54 -0
- package/plugins/cartographer/README.md +56 -1
- package/plugins/cartographer/config.json +7 -1
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
- package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
- package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
- package/plugins/cartographer/scripts/run-audit.sh +136 -53
- package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
- package/plugins/curator/.claude-plugin/plugin.json +1 -1
- package/plugins/curator/CHANGELOG.md +7 -0
- package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
- package/plugins/historian/.claude-plugin/plugin.json +1 -1
- package/plugins/historian/CHANGELOG.md +7 -0
- package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
- package/plugins/librarian/.claude-plugin/plugin.json +1 -1
- package/plugins/librarian/CHANGELOG.md +71 -0
- package/plugins/librarian/config.json +30 -1
- package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
- package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
- package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
- package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
- package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
- package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
- package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
- package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
- package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
- package/plugins/librarian/skills/librarian/SKILL.md +98 -1
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +63 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
- package/plugins/tribunal/config.json +1 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
- package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
- package/scripts/lib/prompt-rules.sh +6 -1
- package/test/bats/archivist-inject.bats +1 -1
- package/test/bats/assayer-extract.bats +2 -2
- package/test/bats/bursar-session-start.bats +3 -3
- package/test/bats/cartographer-config.bats +43 -0
- package/test/bats/cartographer-events.bats +267 -0
- package/test/bats/cartographer-filter.bats +169 -0
- package/test/bats/cartographer-lock.bats +3 -3
- package/test/bats/cartographer-omission.bats +202 -0
- package/test/bats/cartographer-resolve.bats +287 -0
- package/test/bats/cartographer-run-audit.bats +325 -0
- package/test/bats/compass-sanitizer.bats +11 -11
- package/test/bats/compass-transcript.bats +2 -2
- package/test/bats/config.bats +15 -15
- package/test/bats/curator-session-start.bats +10 -3
- package/test/bats/emit-payload-default.bats +52 -0
- package/test/bats/governor-ledger.bats +1 -1
- package/test/bats/historian-prompt-submit.bats +1 -1
- package/test/bats/inspector-post-write-hook.bats +4 -4
- package/test/bats/librarian-author-key.bats +477 -0
- package/test/bats/librarian-cli.bats +16 -16
- package/test/bats/librarian-lesson-judge.bats +884 -0
- package/test/bats/librarian-lesson-promote.bats +552 -0
- package/test/bats/librarian-lesson-review.bats +900 -0
- package/test/bats/librarian-lesson-transform.bats +17 -3
- package/test/bats/librarian-session-end.bats +161 -0
- package/test/bats/librarian-session-start.bats +36 -2
- package/test/bats/lineage-config.bats +1 -1
- package/test/bats/lineage-redact.bats +5 -5
- package/test/bats/session-tracker.bats +4 -4
- package/test/bats/tribunal-aggregate.bats +354 -1
- package/test/bats/tribunal-gate.bats +631 -0
- package/test/bats/tribunal-judge-agents.bats +119 -0
- package/test/bats/tribunal-jury.bats +1 -1
- package/test/bats/turn-tracker.bats +1 -1
- package/test/bats/warden-sanitizer.bats +3 -3
- package/test/bats/worktree-tracker.bats +2 -2
- package/test/node/lesson-validate-agreement.test.mjs +35 -6
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
# Lesson Confirmation 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:** Let a human pick which lesson candidates go to the jury, and their visibility, before any expensive tokens are spent.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Four bash units with clean seams — a second validator that permits the `version_independent` branch a human may assert, a state-transition lib over the proposal files stage 5 already writes, a nested `librarian_cli lessons <verb>` dispatch, and the two user-facing surfaces (SessionStart counter, `/librarian lessons` skill route). No model calls anywhere in this stage; no event emission, because the types are unregistered.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** bash, `jq`, `bats`, `node:test` (agreement test only).
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md`
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- Bash only. `jq` for JSON. No Python in shipped code.
|
|
16
|
+
- Always `${ONLOOKER_DIR:-$HOME/.onlooker}` — never a literal `~/.onlooker`.
|
|
17
|
+
- ULIDs via `librarian_ulid`, never UUIDs.
|
|
18
|
+
- Repo shell style is **TAB-indented**. `shellcheck -S error -x` must be clean.
|
|
19
|
+
- **Never inline braces in a parameter-expansion default.** `${N:-{\}}` and `${N:-{}}` are both wrong. Default in a separate statement: `local p="${3:-}"` then `[ -z "$p" ] && p='{}'`. Guarded by `test/bats/emit-payload-default.bats`.
|
|
20
|
+
- bats runs under macOS bash 3.2, where a failing **non-final** `[[ ]]` does not fail the test. Use `[ ]`, or append `|| return 1`. Break each new assertion once to confirm it discriminates.
|
|
21
|
+
- **This stage must never invoke a model.** No `claude`, no network.
|
|
22
|
+
- Visibility values are exactly `private`, `org`, `public`.
|
|
23
|
+
- `version_independent` requires visibility `org` or `public`.
|
|
24
|
+
- American English. Commit style `<type>(<scope>): <subject> :emoji:` with a why-focused body.
|
|
25
|
+
|
|
26
|
+
## File Structure
|
|
27
|
+
|
|
28
|
+
| File | Responsibility |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-validate.sh` | Modified: extract the shared structural clause; add `librarian_lesson_validate_confirmed` |
|
|
31
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-review.sh` | Create: state transitions (`confirm` / `pass`), `passed.jsonl` append, pending listing |
|
|
32
|
+
| `plugins/librarian/scripts/lib/librarian-cli.sh` | Modified: nested `lessons` dispatch + six verbs |
|
|
33
|
+
| `plugins/librarian/scripts/hooks/librarian-session-start.sh` | Modified: second one-line count |
|
|
34
|
+
| `plugins/librarian/skills/librarian/SKILL.md` | Modified: route `/librarian lessons` |
|
|
35
|
+
| `test/bats/librarian-lesson-review.bats` | Create: all bats coverage for this stage |
|
|
36
|
+
| `test/node/lesson-validate-agreement.test.mjs` | Modified: cover the confirmed validator |
|
|
37
|
+
|
|
38
|
+
**Why the validator is refactored rather than copied.** `librarian_lesson_validate_candidate` is ~50 lines of `jq` mirroring the vendored sub-schemas. Copying it to add one branch is how the two mechanisms drifted apart the first time — that drift shipped, and cost a fix round to find. Extracting the shared clause means a schema change is fixed in one place. The refactor is protected by `test/node/lesson-validate-agreement.test.mjs`, which drives the real bash function against ajv.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
### Task 1: The confirmed-candidate validator
|
|
43
|
+
|
|
44
|
+
**Files:**
|
|
45
|
+
- Modify: `plugins/librarian/scripts/lib/librarian-lesson-validate.sh`
|
|
46
|
+
- Modify: `test/node/lesson-validate-agreement.test.mjs`
|
|
47
|
+
- Test: `test/bats/librarian-lesson-review.bats` (create)
|
|
48
|
+
|
|
49
|
+
**Interfaces:**
|
|
50
|
+
- Consumes: `librarian_lesson_valid_range <string>` (exists).
|
|
51
|
+
- Produces: `librarian_lesson_validate_confirmed <candidate_json>` → exit 0 valid, 1 invalid (reason `schema_invalid` on stderr). Accepts either scope branch; `version_independent` requires a non-empty `justification`.
|
|
52
|
+
|
|
53
|
+
- [ ] **Step 1: Write the failing tests**
|
|
54
|
+
|
|
55
|
+
Create `test/bats/librarian-lesson-review.bats`:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
#!/usr/bin/env bats
|
|
59
|
+
#
|
|
60
|
+
# Lesson confirmation: the human intent filter between the transform and the
|
|
61
|
+
# jury. Assertions use [ ] or `|| return 1` so every one gates under bash 3.2.
|
|
62
|
+
|
|
63
|
+
setup() {
|
|
64
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
65
|
+
setup_test_env
|
|
66
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
|
|
67
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
68
|
+
# shellcheck disable=SC1091
|
|
69
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_evidence() {
|
|
73
|
+
printf '%s' '{"artifact_ids":["01KZ45MKAM734ZS7JK24D2DK0R"],"session_ids":["s1"],"project_key":"6a7678979e31","observed_at":"2026-08-03T15:59:48Z","resolution":"Pin vitest to 3.x."}'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Usage: _candidate <scope_json>
|
|
77
|
+
_candidate() {
|
|
78
|
+
jq -cn --argjson ev "$(_evidence)" --argjson scope "$1" \
|
|
79
|
+
'{claim: "c", rationale: "r", evidence: $ev,
|
|
80
|
+
applies_to: {stack: ["vite"], scope: $scope, file_patterns: [], task_kinds: []}}'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_versioned() { printf '%s' '{"kind":"versioned","versions":{"vite":"<6"}}'; }
|
|
84
|
+
_indep() { printf '%s' '{"kind":"version_independent","justification":"git aborts checkout on a dirty tree regardless of version."}'; }
|
|
85
|
+
|
|
86
|
+
@test "confirmed validator accepts a versioned candidate" {
|
|
87
|
+
run librarian_lesson_validate_confirmed "$(_candidate "$(_versioned)")"
|
|
88
|
+
[ "$status" -eq 0 ]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@test "confirmed validator accepts version_independent with a justification" {
|
|
92
|
+
run librarian_lesson_validate_confirmed "$(_candidate "$(_indep)")"
|
|
93
|
+
[ "$status" -eq 0 ]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@test "confirmed validator rejects version_independent with an empty justification" {
|
|
97
|
+
run librarian_lesson_validate_confirmed \
|
|
98
|
+
"$(_candidate '{"kind":"version_independent","justification":""}')"
|
|
99
|
+
[ "$status" -eq 1 ]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@test "confirmed validator rejects version_independent with no justification key" {
|
|
103
|
+
run librarian_lesson_validate_confirmed \
|
|
104
|
+
"$(_candidate '{"kind":"version_independent"}')"
|
|
105
|
+
[ "$status" -eq 1 ]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@test "confirmed validator rejects an unknown scope kind" {
|
|
109
|
+
run librarian_lesson_validate_confirmed \
|
|
110
|
+
"$(_candidate '{"kind":"whenever","justification":"x"}')"
|
|
111
|
+
[ "$status" -eq 1 ]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@test "confirmed validator still enforces the range pattern on versioned scope" {
|
|
115
|
+
run librarian_lesson_validate_confirmed \
|
|
116
|
+
"$(_candidate '{"kind":"versioned","versions":{"vite":"^5.4.21"}}')"
|
|
117
|
+
[ "$status" -eq 1 ]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@test "confirmed validator still enforces provenance" {
|
|
121
|
+
candidate=$(_candidate "$(_versioned)" | jq -c '.evidence.session_ids = [""]')
|
|
122
|
+
run librarian_lesson_validate_confirmed "$candidate"
|
|
123
|
+
[ "$status" -eq 1 ]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@test "confirmed validator still enforces the versions-subset-of-stack rule" {
|
|
127
|
+
candidate=$(_candidate '{"kind":"versioned","versions":{"vite":"<6","vitest":">=4"}}')
|
|
128
|
+
run librarian_lesson_validate_confirmed "$candidate"
|
|
129
|
+
[ "$status" -eq 1 ]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@test "the transform validator still refuses version_independent" {
|
|
133
|
+
run librarian_lesson_validate_candidate "$(_candidate "$(_indep)")"
|
|
134
|
+
[ "$status" -eq 1 ]
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
139
|
+
|
|
140
|
+
Run: `bats test/bats/librarian-lesson-review.bats`
|
|
141
|
+
Expected: FAIL — `librarian_lesson_validate_confirmed: command not found`.
|
|
142
|
+
|
|
143
|
+
- [ ] **Step 3: Extract the shared structural clause**
|
|
144
|
+
|
|
145
|
+
In `librarian-lesson-validate.sh`, above `librarian_lesson_validate_candidate`, add:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Everything both validators check, excluding the scope branch. Kept in one
|
|
149
|
+
# place because it mirrors the vendored sub-schemas line for line; a second
|
|
150
|
+
# copy is how the jq rules and the schema drifted apart once already.
|
|
151
|
+
#
|
|
152
|
+
# Composed into a jq program by each validator, which appends its own scope
|
|
153
|
+
# clause.
|
|
154
|
+
#
|
|
155
|
+
# The mirrors below are NOT decorative, and both reasons were learned the
|
|
156
|
+
# hard way — do not delete them as redundant with the schema:
|
|
157
|
+
#
|
|
158
|
+
# The `keys - [...]` checks mirror `additionalProperties: false` and the
|
|
159
|
+
# `all(type == "string" and length > 0)` checks mirror the array items'
|
|
160
|
+
# `minLength: 1`. Without them, a model that "helpfully" adds an extra
|
|
161
|
+
# field produces a proposal that passes here but fails ajv against the
|
|
162
|
+
# contract it claims to satisfy — and lessons are shared with other
|
|
163
|
+
# people, so that lands on someone else's machine.
|
|
164
|
+
#
|
|
165
|
+
# The ULID, RFC3339 and non-empty-string patterns on evidence exist
|
|
166
|
+
# because a provenance-less artifact (session_id/created_at stitched in
|
|
167
|
+
# as "") must fail HERE. If it passes, the proposal is written and
|
|
168
|
+
# librarian_lesson_seen marks the artifact handled forever — buried
|
|
169
|
+
# permanently, which is the failure the whole taxonomy exists to prevent.
|
|
170
|
+
_LIBRARIAN_LESSON_STRUCTURAL='
|
|
171
|
+
(.claim | type) == "string" and (.claim | length) > 0
|
|
172
|
+
and (.rationale | type) == "string" and (.rationale | length) > 0
|
|
173
|
+
and (.evidence | type) == "object"
|
|
174
|
+
and ((.evidence | keys) - ["artifact_ids", "session_ids", "project_key", "observed_at", "resolution"] | length) == 0
|
|
175
|
+
and (.evidence.artifact_ids | type) == "array" and (.evidence.artifact_ids | length) > 0
|
|
176
|
+
and (.evidence.artifact_ids | all(type == "string" and test("^[0-9A-HJKMNP-TV-Z]{26}$")))
|
|
177
|
+
and (.evidence.session_ids | type) == "array" and (.evidence.session_ids | length) > 0
|
|
178
|
+
and (.evidence.session_ids | all(type == "string" and length > 0))
|
|
179
|
+
and (.evidence.project_key | type) == "string"
|
|
180
|
+
and (.evidence.project_key | test("^[0-9a-f]{12}$"))
|
|
181
|
+
and (.evidence.observed_at | type) == "string"
|
|
182
|
+
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))$"))
|
|
183
|
+
and (.evidence.resolution | type) == "string" and (.evidence.resolution | length) > 0
|
|
184
|
+
and (.applies_to | type) == "object"
|
|
185
|
+
and ((.applies_to | keys) - ["stack", "scope", "file_patterns", "task_kinds"] | length) == 0
|
|
186
|
+
and (.applies_to.stack | type) == "array" and (.applies_to.stack | length) > 0
|
|
187
|
+
and (.applies_to.stack | all(type == "string" and length > 0))
|
|
188
|
+
and (.applies_to.file_patterns | type) == "array"
|
|
189
|
+
and (.applies_to.file_patterns | all(type == "string" and length > 0))
|
|
190
|
+
and (.applies_to.task_kinds | type) == "array"
|
|
191
|
+
and (.applies_to.task_kinds | all(type == "string" and length > 0))
|
|
192
|
+
'
|
|
193
|
+
|
|
194
|
+
# The versioned branch, shared by both validators.
|
|
195
|
+
_LIBRARIAN_LESSON_SCOPE_VERSIONED='
|
|
196
|
+
.applies_to.scope.kind == "versioned"
|
|
197
|
+
and ((.applies_to.scope | keys) - ["kind", "versions"] | length) == 0
|
|
198
|
+
and (.applies_to.scope.versions | type) == "object"
|
|
199
|
+
and (.applies_to.scope.versions | length) > 0
|
|
200
|
+
'
|
|
201
|
+
|
|
202
|
+
# Checks that only apply to a versioned candidate: the cross-field rule JSON
|
|
203
|
+
# Schema cannot express, and the range pattern on each value.
|
|
204
|
+
#
|
|
205
|
+
# NUL-delimited, not newline-delimited: a range value with an embedded newline
|
|
206
|
+
# would otherwise split into two lines that can each pass individually even
|
|
207
|
+
# though the single value they came from is not a valid range. Do not skip
|
|
208
|
+
# empty reads either — jq never emits one for a non-empty object of strings,
|
|
209
|
+
# so an empty read means the range itself is empty, which is invalid.
|
|
210
|
+
_librarian_lesson_check_versions() {
|
|
211
|
+
local candidate="$1"
|
|
212
|
+
|
|
213
|
+
printf '%s' "$candidate" | jq -e '
|
|
214
|
+
(.applies_to.scope.versions | keys) - .applies_to.stack | length == 0
|
|
215
|
+
' >/dev/null 2>&1 || return 1
|
|
216
|
+
|
|
217
|
+
local range
|
|
218
|
+
while IFS= read -r -d '' range; do
|
|
219
|
+
librarian_lesson_valid_range "$range" || return 1
|
|
220
|
+
done < <(printf '%s' "$candidate" | jq --raw-output0 '.applies_to.scope.versions[]' 2>/dev/null)
|
|
221
|
+
|
|
222
|
+
return 0
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- [ ] **Step 4: Rewrite the transform validator to use them**
|
|
227
|
+
|
|
228
|
+
Replace the body of `librarian_lesson_validate_candidate` with:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
librarian_lesson_validate_candidate() {
|
|
232
|
+
local candidate="${1:-}"
|
|
233
|
+
[[ -z "$candidate" ]] && { printf 'schema_invalid\n' >&2; return 1; }
|
|
234
|
+
|
|
235
|
+
# versioned ONLY. This is the guarantee that stops the transform minting
|
|
236
|
+
# lessons that never expire: private lessons run no jury, so nothing
|
|
237
|
+
# downstream would catch a bad version_independent claim. A human may
|
|
238
|
+
# assert that branch — see librarian_lesson_validate_confirmed — because
|
|
239
|
+
# the constraint in the review path forces it to a judged visibility.
|
|
240
|
+
if ! printf '%s' "$candidate" | jq -e \
|
|
241
|
+
"${_LIBRARIAN_LESSON_STRUCTURAL} and ${_LIBRARIAN_LESSON_SCOPE_VERSIONED}" \
|
|
242
|
+
>/dev/null 2>&1; then
|
|
243
|
+
printf 'schema_invalid\n' >&2
|
|
244
|
+
return 1
|
|
245
|
+
fi
|
|
246
|
+
|
|
247
|
+
_librarian_lesson_check_versions "$candidate" || {
|
|
248
|
+
printf 'schema_invalid\n' >&2
|
|
249
|
+
return 1
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return 0
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- [ ] **Step 5: Add the confirmed validator**
|
|
257
|
+
|
|
258
|
+
Immediately after it:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Validate a candidate a human has confirmed.
|
|
262
|
+
#
|
|
263
|
+
# Identical to librarian_lesson_validate_candidate except that it also permits
|
|
264
|
+
# the version_independent branch, which requires a non-empty justification.
|
|
265
|
+
#
|
|
266
|
+
# The two are NOT redundant and the difference is not stylistic. They encode
|
|
267
|
+
# different trust: this one bounds what a human may assert AND a jury will then
|
|
268
|
+
# check, because the review path refuses version_independent at private
|
|
269
|
+
# visibility. The transform's validator bounds what a model may assert
|
|
270
|
+
# unsupervised, where nothing downstream would catch a bad claim. Deleting
|
|
271
|
+
# either collapses that distinction.
|
|
272
|
+
#
|
|
273
|
+
# Usage: librarian_lesson_validate_confirmed <candidate_json>
|
|
274
|
+
librarian_lesson_validate_confirmed() {
|
|
275
|
+
local candidate="${1:-}"
|
|
276
|
+
[[ -z "$candidate" ]] && { printf 'schema_invalid\n' >&2; return 1; }
|
|
277
|
+
|
|
278
|
+
local scope_clause='
|
|
279
|
+
(
|
|
280
|
+
('"${_LIBRARIAN_LESSON_SCOPE_VERSIONED}"')
|
|
281
|
+
or (
|
|
282
|
+
.applies_to.scope.kind == "version_independent"
|
|
283
|
+
and ((.applies_to.scope | keys) - ["kind", "justification"] | length) == 0
|
|
284
|
+
and (.applies_to.scope.justification | type) == "string"
|
|
285
|
+
and (.applies_to.scope.justification | length) > 0
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
'
|
|
289
|
+
|
|
290
|
+
if ! printf '%s' "$candidate" | jq -e \
|
|
291
|
+
"${_LIBRARIAN_LESSON_STRUCTURAL} and ${scope_clause}" \
|
|
292
|
+
>/dev/null 2>&1; then
|
|
293
|
+
printf 'schema_invalid\n' >&2
|
|
294
|
+
return 1
|
|
295
|
+
fi
|
|
296
|
+
|
|
297
|
+
# Range and subset rules apply only to the versioned branch.
|
|
298
|
+
if printf '%s' "$candidate" | jq -e '.applies_to.scope.kind == "versioned"' >/dev/null 2>&1; then
|
|
299
|
+
_librarian_lesson_check_versions "$candidate" || {
|
|
300
|
+
printf 'schema_invalid\n' >&2
|
|
301
|
+
return 1
|
|
302
|
+
}
|
|
303
|
+
fi
|
|
304
|
+
|
|
305
|
+
return 0
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
- [ ] **Step 6: Run the tests**
|
|
310
|
+
|
|
311
|
+
Run: `bats test/bats/librarian-lesson-review.bats`
|
|
312
|
+
Expected: all 9 PASS.
|
|
313
|
+
|
|
314
|
+
- [ ] **Step 7: Prove the refactor did not change the transform validator**
|
|
315
|
+
|
|
316
|
+
Run: `bats test/bats/librarian-lesson-transform.bats && node --test test/node/lesson-validate-agreement.test.mjs`
|
|
317
|
+
Expected: 47/47 bats PASS; agreement test PASS. The agreement test drives the real bash function against ajv, so it is the guard that the extraction preserved behavior.
|
|
318
|
+
|
|
319
|
+
- [ ] **Step 8: Extend the agreement test to the confirmed validator**
|
|
320
|
+
|
|
321
|
+
In `test/node/lesson-validate-agreement.test.mjs`, add cases asserting `librarian_lesson_validate_confirmed` agrees with ajv on a `version_independent` candidate. The vendored `lesson-applies-to.subschema.json` already carries that branch with `justification` `minLength: 1`, so ajv is the reference for both:
|
|
322
|
+
|
|
323
|
+
The file's `jqAccepts()` currently hardcodes `librarian_lesson_validate_candidate`. Parameterize it, keeping the existing call sites working:
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
function jqAccepts(candidate, fn = 'librarian_lesson_validate_candidate') {
|
|
327
|
+
const result = spawnSync(
|
|
328
|
+
'bash',
|
|
329
|
+
['-c', `source '${VALIDATE_LIB}' && ${fn} "$CANDIDATE_JSON"`],
|
|
330
|
+
{ env: { ...process.env, CANDIDATE_JSON: JSON.stringify(candidate) }, encoding: 'utf8' },
|
|
331
|
+
);
|
|
332
|
+
return result.status === 0;
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Then add, reusing the file's existing `baseCandidate()` and `schemaAccepts()`:
|
|
337
|
+
|
|
338
|
+
```javascript
|
|
339
|
+
describe('confirmed validator', () => {
|
|
340
|
+
it('agrees with the schema on a well-formed version_independent candidate', () => {
|
|
341
|
+
const candidate = baseCandidate();
|
|
342
|
+
candidate.applies_to.scope = {
|
|
343
|
+
kind: 'version_independent',
|
|
344
|
+
justification: 'git aborts checkout on a dirty tree regardless of version.',
|
|
345
|
+
};
|
|
346
|
+
assert.equal(jqAccepts(candidate, 'librarian_lesson_validate_confirmed'), true);
|
|
347
|
+
assert.equal(schemaAccepts(candidate), true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('agrees with the schema in rejecting an empty justification', () => {
|
|
351
|
+
const candidate = baseCandidate();
|
|
352
|
+
candidate.applies_to.scope = { kind: 'version_independent', justification: '' };
|
|
353
|
+
assert.equal(jqAccepts(candidate, 'librarian_lesson_validate_confirmed'), false);
|
|
354
|
+
assert.equal(schemaAccepts(candidate), false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('still refuses version_independent through the transform validator', () => {
|
|
358
|
+
const candidate = baseCandidate();
|
|
359
|
+
candidate.applies_to.scope = {
|
|
360
|
+
kind: 'version_independent',
|
|
361
|
+
justification: 'git aborts checkout on a dirty tree regardless of version.',
|
|
362
|
+
};
|
|
363
|
+
// The schema permits this branch; the transform's gate deliberately does not.
|
|
364
|
+
assert.equal(schemaAccepts(candidate), true);
|
|
365
|
+
assert.equal(jqAccepts(candidate), false);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
That third case is the one worth having: it pins the deliberate divergence, so nobody later "fixes" the transform validator into agreeing with the schema and silently reopens the never-expiring-lesson hole.
|
|
371
|
+
|
|
372
|
+
- [ ] **Step 9: Verify and commit**
|
|
373
|
+
|
|
374
|
+
Run: `node --test test/node/lesson-validate-agreement.test.mjs && shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-validate.sh && npm run lint:check`
|
|
375
|
+
Expected: all pass, shellcheck silent, lint exit 0.
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
git add plugins/librarian/scripts/lib/librarian-lesson-validate.sh \
|
|
379
|
+
test/bats/librarian-lesson-review.bats \
|
|
380
|
+
test/node/lesson-validate-agreement.test.mjs
|
|
381
|
+
git commit -m "feat(librarian): validate what a human may assert, separately :scales:"
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
### Task 2: State transitions and the passed ledger
|
|
387
|
+
|
|
388
|
+
**Files:**
|
|
389
|
+
- Create: `plugins/librarian/scripts/lib/librarian-lesson-review.sh`
|
|
390
|
+
- Test: `test/bats/librarian-lesson-review.bats` (append)
|
|
391
|
+
|
|
392
|
+
**Interfaces:**
|
|
393
|
+
- Consumes: `librarian_lessons_dir <key>`, `librarian_lesson_storage_init <key>` (from stage 5); `librarian_lesson_validate_confirmed` (Task 1).
|
|
394
|
+
- Produces:
|
|
395
|
+
- `librarian_lesson_list_pending <key>` → JSON array of pending proposals, `[]` when none
|
|
396
|
+
- `librarian_lesson_confirm <key> <lesson_id> <visibility> [justification]` → exit 0 on success; sets `status: "confirmed"` and `visibility`; with a justification, rewrites scope to `version_independent`
|
|
397
|
+
- `librarian_lesson_pass <key> <lesson_id> [reason]` → exit 0; sets `status: "passed"` and appends to `passed.jsonl`
|
|
398
|
+
- `librarian_lesson_passed_path <key>` → prints the ledger path
|
|
399
|
+
|
|
400
|
+
- [ ] **Step 1: Write the failing tests**
|
|
401
|
+
|
|
402
|
+
Append to `test/bats/librarian-lesson-review.bats`:
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
_review_setup() {
|
|
406
|
+
# shellcheck disable=SC1091
|
|
407
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
|
|
408
|
+
# shellcheck disable=SC1091
|
|
409
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-ulid.sh"
|
|
410
|
+
# shellcheck disable=SC1091
|
|
411
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
412
|
+
# shellcheck disable=SC1091
|
|
413
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
414
|
+
# shellcheck disable=SC1091
|
|
415
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
|
|
416
|
+
|
|
417
|
+
PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
|
|
418
|
+
mkdir -p "$PROJECT_REPO"
|
|
419
|
+
git -C "$PROJECT_REPO" init -q
|
|
420
|
+
git -C "$PROJECT_REPO" config user.email t@example.com
|
|
421
|
+
git -C "$PROJECT_REPO" config user.name "Test"
|
|
422
|
+
git -C "$PROJECT_REPO" remote add origin git@github.com:org/lesson-review.git
|
|
423
|
+
PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
|
|
424
|
+
[ -n "$PROJECT_KEY" ]
|
|
425
|
+
LESSONS_DIR="${ONLOOKER_DIR}/librarian/${PROJECT_KEY}/lessons"
|
|
426
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Seeds one pending proposal and prints its id.
|
|
430
|
+
_seed_pending() {
|
|
431
|
+
librarian_lesson_write_proposal "$PROJECT_KEY" \
|
|
432
|
+
"$(_candidate "$(_versioned)")" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@test "list_pending returns an empty array when the queue is empty" {
|
|
436
|
+
_review_setup
|
|
437
|
+
run librarian_lesson_list_pending "$PROJECT_KEY"
|
|
438
|
+
[ "$status" -eq 0 ]
|
|
439
|
+
[ "$output" = "[]" ]
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@test "list_pending returns a seeded pending proposal" {
|
|
443
|
+
_review_setup
|
|
444
|
+
id=$(_seed_pending)
|
|
445
|
+
run librarian_lesson_list_pending "$PROJECT_KEY"
|
|
446
|
+
[ "$status" -eq 0 ]
|
|
447
|
+
printf '%s' "$output" | jq -e --arg id "$id" 'length == 1 and .[0].id == $id' >/dev/null
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@test "confirm records status and visibility on the proposal" {
|
|
451
|
+
_review_setup
|
|
452
|
+
id=$(_seed_pending)
|
|
453
|
+
run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
|
|
454
|
+
[ "$status" -eq 0 ]
|
|
455
|
+
jq -e '.status == "confirmed" and .visibility == "org"' \
|
|
456
|
+
"${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
@test "confirm refuses an empty visibility and leaves the proposal pending" {
|
|
460
|
+
_review_setup
|
|
461
|
+
id=$(_seed_pending)
|
|
462
|
+
run librarian_lesson_confirm "$PROJECT_KEY" "$id" ""
|
|
463
|
+
[ "$status" -ne 0 ]
|
|
464
|
+
jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
@test "confirm refuses an unknown visibility" {
|
|
468
|
+
_review_setup
|
|
469
|
+
id=$(_seed_pending)
|
|
470
|
+
run librarian_lesson_confirm "$PROJECT_KEY" "$id" "everyone"
|
|
471
|
+
[ "$status" -ne 0 ]
|
|
472
|
+
jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
@test "confirm with a justification rewrites scope to version_independent" {
|
|
476
|
+
_review_setup
|
|
477
|
+
id=$(_seed_pending)
|
|
478
|
+
run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org" "git behavior is stable across versions."
|
|
479
|
+
[ "$status" -eq 0 ]
|
|
480
|
+
jq -e '.candidate.applies_to.scope.kind == "version_independent"
|
|
481
|
+
and (.candidate.applies_to.scope.justification | length) > 0' \
|
|
482
|
+
"${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
@test "confirm refuses version_independent at private visibility" {
|
|
486
|
+
_review_setup
|
|
487
|
+
id=$(_seed_pending)
|
|
488
|
+
run librarian_lesson_confirm "$PROJECT_KEY" "$id" "private" "stable across versions."
|
|
489
|
+
[ "$status" -ne 0 ]
|
|
490
|
+
jq -e '.status == "pending" and .candidate.applies_to.scope.kind == "versioned"' \
|
|
491
|
+
"${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
@test "confirm refuses a candidate that fails the confirmed validator" {
|
|
495
|
+
_review_setup
|
|
496
|
+
id=$(_seed_pending)
|
|
497
|
+
# Corrupt the stored candidate so validation must reject it.
|
|
498
|
+
tmp="${BATS_TEST_TMPDIR}/bad.json"
|
|
499
|
+
jq '.candidate.evidence.resolution = ""' "${LESSONS_DIR}/proposals/${id}.json" > "$tmp"
|
|
500
|
+
mv "$tmp" "${LESSONS_DIR}/proposals/${id}.json"
|
|
501
|
+
run librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
|
|
502
|
+
[ "$status" -ne 0 ]
|
|
503
|
+
jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
@test "pass marks the proposal and appends one ledger line" {
|
|
507
|
+
_review_setup
|
|
508
|
+
id=$(_seed_pending)
|
|
509
|
+
run librarian_lesson_pass "$PROJECT_KEY" "$id" "not worth sharing"
|
|
510
|
+
[ "$status" -eq 0 ]
|
|
511
|
+
jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
512
|
+
[ "$(wc -l < "${LESSONS_DIR}/passed.jsonl")" -eq 1 ]
|
|
513
|
+
tail -n 1 "${LESSONS_DIR}/passed.jsonl" \
|
|
514
|
+
| jq -e --arg id "$id" '.lesson_id == $id and .reason == "not worth sharing"' >/dev/null
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
@test "a passed proposal keeps its file so the artifact stays seen" {
|
|
518
|
+
_review_setup
|
|
519
|
+
id=$(_seed_pending)
|
|
520
|
+
librarian_lesson_pass "$PROJECT_KEY" "$id"
|
|
521
|
+
[ -f "${LESSONS_DIR}/proposals/${id}.json" ]
|
|
522
|
+
run librarian_lesson_seen "$PROJECT_KEY" "01KZ45MKAM734ZS7JK24D2DK0R"
|
|
523
|
+
[ "$status" -eq 0 ]
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
@test "this stage never writes to declined.jsonl" {
|
|
527
|
+
_review_setup
|
|
528
|
+
id=$(_seed_pending)
|
|
529
|
+
librarian_lesson_confirm "$PROJECT_KEY" "$id" "org"
|
|
530
|
+
id2=$(_seed_pending)
|
|
531
|
+
librarian_lesson_pass "$PROJECT_KEY" "$id2"
|
|
532
|
+
[ ! -f "${LESSONS_DIR}/declined.jsonl" ]
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
@test "confirming never invokes a model" {
|
|
536
|
+
_review_setup
|
|
537
|
+
# A claude on PATH that fails loudly if called at all.
|
|
538
|
+
stub="${BATS_TEST_TMPDIR}/bin"
|
|
539
|
+
mkdir -p "$stub"
|
|
540
|
+
printf '#!/usr/bin/env bash\necho "MODEL WAS INVOKED" >&2\nexit 42\n' > "${stub}/claude"
|
|
541
|
+
chmod +x "${stub}/claude"
|
|
542
|
+
PATH="${stub}:${PATH}"
|
|
543
|
+
|
|
544
|
+
id=$(_seed_pending)
|
|
545
|
+
run librarian_lesson_confirm "$PROJECT_KEY" "$id" "public"
|
|
546
|
+
[ "$status" -eq 0 ]
|
|
547
|
+
[[ "$output" != *"MODEL WAS INVOKED"* ]] || return 1
|
|
548
|
+
jq -e '.status == "confirmed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
@test "list_pending excludes confirmed and passed proposals" {
|
|
552
|
+
_review_setup
|
|
553
|
+
a=$(_seed_pending); b=$(_seed_pending); c=$(_seed_pending)
|
|
554
|
+
librarian_lesson_confirm "$PROJECT_KEY" "$a" "org"
|
|
555
|
+
librarian_lesson_pass "$PROJECT_KEY" "$b"
|
|
556
|
+
run librarian_lesson_list_pending "$PROJECT_KEY"
|
|
557
|
+
printf '%s' "$output" | jq -e --arg c "$c" 'length == 1 and .[0].id == $c' >/dev/null
|
|
558
|
+
}
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
562
|
+
|
|
563
|
+
Run: `bats test/bats/librarian-lesson-review.bats`
|
|
564
|
+
Expected: the new tests FAIL with `librarian_lesson_list_pending: command not found`. Task 1's nine still pass.
|
|
565
|
+
|
|
566
|
+
- [ ] **Step 3: Implement the library**
|
|
567
|
+
|
|
568
|
+
Create `plugins/librarian/scripts/lib/librarian-lesson-review.sh`:
|
|
569
|
+
|
|
570
|
+
```bash
|
|
571
|
+
#!/usr/bin/env bash
|
|
572
|
+
# Lesson confirmation — the human intent filter.
|
|
573
|
+
#
|
|
574
|
+
# The human picks which candidates go to the jury and their visibility, before
|
|
575
|
+
# any expensive tokens are spent. Intent is cheap and only a human can supply
|
|
576
|
+
# it; quality is expensive and only the jury can judge it. Splitting them here
|
|
577
|
+
# means cost scales with intent rather than artifact volume.
|
|
578
|
+
#
|
|
579
|
+
# NOTHING in this file may invoke a model. That is what makes "no tokens spent
|
|
580
|
+
# on unselected candidates" a property of the code rather than a promise.
|
|
581
|
+
#
|
|
582
|
+
# Requires librarian-lesson-storage.sh and librarian-lesson-validate.sh.
|
|
583
|
+
|
|
584
|
+
librarian_lesson_passed_path() {
|
|
585
|
+
local key="$1"
|
|
586
|
+
printf '%s/passed.jsonl' "$(librarian_lessons_dir "$key")"
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
# Print pending proposals as a JSON array, oldest first. Prints [] when none.
|
|
590
|
+
# Usage: librarian_lesson_list_pending <key>
|
|
591
|
+
librarian_lesson_list_pending() {
|
|
592
|
+
local key="$1"
|
|
593
|
+
[[ -z "$key" ]] && { printf '[]'; return 0; }
|
|
594
|
+
|
|
595
|
+
local dir
|
|
596
|
+
dir="$(librarian_lessons_dir "$key")/proposals"
|
|
597
|
+
[[ -d "$dir" ]] || { printf '[]'; return 0; }
|
|
598
|
+
|
|
599
|
+
local f out
|
|
600
|
+
out='[]'
|
|
601
|
+
for f in "$dir"/*.json; do
|
|
602
|
+
[[ -f "$f" ]] || continue
|
|
603
|
+
jq -e '.status == "pending"' "$f" >/dev/null 2>&1 || continue
|
|
604
|
+
# Skip a file we cannot accumulate rather than abandoning the listing.
|
|
605
|
+
# A concurrent confirm/pass can rewrite the file between the status
|
|
606
|
+
# check above and this read; discarding everything gathered so far
|
|
607
|
+
# would report an empty queue, which reads as "nothing to review"
|
|
608
|
+
# rather than as an error. Same failure class librarian_lesson_seen
|
|
609
|
+
# guards against in the sibling file.
|
|
610
|
+
#
|
|
611
|
+
# `out=$(cmd) || continue` does NOT work here: the assignment happens
|
|
612
|
+
# and clobbers $out to empty stdout BEFORE `||` is evaluated, so the
|
|
613
|
+
# accumulator is already wiped by the time `continue` runs. Merge into
|
|
614
|
+
# a temp and promote only on success.
|
|
615
|
+
local merged
|
|
616
|
+
if merged=$(jq -c --slurpfile p "$f" '. + $p' <<<"$out" 2>/dev/null); then
|
|
617
|
+
out="$merged"
|
|
618
|
+
fi
|
|
619
|
+
done
|
|
620
|
+
printf '%s' "$(jq -c 'sort_by(.created_at)' <<<"$out")"
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
_librarian_lesson_valid_visibility() {
|
|
624
|
+
case "${1:-}" in
|
|
625
|
+
private | org | public) return 0 ;;
|
|
626
|
+
*) return 1 ;;
|
|
627
|
+
esac
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
# Confirm a candidate for the jury.
|
|
631
|
+
#
|
|
632
|
+
# Usage: librarian_lesson_confirm <key> <lesson_id> <visibility> [justification]
|
|
633
|
+
#
|
|
634
|
+
# With a justification, the candidate's scope is rewritten to
|
|
635
|
+
# version_independent. That branch is refused at private visibility: private
|
|
636
|
+
# lessons run no jury, so the justification would reach the pool with nothing
|
|
637
|
+
# checking it — the same hole the transform closes by refusing the branch
|
|
638
|
+
# outright. Requiring org or public means scope_accuracy actually tests it.
|
|
639
|
+
librarian_lesson_confirm() {
|
|
640
|
+
local key="$1"
|
|
641
|
+
local lesson_id="$2"
|
|
642
|
+
local visibility="${3:-}"
|
|
643
|
+
local justification="${4:-}"
|
|
644
|
+
[[ -z "$key" || -z "$lesson_id" ]] && return 1
|
|
645
|
+
|
|
646
|
+
_librarian_lesson_valid_visibility "$visibility" || {
|
|
647
|
+
printf 'visibility must be one of: private, org, public\n' >&2
|
|
648
|
+
return 1
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if [[ -n "$justification" && "$visibility" == "private" ]]; then
|
|
652
|
+
printf 'version_independent requires org or public visibility: a private lesson runs no jury, so its justification would go unchecked and the lesson would never expire\n' >&2
|
|
653
|
+
return 1
|
|
654
|
+
fi
|
|
655
|
+
|
|
656
|
+
local path
|
|
657
|
+
path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
|
|
658
|
+
[[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
|
|
659
|
+
|
|
660
|
+
local proposal candidate current
|
|
661
|
+
proposal=$(jq '.' "$path" 2>/dev/null) || return 1
|
|
662
|
+
candidate=$(printf '%s' "$proposal" | jq -c '.candidate' 2>/dev/null) || return 1
|
|
663
|
+
current=$(printf '%s' "$proposal" | jq -r '.status // ""' 2>/dev/null)
|
|
664
|
+
|
|
665
|
+
# Guard the transition, not just the write. Each write is atomic on its
|
|
666
|
+
# own, but an unguarded SEQUENCE lets passed.jsonl end up contradicting
|
|
667
|
+
# the candidate it describes: pass then confirm would flip status back to
|
|
668
|
+
# confirmed while the ledger still asserts the human declined it. The
|
|
669
|
+
# ledger is the durable record of intent, so it must never disagree.
|
|
670
|
+
case "$current" in
|
|
671
|
+
pending) ;;
|
|
672
|
+
confirmed)
|
|
673
|
+
# Idempotent only when nothing about the decision changed.
|
|
674
|
+
local prev_vis prev_scope
|
|
675
|
+
prev_vis=$(printf '%s' "$proposal" | jq -r '.visibility // ""')
|
|
676
|
+
prev_scope=$(printf '%s' "$proposal" | jq -c '.candidate.applies_to.scope')
|
|
677
|
+
if [[ "$prev_vis" == "$visibility" && -z "$justification" ]] \
|
|
678
|
+
|| [[ "$prev_vis" == "$visibility" && "$prev_scope" == *'"version_independent"'* ]]; then
|
|
679
|
+
return 0
|
|
680
|
+
fi
|
|
681
|
+
printf 'Lesson %s is already confirmed at %s visibility; pass on it first to change that.\n' \
|
|
682
|
+
"$lesson_id" "$prev_vis" >&2
|
|
683
|
+
return 1
|
|
684
|
+
;;
|
|
685
|
+
passed)
|
|
686
|
+
printf 'Lesson %s was passed on; it cannot be confirmed without reopening it.\n' "$lesson_id" >&2
|
|
687
|
+
return 1
|
|
688
|
+
;;
|
|
689
|
+
*)
|
|
690
|
+
printf 'Lesson %s has an unrecognized status: %s\n' "$lesson_id" "$current" >&2
|
|
691
|
+
return 1
|
|
692
|
+
;;
|
|
693
|
+
esac
|
|
694
|
+
|
|
695
|
+
if [[ -n "$justification" ]]; then
|
|
696
|
+
candidate=$(printf '%s' "$candidate" | jq -c \
|
|
697
|
+
--arg j "$justification" \
|
|
698
|
+
'.applies_to.scope = {kind: "version_independent", justification: $j}' 2>/dev/null) || return 1
|
|
699
|
+
fi
|
|
700
|
+
|
|
701
|
+
librarian_lesson_validate_confirmed "$candidate" 2>/dev/null || {
|
|
702
|
+
printf 'Candidate does not validate; not confirmed.\n' >&2
|
|
703
|
+
return 1
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
local now updated
|
|
707
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
708
|
+
# Assign .candidate rather than folding it into the `*` merge. jq's `*` is
|
|
709
|
+
# RECURSIVE: `. * {candidate: $c}` merges the new candidate into the stored
|
|
710
|
+
# one instead of replacing it. When a confirm rewrites scope to
|
|
711
|
+
# version_independent, the fresh scope is {kind, justification} but merging
|
|
712
|
+
# it over the stored {kind, versions} leaves all three keys — a candidate
|
|
713
|
+
# that fails librarian_lesson_validate_confirmed, since that branch permits
|
|
714
|
+
# only kind and justification. It would then be handed to the jury stage,
|
|
715
|
+
# which reads exactly this field.
|
|
716
|
+
updated=$(printf '%s' "$proposal" | jq \
|
|
717
|
+
--arg v "$visibility" --arg t "$now" --argjson c "$candidate" \
|
|
718
|
+
'. * {status: "confirmed", visibility: $v, confirmed_at: $t} | .candidate = $c' 2>/dev/null) || return 1
|
|
719
|
+
[[ -z "$updated" || "$updated" == "null" ]] && return 1
|
|
720
|
+
printf '%s\n' "$updated" > "$path"
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
# Decline to share a candidate.
|
|
724
|
+
#
|
|
725
|
+
# The file is KEPT. librarian_lesson_seen scans proposals/ by artifact_id, so
|
|
726
|
+
# leaving it in place is what stops the artifact being re-proposed on the next
|
|
727
|
+
# scan and re-paying for a transform whose answer the human already gave.
|
|
728
|
+
#
|
|
729
|
+
# The ledger is separate from declined.jsonl on purpose: that file records
|
|
730
|
+
# machine verdicts and feeds rubric tuning, and folding human intent into it
|
|
731
|
+
# would corrupt the signal it exists to carry.
|
|
732
|
+
#
|
|
733
|
+
# Usage: librarian_lesson_pass <key> <lesson_id> [reason]
|
|
734
|
+
librarian_lesson_pass() {
|
|
735
|
+
local key="$1"
|
|
736
|
+
local lesson_id="$2"
|
|
737
|
+
local reason="${3:-}"
|
|
738
|
+
[[ -z "$key" || -z "$lesson_id" ]] && return 1
|
|
739
|
+
|
|
740
|
+
local path
|
|
741
|
+
path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
|
|
742
|
+
[[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
|
|
743
|
+
|
|
744
|
+
local current
|
|
745
|
+
current=$(jq -r '.status // ""' "$path" 2>/dev/null)
|
|
746
|
+
|
|
747
|
+
# Same reasoning as confirm: guard the transition. Passing twice must not
|
|
748
|
+
# append a second ledger line, and a confirmed candidate must not be
|
|
749
|
+
# silently un-confirmed — that would leave stale visibility/confirmed_at
|
|
750
|
+
# on the record and send a contradictory signal to the jury stage, which
|
|
751
|
+
# selects on status.
|
|
752
|
+
case "$current" in
|
|
753
|
+
pending) ;;
|
|
754
|
+
passed) return 0 ;;
|
|
755
|
+
confirmed)
|
|
756
|
+
printf 'Lesson %s is already confirmed for the jury; it cannot be passed on now.\n' "$lesson_id" >&2
|
|
757
|
+
return 1
|
|
758
|
+
;;
|
|
759
|
+
*)
|
|
760
|
+
printf 'Lesson %s has an unrecognized status: %s\n' "$lesson_id" "$current" >&2
|
|
761
|
+
return 1
|
|
762
|
+
;;
|
|
763
|
+
esac
|
|
764
|
+
|
|
765
|
+
local artifact_id now updated
|
|
766
|
+
artifact_id=$(jq -r '.artifact_id // ""' "$path" 2>/dev/null)
|
|
767
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
768
|
+
|
|
769
|
+
updated=$(jq --arg t "$now" '. * {status: "passed", passed_at: $t}' "$path" 2>/dev/null) || return 1
|
|
770
|
+
[[ -z "$updated" || "$updated" == "null" ]] && return 1
|
|
771
|
+
printf '%s\n' "$updated" > "$path"
|
|
772
|
+
|
|
773
|
+
local line
|
|
774
|
+
line=$(jq -cn \
|
|
775
|
+
--arg lesson_id "$lesson_id" \
|
|
776
|
+
--arg artifact_id "$artifact_id" \
|
|
777
|
+
--arg reason "$reason" \
|
|
778
|
+
--arg at "$now" \
|
|
779
|
+
'{lesson_id: $lesson_id, artifact_id: $artifact_id,
|
|
780
|
+
reason: (if $reason == "" then null else $reason end), passed_at: $at}') || return 1
|
|
781
|
+
|
|
782
|
+
printf '%s\n' "$line" >> "$(librarian_lesson_passed_path "$key")"
|
|
783
|
+
}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
- [ ] **Step 4: Run the tests**
|
|
787
|
+
|
|
788
|
+
Run: `bats test/bats/librarian-lesson-review.bats`
|
|
789
|
+
Expected: all PASS (9 from Task 1 + 13 new).
|
|
790
|
+
|
|
791
|
+
- [ ] **Step 5: Fault-inject the three guarantees**
|
|
792
|
+
|
|
793
|
+
Each of these is a guarantee, not a behavior, so confirm each test discriminates. For each: make the change, run the named test, confirm it FAILS, revert, confirm it passes.
|
|
794
|
+
|
|
795
|
+
1. Delete the `_librarian_lesson_valid_visibility` call → "confirm refuses an unknown visibility" must fail.
|
|
796
|
+
2. Delete the `private` + justification check → "confirm refuses version_independent at private visibility" must fail.
|
|
797
|
+
3. In `librarian_lesson_pass`, remove the `printf ... >> passed.jsonl` line → "pass marks the proposal and appends one ledger line" must fail.
|
|
798
|
+
|
|
799
|
+
Report the result of each in the task report.
|
|
800
|
+
|
|
801
|
+
- [ ] **Step 6: Verify and commit**
|
|
802
|
+
|
|
803
|
+
Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-review.sh && bats test/bats/librarian-lesson-transform.bats && npm run lint:check`
|
|
804
|
+
Expected: shellcheck silent, transform suite still green, lint exit 0.
|
|
805
|
+
|
|
806
|
+
```bash
|
|
807
|
+
git add plugins/librarian/scripts/lib/librarian-lesson-review.sh \
|
|
808
|
+
test/bats/librarian-lesson-review.bats
|
|
809
|
+
git commit -m "feat(librarian): let a human confirm lessons and record what they pass on :raised_hand:"
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
### Task 3: CLI verbs
|
|
815
|
+
|
|
816
|
+
**Files:**
|
|
817
|
+
- Modify: `plugins/librarian/scripts/lib/librarian-cli.sh`
|
|
818
|
+
- Test: `test/bats/librarian-lesson-review.bats` (append)
|
|
819
|
+
|
|
820
|
+
**Interfaces:**
|
|
821
|
+
- Consumes: `librarian_lesson_list_pending`, `librarian_lesson_confirm`, `librarian_lesson_pass` (Task 2); `_librarian_cli_project_key <cwd>` (exists).
|
|
822
|
+
- Produces: `librarian_cli lessons <list|show|confirm|pass|defer|status> [args]`.
|
|
823
|
+
|
|
824
|
+
- [ ] **Step 1: Write the failing tests**
|
|
825
|
+
|
|
826
|
+
Append:
|
|
827
|
+
|
|
828
|
+
```bash
|
|
829
|
+
_cli_setup() {
|
|
830
|
+
_review_setup
|
|
831
|
+
# shellcheck disable=SC1091
|
|
832
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
|
|
833
|
+
# shellcheck disable=SC1091
|
|
834
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
|
|
835
|
+
# shellcheck disable=SC1091
|
|
836
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
@test "lessons status reports zero on an empty queue" {
|
|
840
|
+
_cli_setup
|
|
841
|
+
run librarian_cli lessons status "$PROJECT_REPO"
|
|
842
|
+
[ "$status" -eq 0 ]
|
|
843
|
+
[[ "$output" == *"0"* ]] || return 1
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
@test "lessons list shows a pending lesson" {
|
|
847
|
+
_cli_setup
|
|
848
|
+
id=$(_seed_pending)
|
|
849
|
+
run librarian_cli lessons list "$PROJECT_REPO"
|
|
850
|
+
[ "$status" -eq 0 ]
|
|
851
|
+
[[ "$output" == *"$id"* ]] || return 1
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
@test "lessons show prints the claim" {
|
|
855
|
+
_cli_setup
|
|
856
|
+
id=$(_seed_pending)
|
|
857
|
+
run librarian_cli lessons show "$id" "$PROJECT_REPO"
|
|
858
|
+
[ "$status" -eq 0 ]
|
|
859
|
+
[[ "$output" == *"c"* ]] || return 1
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
@test "lessons confirm requires a visibility argument" {
|
|
863
|
+
_cli_setup
|
|
864
|
+
id=$(_seed_pending)
|
|
865
|
+
run librarian_cli lessons confirm "$id"
|
|
866
|
+
[ "$status" -ne 0 ]
|
|
867
|
+
jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
@test "lessons confirm sets status and visibility" {
|
|
871
|
+
_cli_setup
|
|
872
|
+
id=$(_seed_pending)
|
|
873
|
+
run librarian_cli lessons confirm "$id" public "" "$PROJECT_REPO"
|
|
874
|
+
[ "$status" -eq 0 ]
|
|
875
|
+
jq -e '.status == "confirmed" and .visibility == "public"' \
|
|
876
|
+
"${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
@test "lessons pass marks it passed" {
|
|
880
|
+
_cli_setup
|
|
881
|
+
id=$(_seed_pending)
|
|
882
|
+
run librarian_cli lessons pass "$id" "" "$PROJECT_REPO"
|
|
883
|
+
[ "$status" -eq 0 ]
|
|
884
|
+
jq -e '.status == "passed"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
@test "lessons defer leaves it pending" {
|
|
888
|
+
_cli_setup
|
|
889
|
+
id=$(_seed_pending)
|
|
890
|
+
run librarian_cli lessons defer "$id" "$PROJECT_REPO"
|
|
891
|
+
[ "$status" -eq 0 ]
|
|
892
|
+
jq -e '.status == "pending"' "${LESSONS_DIR}/proposals/${id}.json" >/dev/null
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
@test "an unknown lessons verb is rejected" {
|
|
896
|
+
_cli_setup
|
|
897
|
+
run librarian_cli lessons frobnicate
|
|
898
|
+
[ "$status" -ne 0 ]
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
@test "memory verbs are unaffected by the lessons namespace" {
|
|
902
|
+
_cli_setup
|
|
903
|
+
run librarian_cli status "$PROJECT_REPO"
|
|
904
|
+
[ "$status" -eq 0 ]
|
|
905
|
+
}
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
- [ ] **Step 2: Run and watch them fail**
|
|
909
|
+
|
|
910
|
+
Run: `bats test/bats/librarian-lesson-review.bats`
|
|
911
|
+
Expected: new tests FAIL — `librarian_cli` reports `unknown action: lessons`.
|
|
912
|
+
|
|
913
|
+
- [ ] **Step 3: Source the review lib from the CLI**
|
|
914
|
+
|
|
915
|
+
At the top of `librarian-cli.sh`, alongside its existing sources, add:
|
|
916
|
+
|
|
917
|
+
```bash
|
|
918
|
+
# shellcheck source=./librarian-lesson-storage.sh
|
|
919
|
+
source "${BASH_SOURCE[0]%/*}/librarian-lesson-storage.sh"
|
|
920
|
+
# shellcheck source=./librarian-lesson-validate.sh
|
|
921
|
+
source "${BASH_SOURCE[0]%/*}/librarian-lesson-validate.sh"
|
|
922
|
+
# shellcheck source=./librarian-lesson-review.sh
|
|
923
|
+
source "${BASH_SOURCE[0]%/*}/librarian-lesson-review.sh"
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
If the file already sources siblings by a different idiom, match that idiom instead — read the top of the file first.
|
|
927
|
+
|
|
928
|
+
- [ ] **Step 4: Add the verbs and nested dispatch**
|
|
929
|
+
|
|
930
|
+
Add before `librarian_cli()`:
|
|
931
|
+
|
|
932
|
+
```bash
|
|
933
|
+
# ----------------------------------------------------------------------------
|
|
934
|
+
# Lesson confirmation surface
|
|
935
|
+
#
|
|
936
|
+
# Namespaced under `lessons` and kept apart from the memory verbs on purpose.
|
|
937
|
+
# Accepting a memory writes a file on this machine; confirming a lesson commits
|
|
938
|
+
# it toward leaving this machine, irreversibly once synced. Those two decisions
|
|
939
|
+
# should not sit one keystroke apart.
|
|
940
|
+
# ----------------------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
librarian_cli_lessons_list() {
|
|
943
|
+
local cwd="${1:-}"
|
|
944
|
+
local key pending
|
|
945
|
+
key=$(_librarian_cli_project_key "$cwd")
|
|
946
|
+
[[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
|
|
947
|
+
pending=$(librarian_lesson_list_pending "$key")
|
|
948
|
+
|
|
949
|
+
if [[ "$(printf '%s' "$pending" | jq 'length')" -eq 0 ]]; then
|
|
950
|
+
printf 'No pending lessons.\n'
|
|
951
|
+
return 0
|
|
952
|
+
fi
|
|
953
|
+
printf '%s' "$pending" | jq -r '.[] | "\(.id) \(.candidate.claim)"'
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
librarian_cli_lessons_show() {
|
|
957
|
+
local lesson_id="${1:-}"
|
|
958
|
+
local cwd="${2:-}"
|
|
959
|
+
[[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons show <lesson_id>\n'; return 1; }
|
|
960
|
+
|
|
961
|
+
local key path
|
|
962
|
+
key=$(_librarian_cli_project_key "$cwd")
|
|
963
|
+
[[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
|
|
964
|
+
path="$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json"
|
|
965
|
+
[[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id"; return 1; }
|
|
966
|
+
|
|
967
|
+
jq -r '
|
|
968
|
+
"id: \(.id)",
|
|
969
|
+
"status: \(.status)",
|
|
970
|
+
"artifact: \(.artifact_id)",
|
|
971
|
+
"claim: \(.candidate.claim)",
|
|
972
|
+
"rationale: \(.candidate.rationale)",
|
|
973
|
+
"resolution: \(.candidate.evidence.resolution)",
|
|
974
|
+
"stack: \(.candidate.applies_to.stack | join(", "))",
|
|
975
|
+
"scope: \(.candidate.applies_to.scope | tojson)"
|
|
976
|
+
' "$path"
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
librarian_cli_lessons_confirm() {
|
|
980
|
+
local lesson_id="${1:-}"
|
|
981
|
+
local visibility="${2:-}"
|
|
982
|
+
shift 2 2>/dev/null || true
|
|
983
|
+
|
|
984
|
+
# Justification is a NAMED flag, not a positional. With positionals, a
|
|
985
|
+
# caller supplying cwd while omitting the justification binds the path
|
|
986
|
+
# into it — and librarian_lesson_confirm treats any non-empty
|
|
987
|
+
# justification as "rewrite scope to version_independent", so a working
|
|
988
|
+
# directory would silently flip the scope on the one verb that commits a
|
|
989
|
+
# lesson toward leaving this machine. Position cannot misparse a flag.
|
|
990
|
+
local justification="" cwd=""
|
|
991
|
+
while [ $# -gt 0 ]; do
|
|
992
|
+
case "$1" in
|
|
993
|
+
--justification)
|
|
994
|
+
justification="${2:-}"
|
|
995
|
+
shift 2 2>/dev/null || shift
|
|
996
|
+
;;
|
|
997
|
+
--*)
|
|
998
|
+
# Reject an unrecognized flag rather than absorbing it as a
|
|
999
|
+
# cwd candidate. A typo like --justifcation would otherwise be
|
|
1000
|
+
# swallowed, the lesson would confirm with an empty
|
|
1001
|
+
# justification and exit 0 with no diagnostic, and the retry
|
|
1002
|
+
# with the correct spelling would then be refused by the
|
|
1003
|
+
# status guard — leaving a wrong state the CLI has no verb to
|
|
1004
|
+
# undo. Nothing legitimate here begins with --.
|
|
1005
|
+
printf 'unknown option: %s\n' "$1" >&2
|
|
1006
|
+
return 1
|
|
1007
|
+
;;
|
|
1008
|
+
*)
|
|
1009
|
+
cwd="$1"
|
|
1010
|
+
shift
|
|
1011
|
+
;;
|
|
1012
|
+
esac
|
|
1013
|
+
done
|
|
1014
|
+
|
|
1015
|
+
# A justification that is only whitespace is not a justification. Refuse
|
|
1016
|
+
# it here rather than in the validator: the vendored schema's minLength
|
|
1017
|
+
# of 1 counts whitespace, our validator mirrors that faithfully, and the
|
|
1018
|
+
# agreement test exists to keep the two aligned. Guard the input path.
|
|
1019
|
+
case "$justification" in
|
|
1020
|
+
*[![:space:]]*) ;;
|
|
1021
|
+
"") ;;
|
|
1022
|
+
*)
|
|
1023
|
+
printf 'A justification cannot be only whitespace.\n' >&2
|
|
1024
|
+
return 1
|
|
1025
|
+
;;
|
|
1026
|
+
esac
|
|
1027
|
+
|
|
1028
|
+
[[ -z "$lesson_id" || -z "$visibility" ]] && {
|
|
1029
|
+
printf 'usage: librarian_cli lessons confirm <lesson_id> <private|org|public> [--justification TEXT] [cwd]\n'
|
|
1030
|
+
return 1
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
local key
|
|
1034
|
+
key=$(_librarian_cli_project_key "$cwd")
|
|
1035
|
+
[[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
|
|
1036
|
+
|
|
1037
|
+
librarian_lesson_confirm "$key" "$lesson_id" "$visibility" "$justification" || return 1
|
|
1038
|
+
printf 'Confirmed %s at %s visibility.\n' "$lesson_id" "$visibility"
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
librarian_cli_lessons_pass() {
|
|
1042
|
+
local lesson_id="${1:-}"
|
|
1043
|
+
local reason="${2:-}"
|
|
1044
|
+
local cwd="${3:-}"
|
|
1045
|
+
[[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons pass <lesson_id> [reason]\n'; return 1; }
|
|
1046
|
+
|
|
1047
|
+
local key
|
|
1048
|
+
key=$(_librarian_cli_project_key "$cwd")
|
|
1049
|
+
[[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
|
|
1050
|
+
|
|
1051
|
+
librarian_lesson_pass "$key" "$lesson_id" "$reason" || return 1
|
|
1052
|
+
printf 'Passed on %s.\n' "$lesson_id"
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
librarian_cli_lessons_defer() {
|
|
1056
|
+
local lesson_id="${1:-}"
|
|
1057
|
+
[[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons defer <lesson_id>\n'; return 1; }
|
|
1058
|
+
printf 'Deferred %s; it stays in the queue.\n' "$lesson_id"
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
librarian_cli_lessons_status() {
|
|
1062
|
+
local cwd="${1:-}"
|
|
1063
|
+
local key pending
|
|
1064
|
+
key=$(_librarian_cli_project_key "$cwd")
|
|
1065
|
+
[[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
|
|
1066
|
+
pending=$(librarian_lesson_list_pending "$key")
|
|
1067
|
+
printf 'lessons pending: %s\n' "$(printf '%s' "$pending" | jq 'length')"
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
librarian_cli_lessons() {
|
|
1071
|
+
local verb="${1:-list}"
|
|
1072
|
+
shift || true
|
|
1073
|
+
case "$verb" in
|
|
1074
|
+
list) librarian_cli_lessons_list "$@" ;;
|
|
1075
|
+
show) librarian_cli_lessons_show "$@" ;;
|
|
1076
|
+
confirm) librarian_cli_lessons_confirm "$@" ;;
|
|
1077
|
+
pass) librarian_cli_lessons_pass "$@" ;;
|
|
1078
|
+
defer) librarian_cli_lessons_defer "$@" ;;
|
|
1079
|
+
status) librarian_cli_lessons_status "$@" ;;
|
|
1080
|
+
*) printf 'unknown lessons action: %s\n' "$verb"; return 2 ;;
|
|
1081
|
+
esac
|
|
1082
|
+
}
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
Then add one line to the existing `librarian_cli` dispatch, before the `*)` catch-all:
|
|
1086
|
+
|
|
1087
|
+
```bash
|
|
1088
|
+
lessons) librarian_cli_lessons "$@" ;;
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
- [ ] **Step 5: Run the tests**
|
|
1092
|
+
|
|
1093
|
+
Run: `bats test/bats/librarian-lesson-review.bats`
|
|
1094
|
+
Expected: all PASS.
|
|
1095
|
+
|
|
1096
|
+
- [ ] **Step 6: Verify and commit**
|
|
1097
|
+
|
|
1098
|
+
Run: `shellcheck -S error -x plugins/librarian/scripts/lib/librarian-cli.sh && bats test/bats/librarian-cli.bats && npm run lint:check`
|
|
1099
|
+
Expected: shellcheck silent, the existing CLI suite still green, lint exit 0.
|
|
1100
|
+
|
|
1101
|
+
```bash
|
|
1102
|
+
git add plugins/librarian/scripts/lib/librarian-cli.sh test/bats/librarian-lesson-review.bats
|
|
1103
|
+
git commit -m "feat(librarian): namespace the lesson verbs away from memory accepts :card_index_dividers:"
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
---
|
|
1107
|
+
|
|
1108
|
+
### Task 4: The two user-facing surfaces
|
|
1109
|
+
|
|
1110
|
+
**Files:**
|
|
1111
|
+
- Modify: `plugins/librarian/scripts/hooks/librarian-session-start.sh`
|
|
1112
|
+
- Modify: `plugins/librarian/skills/librarian/SKILL.md`
|
|
1113
|
+
- Test: `test/bats/librarian-session-start.bats` (append)
|
|
1114
|
+
|
|
1115
|
+
**Interfaces:**
|
|
1116
|
+
- Consumes: `librarian_lesson_list_pending <key>` (Task 2), `librarian_cli lessons <verb>` (Task 3).
|
|
1117
|
+
- Produces: no new functions.
|
|
1118
|
+
|
|
1119
|
+
- [ ] **Step 1: Write the failing test**
|
|
1120
|
+
|
|
1121
|
+
Read `test/bats/librarian-session-start.bats` first and reuse its existing setup helper rather than writing a new one. Append:
|
|
1122
|
+
|
|
1123
|
+
```bash
|
|
1124
|
+
@test "session-start surfaces a pending lesson count as its own line" {
|
|
1125
|
+
# Reuse this file's existing project/hook setup, then seed one pending
|
|
1126
|
+
# lesson through the storage lib.
|
|
1127
|
+
# shellcheck disable=SC1091
|
|
1128
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
1129
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
1130
|
+
librarian_lesson_write_proposal "$PROJECT_KEY" \
|
|
1131
|
+
"$(jq -cn '{claim: "c", rationale: "r"}')" "01KZ45MKAM734ZS7JK24D2DK0R" >/dev/null
|
|
1132
|
+
|
|
1133
|
+
run bash -c "printf '%s' '$(_input)' | '$HOOK'"
|
|
1134
|
+
[ "$status" -eq 0 ]
|
|
1135
|
+
[[ "$output" == *"lesson"* ]] || return 1
|
|
1136
|
+
}
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
- [ ] **Step 2: Run and watch it fail**
|
|
1140
|
+
|
|
1141
|
+
Run: `bats test/bats/librarian-session-start.bats`
|
|
1142
|
+
Expected: FAIL — no "lesson" in the injected context.
|
|
1143
|
+
|
|
1144
|
+
- [ ] **Step 3: Add the count to the surfacer**
|
|
1145
|
+
|
|
1146
|
+
In `librarian-session-start.sh`, alongside the existing pending-proposal count, source the lesson storage and review libs and add a second line to `additionalContext`. Keep it a pointer, not bodies — the file's own header explains why: session-start context is precious, and the queue belongs in the review skill.
|
|
1147
|
+
|
|
1148
|
+
```bash
|
|
1149
|
+
# shellcheck source=../lib/librarian-lesson-storage.sh
|
|
1150
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
1151
|
+
# shellcheck source=../lib/librarian-lesson-validate.sh
|
|
1152
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-validate.sh"
|
|
1153
|
+
# shellcheck source=../lib/librarian-lesson-review.sh
|
|
1154
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
**There is a trap in the existing control flow — read this before editing.** The hook computes `PENDING` (memory proposals) and then early-exits:
|
|
1158
|
+
|
|
1159
|
+
```bash
|
|
1160
|
+
if [[ "$PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
|
|
1161
|
+
_emit ""
|
|
1162
|
+
exit 0
|
|
1163
|
+
fi
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
Adding the lesson line *after* that branch means lessons are never surfaced when there are no memory proposals — which is the common case, since the two queues fill independently. Compute the lesson count **before** the branch and include it in the condition:
|
|
1167
|
+
|
|
1168
|
+
```bash
|
|
1169
|
+
LESSON_PENDING=$(librarian_lesson_list_pending "$PROJECT_KEY" | jq 'length' 2>/dev/null) || LESSON_PENDING=0
|
|
1170
|
+
[[ -z "$LESSON_PENDING" || "$LESSON_PENDING" == "null" ]] && LESSON_PENDING=0
|
|
1171
|
+
|
|
1172
|
+
if [[ "$PENDING" -eq 0 && "$LESSON_PENDING" -eq 0 && "$SKIP_WHEN_ZERO" == "true" ]]; then
|
|
1173
|
+
_emit ""
|
|
1174
|
+
exit 0
|
|
1175
|
+
fi
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
Then build the lesson line and append it to whatever context string the hook passes to `_emit`, separated by a newline:
|
|
1179
|
+
|
|
1180
|
+
```bash
|
|
1181
|
+
LESSON_LINE=""
|
|
1182
|
+
if [[ "$LESSON_PENDING" -gt 0 ]]; then
|
|
1183
|
+
LESSON_LINE=$(printf '%s lesson candidate(s) awaiting confirmation — run /librarian lessons' "$LESSON_PENDING")
|
|
1184
|
+
fi
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
At the `_emit` call, join the two lines, skipping either when empty so a single-queue session gets one clean line rather than a stray blank:
|
|
1188
|
+
|
|
1189
|
+
```bash
|
|
1190
|
+
CONTEXT="$MEMORY_LINE"
|
|
1191
|
+
if [[ -n "$LESSON_LINE" ]]; then
|
|
1192
|
+
if [[ -n "$CONTEXT" ]]; then
|
|
1193
|
+
CONTEXT="${CONTEXT}"$'\n'"${LESSON_LINE}"
|
|
1194
|
+
else
|
|
1195
|
+
CONTEXT="$LESSON_LINE"
|
|
1196
|
+
fi
|
|
1197
|
+
fi
|
|
1198
|
+
_emit "$CONTEXT"
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
`MEMORY_LINE` is whatever variable the hook currently passes to `_emit` — read the end of the file and use its real name rather than renaming it.
|
|
1202
|
+
|
|
1203
|
+
Add a second test covering the trap, since it is the failure a reviewer would most likely miss:
|
|
1204
|
+
|
|
1205
|
+
```bash
|
|
1206
|
+
@test "session-start surfaces lessons even with zero memory proposals" {
|
|
1207
|
+
# shellcheck disable=SC1091
|
|
1208
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
1209
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
1210
|
+
librarian_lesson_write_proposal "$PROJECT_KEY" \
|
|
1211
|
+
"$(jq -cn '{claim: "c", rationale: "r"}')" "01KZ45MKAM734ZS7JK24D2DK0R" >/dev/null
|
|
1212
|
+
|
|
1213
|
+
run bash -c "printf '%s' '$(_input)' | '$HOOK'"
|
|
1214
|
+
[ "$status" -eq 0 ]
|
|
1215
|
+
[[ "$output" == *"lesson"* ]] || return 1
|
|
1216
|
+
}
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
- [ ] **Step 4: Run the tests**
|
|
1220
|
+
|
|
1221
|
+
Run: `bats test/bats/librarian-session-start.bats`
|
|
1222
|
+
Expected: all PASS, including the pre-existing ones.
|
|
1223
|
+
|
|
1224
|
+
- [ ] **Step 5: Route the skill**
|
|
1225
|
+
|
|
1226
|
+
**First, fix the sourcing gap — the verbs do not work in production without it.** `librarian-cli.sh` deliberately sources nothing itself; its callers source its dependencies. The skill currently sources five libs (`librarian-config.sh`, `librarian-project-key.sh`, `librarian-storage.sh`, `librarian-emit.sh`, `librarian-cli.sh`) and none of the three lesson libs, so `librarian_cli lessons list` fails with `librarian_lesson_list_pending: command not found`. Verified by sourcing exactly what the skill lists and calling the verb.
|
|
1227
|
+
|
|
1228
|
+
Add these three to the skill's source block, before `librarian-cli.sh`:
|
|
1229
|
+
|
|
1230
|
+
```bash
|
|
1231
|
+
source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-storage.sh"
|
|
1232
|
+
source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-validate.sh"
|
|
1233
|
+
source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-review.sh"
|
|
1234
|
+
```
|
|
1235
|
+
|
|
1236
|
+
Order matters: `librarian-lesson-review.sh` calls into both of the others, and `librarian-lesson-storage.sh` needs `librarian-storage.sh` (already sourced above it).
|
|
1237
|
+
|
|
1238
|
+
The bats suites pass without this because their setup sources every lib directly — which is exactly why this gap is invisible to per-task tests and has to be closed here.
|
|
1239
|
+
|
|
1240
|
+
Then, in `plugins/librarian/skills/librarian/SKILL.md`, extend the "Parse the request" section with the `lessons` route, and add a section describing the walk. Keep the existing memory routes untouched.
|
|
1241
|
+
|
|
1242
|
+
```markdown
|
|
1243
|
+
- `lessons`, `lessons review` → **walk the lesson queue** (see below)
|
|
1244
|
+
- `lessons list` / `lessons status` → print and stop
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
Add a section documenting: the walk shows `claim`, `rationale`, `evidence.resolution`, `applies_to.stack`, `scope.versions`, and the source artifact id; the three outcomes are `confirm <id> <visibility>`, `pass <id> [reason]`, `defer <id>`; confirming **requires** a visibility; and `version_independent` requires `org` or `public` because a private lesson runs no jury, so its justification would go unchecked.
|
|
1248
|
+
|
|
1249
|
+
Also update the skill's frontmatter `description` so `/librarian lessons` is discoverable.
|
|
1250
|
+
|
|
1251
|
+
- [ ] **Step 6: Verify and commit**
|
|
1252
|
+
|
|
1253
|
+
Run: `npm run test:ci`
|
|
1254
|
+
Expected: exit 0.
|
|
1255
|
+
|
|
1256
|
+
```bash
|
|
1257
|
+
git add plugins/librarian/scripts/hooks/librarian-session-start.sh \
|
|
1258
|
+
plugins/librarian/skills/librarian/SKILL.md \
|
|
1259
|
+
test/bats/librarian-session-start.bats
|
|
1260
|
+
git commit -m "feat(librarian): surface pending lessons and route the review skill :bellhop_bell:"
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
---
|
|
1264
|
+
|
|
1265
|
+
## Self-review notes
|
|
1266
|
+
|
|
1267
|
+
**Spec coverage.** Surface and verbs → Task 3; state model and both ledgers → Task 2; the three constraints → Task 2 (enforced) and Tasks 2–3 (tested); two validators → Task 1; surfacing → Task 4; events → deliberately absent, see below; testing → inside each task; boundary to `4z8.3` → the `status: "confirmed"` + `visibility` fields written in Task 2.
|
|
1268
|
+
|
|
1269
|
+
**Deliberately absent.** No event emission. `librarian.lesson.*` is unregistered in `@onlooker-community/schema` 2.11.0 — verified — and with a validator present the emitter exits 1 on an unknown `event_type`. `4z8.3` reads the proposal files, not the bus, so nothing is blocked by waiting. One follow-up should wire all four types (`proposed`, `declined`, `confirmed`, `passed`) once the package publishes them.
|
|
1270
|
+
|
|
1271
|
+
**Refactor risk, and what covers it.** Task 1 restructures a validator that shipped in `librarian-v0.7.1` and cost a fix round to harden. The guard is `test/node/lesson-validate-agreement.test.mjs`, which drives the real bash function against ajv over a corpus — Step 7 runs it before the new behavior is added, specifically to prove the extraction changed nothing.
|
|
1272
|
+
|
|
1273
|
+
**Known gap.** `librarian_cli_lessons_defer` is a no-op that prints. It exists so the walk has three symmetric outcomes and the user can say "not now" without the skill inventing behavior. If a deferral ever needs to suppress re-surfacing for N sessions, that is a new field, not a change to this verb.
|