@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,747 @@
|
|
|
1
|
+
# Approved Pool and Declined Ledger 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:** Turn a judged proposal into its terminal record — a `ZLesson`-shaped pool entry awaiting sync, or a row in the declined ledger.
|
|
6
|
+
|
|
7
|
+
**Architecture:** One new lib function plus a small extension to an existing one. The terminal record lands first, then a `promoted_at` stamp on the proposal makes the operation detectably done. The `lessons judge` CLI calls it automatically; it is also runnable standalone to reconcile.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** bash (macOS bash 3.2 compatible), `jq`, bats.
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- **Write order is load-bearing.** The terminal record (pool entry or declined row) lands **before** `promoted_at` is stamped. Reversed, a stamp followed by a failed write leaves the lesson marked done, present nowhere, and invisible to a reconcile that keys on the stamp's absence.
|
|
14
|
+
- **Both writes are atomic** — temp file then `mv`. `ecosystem-a3b` is open against three existing `printf > path` sites in this plugin; do not add more. Do not fix the existing three either — that is a3b's branch.
|
|
15
|
+
- **`ZLesson` is a `z.strictObject`.** The pool entry's key set must be exactly: `applies_to author_key claim consensus evidence id promoted_at rationale schema_version source status superseded_by visibility`. An extra key fails ingest as surely as a missing one.
|
|
16
|
+
- **`source` is not `visibility`.** `private` → **`local`**, `org` → `org`, `public` → `public`. Emitting `private` fails ingest.
|
|
17
|
+
- A `private` entry has `consensus.judges: 0` and is deliberately **not** ingest-valid. Do not synthesize a jury to make it validate.
|
|
18
|
+
- Failure returns non-zero, writes **nothing**, and leaves the proposal `approved` without `promoted_at`, with a reason on stderr.
|
|
19
|
+
- Already promoted is a **no-op success**, not an error.
|
|
20
|
+
- Runtime artifacts under `$ONLOOKER_DIR`; never a hardcoded `~/.onlooker`.
|
|
21
|
+
- No event emission. `@onlooker-community/schema` 2.11.0 registers no `librarian.lesson.*` and the emitter exits 1 on an unknown type.
|
|
22
|
+
- Bash 3.2: no associative arrays, no `${var^^}`, no `mapfile`.
|
|
23
|
+
- **bats runs under macOS system bash 3.2, where a failing NON-FINAL `[[ ]]` does NOT fail the test.** Every non-final `[[ ]]` needs `|| return 1`; single-bracket `[ ]` gates on its own.
|
|
24
|
+
- **A test asserting "nothing on stdout" alongside a required stderr reason MUST use `run --separate-stderr`** — plain `run` merges them, and the assertion becomes unsatisfiable by any correct implementation. This defect has already cost two fix rounds on prior branches.
|
|
25
|
+
- Assert on messages, not just exit codes, wherever a test asserts a refusal.
|
|
26
|
+
- American English. Commit via the `/commit` contract: `<type>(<scope>): <subject> :emoji:`, subject ≤72 chars including the emoji, why-focused body.
|
|
27
|
+
|
|
28
|
+
## File Structure
|
|
29
|
+
|
|
30
|
+
| File | Responsibility |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-storage.sh` | **Modify.** Extend `librarian_lesson_append_declined` with an optional `verdict`. |
|
|
33
|
+
| `plugins/librarian/scripts/lib/librarian-lesson-promote.sh` | **Create.** `librarian_lesson_promote`. |
|
|
34
|
+
| `plugins/librarian/scripts/lib/librarian-cli.sh` | **Modify** (Task 2). Add `lessons promote`; call promote from `lessons judge`. |
|
|
35
|
+
| `plugins/librarian/skills/librarian/SKILL.md` | **Modify** (Task 2). Source the new lib; describe promotion in the judge walk. |
|
|
36
|
+
| `test/bats/librarian-lesson-promote.bats` | **Create.** |
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
### Task 1: Promote a judged proposal
|
|
41
|
+
|
|
42
|
+
**Files:**
|
|
43
|
+
- Modify: `plugins/librarian/scripts/lib/librarian-lesson-storage.sh`
|
|
44
|
+
- Create: `plugins/librarian/scripts/lib/librarian-lesson-promote.sh`
|
|
45
|
+
- Test: `test/bats/librarian-lesson-promote.bats`
|
|
46
|
+
|
|
47
|
+
**Interfaces:**
|
|
48
|
+
- Consumes: `librarian_lessons_dir <key>`, `librarian_lesson_storage_init <key>` (storage); `librarian_author_key <visibility>` (author-key lib).
|
|
49
|
+
- Produces:
|
|
50
|
+
- `librarian_lesson_append_declined <key> <artifact_id> <reason> [detail] [verdict_json]` — gains an optional 5th argument.
|
|
51
|
+
- `librarian_lesson_promote <key> <lesson_id>` → 0 on success (including the already-promoted no-op), 1 on refusal or failure.
|
|
52
|
+
|
|
53
|
+
- [ ] **Step 1: Write the failing tests**
|
|
54
|
+
|
|
55
|
+
Create `test/bats/librarian-lesson-promote.bats`:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
#!/usr/bin/env bats
|
|
59
|
+
|
|
60
|
+
setup() {
|
|
61
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
62
|
+
setup_test_env
|
|
63
|
+
|
|
64
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
|
|
65
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
66
|
+
|
|
67
|
+
PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
|
|
68
|
+
mkdir -p "$PROJECT_REPO"
|
|
69
|
+
git -C "$PROJECT_REPO" init -q
|
|
70
|
+
git -C "$PROJECT_REPO" config user.email t@example.com
|
|
71
|
+
git -C "$PROJECT_REPO" config user.name "Test"
|
|
72
|
+
git -C "$PROJECT_REPO" remote add origin git@github.com:org/fixture.git
|
|
73
|
+
|
|
74
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-config.sh"
|
|
75
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
|
|
76
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-storage.sh"
|
|
77
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-storage.sh"
|
|
78
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
|
|
79
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-promote.sh"
|
|
80
|
+
librarian_config_load "$PROJECT_REPO"
|
|
81
|
+
|
|
82
|
+
PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
|
|
83
|
+
librarian_lesson_storage_init "$PROJECT_KEY"
|
|
84
|
+
|
|
85
|
+
# Promotion must spend nothing. Any invocation of this stub is a failure.
|
|
86
|
+
STUB_BIN="${BATS_TEST_TMPDIR}/bin"
|
|
87
|
+
mkdir -p "$STUB_BIN"
|
|
88
|
+
cat > "${STUB_BIN}/claude" <<'STUB'
|
|
89
|
+
#!/usr/bin/env bash
|
|
90
|
+
echo "claude was invoked but promotion must spend no tokens" >&2
|
|
91
|
+
exit 99
|
|
92
|
+
STUB
|
|
93
|
+
chmod +x "${STUB_BIN}/claude"
|
|
94
|
+
export PATH="${STUB_BIN}:${PATH}"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_dir() { printf '%s' "$(librarian_lessons_dir "$PROJECT_KEY")"; }
|
|
98
|
+
|
|
99
|
+
# A judged proposal. $1 = id, $2 = visibility, $3 = status,
|
|
100
|
+
# $4 = verdict judges JSON array.
|
|
101
|
+
_seed_judged() {
|
|
102
|
+
local id="$1" visibility="$2" status="$3" judges="$4"
|
|
103
|
+
local now
|
|
104
|
+
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
105
|
+
jq -n --arg id "$id" --arg v "$visibility" --arg s "$status" \
|
|
106
|
+
--arg t "$now" --argjson j "$judges" \
|
|
107
|
+
'{ id: $id, artifact_id: "art-\($id)", status: $s, visibility: $v,
|
|
108
|
+
confirmed_at: $t, judged_at: $t,
|
|
109
|
+
candidate: { claim: "Prefer jq -c for compact output",
|
|
110
|
+
rationale: "Readable diffs",
|
|
111
|
+
evidence: { resolution: "Applied and verified" },
|
|
112
|
+
applies_to: { stack: ["bash"],
|
|
113
|
+
scope: { kind: "versioned", versions: ">=3.2" } } },
|
|
114
|
+
verdict: { rubric_id: "lesson-promotion", gate_policy: "majority",
|
|
115
|
+
score_threshold: 0.75, aggregate_score: 0.85,
|
|
116
|
+
passed: ($s == "approved"), reason: "gate_passed",
|
|
117
|
+
judges: $j } }' \
|
|
118
|
+
> "$(_dir)/proposals/${id}.json"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_two_passing() {
|
|
122
|
+
printf '%s' '[{"judge_type":"standard","score":0.9,"passed":true},{"judge_type":"adversarial","score":0.8,"passed":true}]'
|
|
123
|
+
}
|
|
124
|
+
_split() {
|
|
125
|
+
printf '%s' '[{"judge_type":"standard","score":0.95,"passed":true},{"judge_type":"adversarial","score":0.75,"passed":false}]'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@test "an approved org lesson becomes a pool entry with exactly ZLesson's keys" {
|
|
129
|
+
# ZLesson is a z.strictObject: an extra key fails ingest as surely as a
|
|
130
|
+
# missing one, so the key SET is the assertion, not a spot-check.
|
|
131
|
+
_seed_judged "org01" "org" "approved" "$(_two_passing)"
|
|
132
|
+
run librarian_lesson_promote "$PROJECT_KEY" "org01"
|
|
133
|
+
[ "$status" -eq 0 ]
|
|
134
|
+
|
|
135
|
+
local keys
|
|
136
|
+
keys=$(jq -r 'keys_unsorted | sort | join(" ")' "$(_dir)/approved/org01.json")
|
|
137
|
+
[ "$keys" = "applies_to author_key claim consensus evidence id promoted_at rationale schema_version source status superseded_by visibility" ]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@test "the pool entry carries the mapped source and a derived consensus" {
|
|
141
|
+
_seed_judged "org02" "org" "approved" "$(_two_passing)"
|
|
142
|
+
librarian_lesson_promote "$PROJECT_KEY" "org02"
|
|
143
|
+
|
|
144
|
+
local e
|
|
145
|
+
e=$(cat "$(_dir)/approved/org02.json")
|
|
146
|
+
[ "$(printf '%s' "$e" | jq -r '.source')" = "org" ]
|
|
147
|
+
[ "$(printf '%s' "$e" | jq -r '.visibility')" = "org" ]
|
|
148
|
+
[ "$(printf '%s' "$e" | jq -r '.schema_version')" = "2" ]
|
|
149
|
+
[ "$(printf '%s' "$e" | jq -r '.status')" = "active" ]
|
|
150
|
+
[ "$(printf '%s' "$e" | jq -r '.superseded_by')" = "null" ]
|
|
151
|
+
[ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "2" ]
|
|
152
|
+
[ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "2" ]
|
|
153
|
+
[ "$(printf '%s' "$e" | jq -r '.author_key')" != "null" ]
|
|
154
|
+
printf '%s' "$e" | jq -e '.author_key | test("^[0-9a-f]{32}$")' >/dev/null || return 1
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@test "a private lesson maps to source local with zero judges" {
|
|
158
|
+
# Deliberately NOT ingest-valid: ZConsensus requires judges >= 1. A
|
|
159
|
+
# private lesson never syncs, so it never reaches the validator. Do not
|
|
160
|
+
# "fix" this by synthesizing a jury that never sat.
|
|
161
|
+
_seed_judged "priv01" "private" "approved" '[]'
|
|
162
|
+
run librarian_lesson_promote "$PROJECT_KEY" "priv01"
|
|
163
|
+
[ "$status" -eq 0 ]
|
|
164
|
+
|
|
165
|
+
local e
|
|
166
|
+
e=$(cat "$(_dir)/approved/priv01.json")
|
|
167
|
+
[ "$(printf '%s' "$e" | jq -r '.source')" = "local" ]
|
|
168
|
+
[ "$(printf '%s' "$e" | jq -r '.visibility')" = "private" ]
|
|
169
|
+
[ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "0" ]
|
|
170
|
+
[ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "0" ]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@test "a public lesson maps to source public" {
|
|
174
|
+
_seed_judged "pub01" "public" "approved" "$(_two_passing)"
|
|
175
|
+
librarian_lesson_promote "$PROJECT_KEY" "pub01"
|
|
176
|
+
[ "$(jq -r '.source' "$(_dir)/approved/pub01.json")" = "public" ]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@test "agreed never exceeds judges" {
|
|
180
|
+
# The contract's own ingest rule, which its schema deliberately cannot
|
|
181
|
+
# express (it would need .refine(), which z.toJSONSchema drops).
|
|
182
|
+
_seed_judged "cnt01" "org" "approved" "$(_split)"
|
|
183
|
+
librarian_lesson_promote "$PROJECT_KEY" "cnt01"
|
|
184
|
+
|
|
185
|
+
local e
|
|
186
|
+
e=$(cat "$(_dir)/approved/cnt01.json")
|
|
187
|
+
[ "$(printf '%s' "$e" | jq -r '.consensus.judges')" = "2" ]
|
|
188
|
+
[ "$(printf '%s' "$e" | jq -r '.consensus.agreed')" = "1" ]
|
|
189
|
+
printf '%s' "$e" | jq -e '.consensus.agreed <= .consensus.judges' >/dev/null || return 1
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@test "a rejected lesson writes a declined row with a NESTED verdict and no pool entry" {
|
|
193
|
+
# .verdict must be an object, not a serialized string. A --arg/--argjson
|
|
194
|
+
# mistake produces a row that looks right and is unusable to a consumer,
|
|
195
|
+
# so assert by indexing into it rather than matching a substring.
|
|
196
|
+
_seed_judged "rej01" "public" "rejected" "$(_split)"
|
|
197
|
+
run librarian_lesson_promote "$PROJECT_KEY" "rej01"
|
|
198
|
+
[ "$status" -eq 0 ]
|
|
199
|
+
|
|
200
|
+
[ ! -f "$(_dir)/approved/rej01.json" ]
|
|
201
|
+
local row
|
|
202
|
+
row=$(grep 'art-rej01' "$(_dir)/declined.jsonl")
|
|
203
|
+
[ "$(printf '%s' "$row" | jq -r '.artifact_id')" = "art-rej01" ]
|
|
204
|
+
[ "$(printf '%s' "$row" | jq -r '.verdict | type')" = "object" ]
|
|
205
|
+
[ "$(printf '%s' "$row" | jq -r '.verdict.judges | length')" = "2" ]
|
|
206
|
+
[ "$(printf '%s' "$row" | jq -r '.verdict.rubric_id')" = "lesson-promotion" ]
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@test "a stage-5 style decline still writes cleanly and has no verdict key" {
|
|
210
|
+
librarian_lesson_append_declined "$PROJECT_KEY" "art-t5" "transform_invalid" "malformed JSON"
|
|
211
|
+
local row
|
|
212
|
+
row=$(grep 'art-t5' "$(_dir)/declined.jsonl")
|
|
213
|
+
[ "$(printf '%s' "$row" | jq -r '.reason')" = "transform_invalid" ]
|
|
214
|
+
[ "$(printf '%s' "$row" | jq -r 'has("verdict")')" = "false" ]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@test "promoting twice leaves one pool entry with an unchanged promoted_at" {
|
|
218
|
+
_seed_judged "idem01" "org" "approved" "$(_two_passing)"
|
|
219
|
+
librarian_lesson_promote "$PROJECT_KEY" "idem01"
|
|
220
|
+
local first
|
|
221
|
+
first=$(cat "$(_dir)/approved/idem01.json")
|
|
222
|
+
|
|
223
|
+
run librarian_lesson_promote "$PROJECT_KEY" "idem01"
|
|
224
|
+
[ "$status" -eq 0 ]
|
|
225
|
+
[ "$(cat "$(_dir)/approved/idem01.json")" = "$first" ]
|
|
226
|
+
[ "$(ls "$(_dir)/approved" | grep -c idem01)" -eq 1 ]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
@test "promotion is refused before the lesson has been judged" {
|
|
230
|
+
_seed_judged "conf01" "org" "confirmed" '[]'
|
|
231
|
+
run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "conf01"
|
|
232
|
+
[ "$status" -ne 0 ]
|
|
233
|
+
[ "$output" = "" ]
|
|
234
|
+
[[ "$stderr" == *"not been judged"* ]] || return 1
|
|
235
|
+
[ ! -f "$(_dir)/approved/conf01.json" ]
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@test "promotion is refused from a passed lesson, naming the status" {
|
|
239
|
+
_seed_judged "pass01" "org" "passed" '[]'
|
|
240
|
+
run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "pass01"
|
|
241
|
+
[ "$status" -ne 0 ]
|
|
242
|
+
[[ "$stderr" == *"passed"* ]] || return 1
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@test "a failing author_key leaves nothing written and the lesson still approved" {
|
|
246
|
+
# THE reconcile property. Promotion fails for reasons judging does not —
|
|
247
|
+
# a malformed secret, absent node, a full disk — and the lesson must stay
|
|
248
|
+
# exactly where a standalone re-run can pick it up.
|
|
249
|
+
_seed_judged "ak01" "org" "approved" "$(_two_passing)"
|
|
250
|
+
local secret_path
|
|
251
|
+
secret_path=$(librarian_author_secret_path)
|
|
252
|
+
mkdir -p "$(dirname "$secret_path")"
|
|
253
|
+
printf 'not-a-valid-secret\n' > "$secret_path"
|
|
254
|
+
chmod 0600 "$secret_path"
|
|
255
|
+
|
|
256
|
+
run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "ak01"
|
|
257
|
+
[ "$status" -ne 0 ]
|
|
258
|
+
[ ! -f "$(_dir)/approved/ak01.json" ]
|
|
259
|
+
[ "$(jq -r 'has("promoted_at")' "$(_dir)/proposals/ak01.json")" = "false" ]
|
|
260
|
+
[ "$(jq -r '.status' "$(_dir)/proposals/ak01.json")" = "approved" ]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@test "the proposal survives promotion and carries promoted_at" {
|
|
264
|
+
_seed_judged "keep01" "org" "approved" "$(_two_passing)"
|
|
265
|
+
librarian_lesson_promote "$PROJECT_KEY" "keep01"
|
|
266
|
+
[ -f "$(_dir)/proposals/keep01.json" ]
|
|
267
|
+
[ "$(jq -r '.status' "$(_dir)/proposals/keep01.json")" = "approved" ]
|
|
268
|
+
printf '%s' "$(jq -r '.promoted_at' "$(_dir)/proposals/keep01.json")" \
|
|
269
|
+
| grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' || return 1
|
|
270
|
+
# The per-judge detail is why the proposal is kept: the pool entry has
|
|
271
|
+
# only consensus counts.
|
|
272
|
+
[ "$(jq -r '.verdict.judges | length' "$(_dir)/proposals/keep01.json")" = "2" ]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@test "librarian_lesson_seen reports the artifact handled after either path" {
|
|
276
|
+
_seed_judged "seen01" "org" "approved" "$(_two_passing)"
|
|
277
|
+
librarian_lesson_promote "$PROJECT_KEY" "seen01"
|
|
278
|
+
run librarian_lesson_seen "$PROJECT_KEY" "art-seen01"
|
|
279
|
+
[ "$status" -eq 0 ]
|
|
280
|
+
|
|
281
|
+
_seed_judged "seen02" "public" "rejected" "$(_split)"
|
|
282
|
+
librarian_lesson_promote "$PROJECT_KEY" "seen02"
|
|
283
|
+
run librarian_lesson_seen "$PROJECT_KEY" "art-seen02"
|
|
284
|
+
[ "$status" -eq 0 ]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
@test "a missing lesson is refused" {
|
|
288
|
+
run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "nope01"
|
|
289
|
+
[ "$status" -ne 0 ]
|
|
290
|
+
[[ "$stderr" == *"not found"* ]] || return 1
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
295
|
+
|
|
296
|
+
Run: `bats test/bats/librarian-lesson-promote.bats`
|
|
297
|
+
Expected: every test FAILS — the lib does not exist, so `source` in `setup()` errors.
|
|
298
|
+
|
|
299
|
+
- [ ] **Step 3: Extend `librarian_lesson_append_declined`**
|
|
300
|
+
|
|
301
|
+
In `plugins/librarian/scripts/lib/librarian-lesson-storage.sh`, add an optional
|
|
302
|
+
5th argument. Keep the two-branch shape rather than folding it — gating
|
|
303
|
+
`--argjson` behind a non-empty check keeps "absent" structural, so `--argjson`
|
|
304
|
+
never receives an empty string:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
# Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail] [verdict_json]
|
|
308
|
+
#
|
|
309
|
+
# `verdict` is emitted with --argjson so it lands as a nested object, not a
|
|
310
|
+
# serialized string: consumers read .verdict.judges[].score directly. Rows
|
|
311
|
+
# written by the transform have no verdict and simply lack the key — a format
|
|
312
|
+
# failure has no jury.
|
|
313
|
+
librarian_lesson_append_declined() {
|
|
314
|
+
local key="$1"
|
|
315
|
+
local artifact_id="$2"
|
|
316
|
+
local reason="$3"
|
|
317
|
+
local detail="${4:-}"
|
|
318
|
+
local verdict="${5:-}"
|
|
319
|
+
[[ -z "$key" || -z "$artifact_id" || -z "$reason" ]] && return 1
|
|
320
|
+
|
|
321
|
+
librarian_lesson_storage_init "$key" || return 1
|
|
322
|
+
|
|
323
|
+
local now line
|
|
324
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
325
|
+
if [[ -n "$verdict" ]]; then
|
|
326
|
+
line=$(jq -cn \
|
|
327
|
+
--arg artifact_id "$artifact_id" \
|
|
328
|
+
--arg reason "$reason" \
|
|
329
|
+
--arg detail "$detail" \
|
|
330
|
+
--arg at "$now" \
|
|
331
|
+
--argjson verdict "$verdict" \
|
|
332
|
+
'{
|
|
333
|
+
artifact_id: $artifact_id,
|
|
334
|
+
reason: $reason,
|
|
335
|
+
detail: (if $detail == "" then null else $detail end),
|
|
336
|
+
declined_at: $at,
|
|
337
|
+
verdict: $verdict
|
|
338
|
+
}') || return 1
|
|
339
|
+
else
|
|
340
|
+
line=$(jq -cn \
|
|
341
|
+
--arg artifact_id "$artifact_id" \
|
|
342
|
+
--arg reason "$reason" \
|
|
343
|
+
--arg detail "$detail" \
|
|
344
|
+
--arg at "$now" \
|
|
345
|
+
'{
|
|
346
|
+
artifact_id: $artifact_id,
|
|
347
|
+
reason: $reason,
|
|
348
|
+
detail: (if $detail == "" then null else $detail end),
|
|
349
|
+
declined_at: $at
|
|
350
|
+
}') || return 1
|
|
351
|
+
fi
|
|
352
|
+
|
|
353
|
+
printf '%s\n' "$line" >> "$(librarian_lessons_dir "$key")/declined.jsonl"
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
- [ ] **Step 4: Write the promote lib**
|
|
358
|
+
|
|
359
|
+
Create `plugins/librarian/scripts/lib/librarian-lesson-promote.sh`:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
#!/usr/bin/env bash
|
|
363
|
+
# Terminal state for the lesson-promotion pipeline.
|
|
364
|
+
#
|
|
365
|
+
# A judged proposal becomes either a ZLesson-shaped pool entry awaiting sync,
|
|
366
|
+
# or a row in the declined ledger. Nothing crosses the network — the sync
|
|
367
|
+
# service that drains the pool does not exist yet.
|
|
368
|
+
#
|
|
369
|
+
# Requires librarian-lesson-storage.sh and librarian-author-key.sh.
|
|
370
|
+
#
|
|
371
|
+
# Exposes:
|
|
372
|
+
# librarian_lesson_promote <key> <lesson_id>
|
|
373
|
+
|
|
374
|
+
# Map a lesson's visibility to the contract's `source` enum.
|
|
375
|
+
#
|
|
376
|
+
# NOT a rename: ZSource is local|org|public while visibility is
|
|
377
|
+
# private|org|public. `private` maps to `local` — the tier that never leaves
|
|
378
|
+
# this machine maps to the source meaning "not from anywhere else". Emitting
|
|
379
|
+
# "private" would fail ingest.
|
|
380
|
+
_librarian_lesson_source_for_visibility() {
|
|
381
|
+
case "${1:-}" in
|
|
382
|
+
private) printf 'local' ;;
|
|
383
|
+
org) printf 'org' ;;
|
|
384
|
+
public) printf 'public' ;;
|
|
385
|
+
*) return 1 ;;
|
|
386
|
+
esac
|
|
387
|
+
return 0
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# Write a file atomically: temp in the same directory, then mv.
|
|
391
|
+
#
|
|
392
|
+
# ecosystem-a3b is open against three existing `printf > path` sites in this
|
|
393
|
+
# plugin, each of which truncates before writing. These are new sites; adding
|
|
394
|
+
# a fourth instance of a known bug would be a choice, not an inheritance.
|
|
395
|
+
_librarian_lesson_write_atomic() {
|
|
396
|
+
local path="$1"
|
|
397
|
+
local content="$2"
|
|
398
|
+
local tmp
|
|
399
|
+
tmp=$(mktemp "${path}.XXXXXX" 2>/dev/null) || return 1
|
|
400
|
+
printf '%s\n' "$content" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
|
|
401
|
+
mv "$tmp" "$path" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# Promote one judged proposal to its terminal record.
|
|
405
|
+
#
|
|
406
|
+
# Returns 0 on success, including the already-promoted no-op. Returns 1 on
|
|
407
|
+
# refusal or failure, having written NOTHING — the proposal stays `approved`
|
|
408
|
+
# without `promoted_at`, which is the state a standalone re-run resolves.
|
|
409
|
+
#
|
|
410
|
+
# Ordering is load-bearing: the terminal record lands BEFORE the stamp. A
|
|
411
|
+
# stamp followed by a failed write would leave the lesson marked done, present
|
|
412
|
+
# nowhere, and invisible to a reconcile that keys on the stamp's absence.
|
|
413
|
+
#
|
|
414
|
+
# Usage: librarian_lesson_promote <key> <lesson_id>
|
|
415
|
+
librarian_lesson_promote() {
|
|
416
|
+
local key="$1"
|
|
417
|
+
local lesson_id="$2"
|
|
418
|
+
[[ -z "$key" || -z "$lesson_id" ]] && return 1
|
|
419
|
+
|
|
420
|
+
local dir path
|
|
421
|
+
dir="$(librarian_lessons_dir "$key")"
|
|
422
|
+
path="${dir}/proposals/${lesson_id}.json"
|
|
423
|
+
[[ -f "$path" ]] || { printf 'Lesson %s not found.\n' "$lesson_id" >&2; return 1; }
|
|
424
|
+
|
|
425
|
+
# Already promoted: a no-op success, so a reconcile loop is safe to run
|
|
426
|
+
# over everything. Same precedent as unconfirm on a pending lesson.
|
|
427
|
+
if [[ "$(jq -r 'has("promoted_at")' "$path" 2>/dev/null)" == "true" ]]; then
|
|
428
|
+
return 0
|
|
429
|
+
fi
|
|
430
|
+
|
|
431
|
+
local current_status visibility
|
|
432
|
+
current_status=$(jq -r '.status // ""' "$path" 2>/dev/null)
|
|
433
|
+
visibility=$(jq -r '.visibility // ""' "$path" 2>/dev/null)
|
|
434
|
+
|
|
435
|
+
case "$current_status" in
|
|
436
|
+
approved|rejected) ;;
|
|
437
|
+
confirmed)
|
|
438
|
+
printf 'Lesson %s has not been judged yet; nothing to promote.\n' "$lesson_id" >&2
|
|
439
|
+
return 1
|
|
440
|
+
;;
|
|
441
|
+
*)
|
|
442
|
+
printf 'Lesson %s cannot be promoted from status: %s\n' "$lesson_id" "$current_status" >&2
|
|
443
|
+
return 1
|
|
444
|
+
;;
|
|
445
|
+
esac
|
|
446
|
+
|
|
447
|
+
local now
|
|
448
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
449
|
+
|
|
450
|
+
if [[ "$current_status" == "approved" ]]; then
|
|
451
|
+
local source author_key entry pool_path
|
|
452
|
+
source=$(_librarian_lesson_source_for_visibility "$visibility") || {
|
|
453
|
+
printf 'Lesson %s has an unrecognized visibility: %s\n' "$lesson_id" "$visibility" >&2
|
|
454
|
+
return 1
|
|
455
|
+
}
|
|
456
|
+
author_key=$(librarian_author_key "$visibility") || {
|
|
457
|
+
printf 'Lesson %s: cannot derive an author key; nothing written.\n' "$lesson_id" >&2
|
|
458
|
+
return 1
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
# Exactly ZLesson's key set — it is a strictObject, so an extra key
|
|
462
|
+
# fails ingest as surely as a missing one. A private entry gets
|
|
463
|
+
# judges: 0 and is deliberately not ingest-valid; it never syncs.
|
|
464
|
+
entry=$(jq -cn \
|
|
465
|
+
--argjson p "$(cat "$path")" \
|
|
466
|
+
--arg ak "$author_key" \
|
|
467
|
+
--arg src "$source" \
|
|
468
|
+
--arg now "$now" \
|
|
469
|
+
'{
|
|
470
|
+
id: $p.id,
|
|
471
|
+
schema_version: 2,
|
|
472
|
+
claim: $p.candidate.claim,
|
|
473
|
+
rationale: $p.candidate.rationale,
|
|
474
|
+
evidence: $p.candidate.evidence,
|
|
475
|
+
applies_to: $p.candidate.applies_to,
|
|
476
|
+
visibility: $p.visibility,
|
|
477
|
+
consensus: {
|
|
478
|
+
judges: (($p.verdict.judges // []) | length),
|
|
479
|
+
agreed: ([($p.verdict.judges // [])[] | select(.passed == true)] | length),
|
|
480
|
+
decided_at: $p.judged_at
|
|
481
|
+
},
|
|
482
|
+
status: "active",
|
|
483
|
+
superseded_by: null,
|
|
484
|
+
source: $src,
|
|
485
|
+
author_key: $ak,
|
|
486
|
+
promoted_at: $now
|
|
487
|
+
}' 2>/dev/null) || {
|
|
488
|
+
printf 'Lesson %s: cannot build a pool entry.\n' "$lesson_id" >&2
|
|
489
|
+
return 1
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
pool_path="${dir}/approved/${lesson_id}.json"
|
|
493
|
+
if [[ ! -f "$pool_path" ]]; then
|
|
494
|
+
_librarian_lesson_write_atomic "$pool_path" "$entry" || {
|
|
495
|
+
printf 'Lesson %s: cannot write the pool entry.\n' "$lesson_id" >&2
|
|
496
|
+
return 1
|
|
497
|
+
}
|
|
498
|
+
fi
|
|
499
|
+
else
|
|
500
|
+
local artifact_id reason verdict
|
|
501
|
+
artifact_id=$(jq -r '.artifact_id // ""' "$path" 2>/dev/null)
|
|
502
|
+
reason=$(jq -r '.verdict.reason // "rejected"' "$path" 2>/dev/null)
|
|
503
|
+
verdict=$(jq -c '.verdict // {}' "$path" 2>/dev/null)
|
|
504
|
+
librarian_lesson_append_declined "$key" "$artifact_id" "$reason" "" "$verdict" || {
|
|
505
|
+
printf 'Lesson %s: cannot append to the declined ledger.\n' "$lesson_id" >&2
|
|
506
|
+
return 1
|
|
507
|
+
}
|
|
508
|
+
fi
|
|
509
|
+
|
|
510
|
+
# Stamp LAST. See the ordering note above.
|
|
511
|
+
local updated
|
|
512
|
+
updated=$(jq --arg t "$now" '.promoted_at = $t' "$path" 2>/dev/null) || return 1
|
|
513
|
+
[[ -z "$updated" || "$updated" == "null" ]] && return 1
|
|
514
|
+
_librarian_lesson_write_atomic "$path" "$updated"
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
- [ ] **Step 5: Run the tests to verify they pass**
|
|
519
|
+
|
|
520
|
+
Run: `bats test/bats/librarian-lesson-promote.bats`
|
|
521
|
+
Expected: PASS, 14/14.
|
|
522
|
+
|
|
523
|
+
- [ ] **Step 6: Prove the three load-bearing guards discriminate**
|
|
524
|
+
|
|
525
|
+
In a throwaway `git worktree` only — never the shared working tree. Report each
|
|
526
|
+
result separately; if any leaves the suite green, say so plainly.
|
|
527
|
+
|
|
528
|
+
```bash
|
|
529
|
+
git worktree add /tmp/ap-verify HEAD
|
|
530
|
+
# (a) change the private mapping from "local" to "private"
|
|
531
|
+
# Expected: "a private lesson maps to source local" FAILS
|
|
532
|
+
# (b) move the promoted_at stamp BEFORE the pool write
|
|
533
|
+
# Expected: "a failing author_key leaves nothing written" FAILS
|
|
534
|
+
# (c) pass the verdict with --arg instead of --argjson
|
|
535
|
+
# Expected: "a rejected lesson writes a declined row with a NESTED
|
|
536
|
+
# verdict" FAILS on the `.verdict | type` assertion
|
|
537
|
+
git worktree remove --force /tmp/ap-verify
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
- [ ] **Step 7: Lint and commit**
|
|
541
|
+
|
|
542
|
+
```bash
|
|
543
|
+
npm run lint:check
|
|
544
|
+
shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-promote.sh
|
|
545
|
+
shellcheck -S error -x plugins/librarian/scripts/lib/librarian-lesson-storage.sh
|
|
546
|
+
git add plugins/librarian/scripts/lib/librarian-lesson-promote.sh \
|
|
547
|
+
plugins/librarian/scripts/lib/librarian-lesson-storage.sh \
|
|
548
|
+
test/bats/librarian-lesson-promote.bats
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Read each exit code from `$?` directly — never through a pipe.
|
|
552
|
+
|
|
553
|
+
Commit subject: `feat(librarian): land a judged lesson in its terminal home :package:`
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
### Task 2: The CLI surface and the walk
|
|
558
|
+
|
|
559
|
+
**Files:**
|
|
560
|
+
- Modify: `plugins/librarian/scripts/lib/librarian-cli.sh`
|
|
561
|
+
- Modify: `plugins/librarian/skills/librarian/SKILL.md`
|
|
562
|
+
- Test: `test/bats/librarian-lesson-promote.bats` (append)
|
|
563
|
+
|
|
564
|
+
**Interfaces:**
|
|
565
|
+
- Consumes: `librarian_lesson_promote <key> <lesson_id>` (Task 1), returning 0 on success including the already-promoted no-op, 1 on refusal or failure.
|
|
566
|
+
- Produces: `librarian_cli lessons promote <id> [cwd]`, and promotion wired into `lessons judge`.
|
|
567
|
+
|
|
568
|
+
- [ ] **Step 1: Write the failing tests**
|
|
569
|
+
|
|
570
|
+
Append to `test/bats/librarian-lesson-promote.bats`. Add to `setup()`:
|
|
571
|
+
|
|
572
|
+
```bash
|
|
573
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-review.sh"
|
|
574
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-rubric.sh"
|
|
575
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-lesson-judge.sh"
|
|
576
|
+
source "${PLUGIN_ROOT}/scripts/lib/librarian-cli.sh"
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
```bash
|
|
580
|
+
@test "lessons promote lands a pool entry through the CLI" {
|
|
581
|
+
_seed_judged "cli01" "org" "approved" "$(_two_passing)"
|
|
582
|
+
run librarian_cli lessons promote "cli01" "$PROJECT_REPO"
|
|
583
|
+
[ "$status" -eq 0 ]
|
|
584
|
+
[ -f "$(_dir)/approved/cli01.json" ]
|
|
585
|
+
[[ "$output" == *"cli01"* ]] || return 1
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
@test "lessons promote requires a lesson id" {
|
|
589
|
+
run librarian_cli lessons promote
|
|
590
|
+
[ "$status" -ne 0 ]
|
|
591
|
+
[[ "$output" == *"usage:"* && "$output" == *"promote"* ]] || return 1
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
@test "lessons promote rejects an unknown flag" {
|
|
595
|
+
_seed_judged "cli02" "org" "approved" "$(_two_passing)"
|
|
596
|
+
run librarian_cli lessons promote "cli02" --force
|
|
597
|
+
[ "$status" -ne 0 ]
|
|
598
|
+
[[ "$output" == *"unknown option"* && "$output" == *"--force"* ]] || return 1
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
@test "lessons judge promotes automatically after recording a verdict" {
|
|
602
|
+
# The ordinary path is one command: judge, record, promote.
|
|
603
|
+
_seed_judged "auto01" "org" "confirmed" '[]'
|
|
604
|
+
run librarian_cli lessons judge "auto01" "$(_two_passing)" "$PROJECT_REPO"
|
|
605
|
+
[ "$status" -eq 0 ]
|
|
606
|
+
[ "$(jq -r '.status' "$(_dir)/proposals/auto01.json")" = "approved" ]
|
|
607
|
+
[ -f "$(_dir)/approved/auto01.json" ]
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
@test "an unjudged candidate is neither recorded nor promoted" {
|
|
611
|
+
# The judge returns 2 for UNJUDGED and writes nothing; promotion must not
|
|
612
|
+
# run behind it and invent a terminal state for a lesson with no verdict.
|
|
613
|
+
_seed_judged "auto02" "org" "confirmed" '[]'
|
|
614
|
+
run librarian_cli lessons judge "auto02" '[{"judge_type":"standard","score":"bad","passed":true}]' "$PROJECT_REPO"
|
|
615
|
+
[ "$status" -eq 2 ]
|
|
616
|
+
[ "$(jq -r '.status' "$(_dir)/proposals/auto02.json")" = "confirmed" ]
|
|
617
|
+
[ ! -f "$(_dir)/approved/auto02.json" ]
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
- [ ] **Step 2: Run the tests to verify they fail**
|
|
622
|
+
|
|
623
|
+
Run: `bats test/bats/librarian-lesson-promote.bats`
|
|
624
|
+
Expected: the five new tests FAIL — `promote` is an unknown lessons action.
|
|
625
|
+
|
|
626
|
+
- [ ] **Step 3: Add the CLI verb**
|
|
627
|
+
|
|
628
|
+
Add to `librarian-cli.sh`, matching the sibling verbs' shape:
|
|
629
|
+
|
|
630
|
+
```bash
|
|
631
|
+
# Usage: librarian_cli_lessons_promote <lesson_id> [cwd]
|
|
632
|
+
#
|
|
633
|
+
# Runnable standalone on purpose: promotion fails for reasons judging does not
|
|
634
|
+
# — a malformed author secret, absent node, a full disk — and a standalone run
|
|
635
|
+
# is how a correctly-judged, not-yet-promoted lesson gets reconciled.
|
|
636
|
+
librarian_cli_lessons_promote() {
|
|
637
|
+
local lesson_id="" cwd=""
|
|
638
|
+
local positional=0
|
|
639
|
+
while [[ $# -gt 0 ]]; do
|
|
640
|
+
case "$1" in
|
|
641
|
+
--*)
|
|
642
|
+
printf 'unknown option: %s\n' "$1" >&2
|
|
643
|
+
return 1
|
|
644
|
+
;;
|
|
645
|
+
*)
|
|
646
|
+
case "$positional" in
|
|
647
|
+
0) lesson_id="$1" ;;
|
|
648
|
+
*) cwd="$1" ;;
|
|
649
|
+
esac
|
|
650
|
+
positional=$((positional + 1))
|
|
651
|
+
shift
|
|
652
|
+
;;
|
|
653
|
+
esac
|
|
654
|
+
done
|
|
655
|
+
|
|
656
|
+
[[ -z "$lesson_id" ]] && { printf 'usage: librarian_cli lessons promote <lesson_id> [cwd]\n'; return 1; }
|
|
657
|
+
|
|
658
|
+
local key
|
|
659
|
+
key=$(_librarian_cli_project_key "$cwd")
|
|
660
|
+
[[ -z "$key" ]] && { printf 'No project key resolvable from this directory.\n'; return 1; }
|
|
661
|
+
|
|
662
|
+
librarian_lesson_promote "$key" "$lesson_id" || return 1
|
|
663
|
+
printf 'Lesson %s promoted.\n' "$lesson_id"
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
Register it in `librarian_cli_lessons`'s `case`, after `judge`:
|
|
668
|
+
|
|
669
|
+
```bash
|
|
670
|
+
promote) librarian_cli_lessons_promote "$@" ;;
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
- [ ] **Step 4: Wire promotion into the judge verb**
|
|
674
|
+
|
|
675
|
+
In `librarian_cli_lessons_judge`, inside the `case "$rc"` arm for `0` only —
|
|
676
|
+
**not** for `2`. An unjudged candidate has no verdict, and promoting behind it
|
|
677
|
+
would invent a terminal state for a lesson the jury never decided:
|
|
678
|
+
|
|
679
|
+
```bash
|
|
680
|
+
0)
|
|
681
|
+
printf 'Lesson %s is now %s.\n' "$lesson_id" \
|
|
682
|
+
"$(jq -r '.status' "$(librarian_lessons_dir "$key")/proposals/${lesson_id}.json")"
|
|
683
|
+
# Promotion failing does not undo a correct verdict: report it and
|
|
684
|
+
# leave the lesson promotable by a standalone `lessons promote`.
|
|
685
|
+
librarian_lesson_promote "$key" "$lesson_id" \
|
|
686
|
+
|| printf 'Lesson %s was judged but not promoted; run `lessons promote %s` to retry.\n' \
|
|
687
|
+
"$lesson_id" "$lesson_id"
|
|
688
|
+
;;
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
Source `librarian-lesson-promote.sh` alongside the existing lesson libs in
|
|
692
|
+
`librarian-cli.sh`'s header comment list — that file deliberately sources
|
|
693
|
+
nothing itself; its callers do.
|
|
694
|
+
|
|
695
|
+
- [ ] **Step 5: Update SKILL.md**
|
|
696
|
+
|
|
697
|
+
Add `source "$CLAUDE_PLUGIN_ROOT/scripts/lib/librarian-lesson-promote.sh"`
|
|
698
|
+
to the existing source block, **before** the `librarian-cli.sh` line.
|
|
699
|
+
|
|
700
|
+
In the `lessons judge` walk, note that recording a verdict also promotes, and
|
|
701
|
+
that a lesson reported as judged-but-not-promoted should be retried with
|
|
702
|
+
`librarian_cli lessons promote <id>` rather than re-judged — re-judging would
|
|
703
|
+
spend tokens again for a verdict that already exists.
|
|
704
|
+
|
|
705
|
+
- [ ] **Step 6: Run the tests and the full suite**
|
|
706
|
+
|
|
707
|
+
```bash
|
|
708
|
+
bats test/bats/librarian-lesson-promote.bats
|
|
709
|
+
bats test/bats/librarian-lesson-judge.bats
|
|
710
|
+
bats test/bats/librarian-cli.bats
|
|
711
|
+
npm run lint:check
|
|
712
|
+
npm run test:ci
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Read each exit code from `$?` directly, never through a pipe.
|
|
716
|
+
|
|
717
|
+
- [ ] **Step 7: Commit**
|
|
718
|
+
|
|
719
|
+
```bash
|
|
720
|
+
git add plugins/librarian/scripts/lib/librarian-cli.sh \
|
|
721
|
+
plugins/librarian/skills/librarian/SKILL.md \
|
|
722
|
+
test/bats/librarian-lesson-promote.bats
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
Commit subject: `feat(librarian): promote a lesson from the judge walk :package:`
|
|
726
|
+
|
|
727
|
+
---
|
|
728
|
+
|
|
729
|
+
## Spec coverage
|
|
730
|
+
|
|
731
|
+
| Spec requirement | Task |
|
|
732
|
+
|---|---|
|
|
733
|
+
| Pool entry's key set exactly equals `ZLesson`'s | 1 |
|
|
734
|
+
| `private` → `local`, `org` → `org`, `public` → `public` | 1 |
|
|
735
|
+
| `private` entry has `judges: 0`, deliberately not ingest-valid | 1 |
|
|
736
|
+
| `agreed <= judges` | 1 |
|
|
737
|
+
| Rejected → declined row with gate reason and a nested verdict, no pool entry | 1 |
|
|
738
|
+
| Stage-5 declines still write, without a `verdict` key | 1 |
|
|
739
|
+
| `confirmed` / `pending` / `passed` refused, naming the status | 1 |
|
|
740
|
+
| Already promoted is a no-op with one entry and unchanged `promoted_at` | 1 |
|
|
741
|
+
| Failing `author_key` writes nothing, lesson stays `approved` unstamped | 1 |
|
|
742
|
+
| `librarian_lesson_seen` reports handled after both paths | 1 |
|
|
743
|
+
| Terminal record before the stamp | 1 (proved by the Step 6b injection) |
|
|
744
|
+
| Atomic writes | 1 |
|
|
745
|
+
| Promotion spends nothing | 1 (the `claude` stub in `setup()`) |
|
|
746
|
+
| Standalone verb and automatic call from the judge walk | 2 |
|
|
747
|
+
| An unjudged candidate is neither recorded nor promoted | 2 |
|