@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.
Files changed (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -0,0 +1,567 @@
1
+ # Author Key Derivation 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:** Derive a per-visibility-scope `author_key` from a machine-local secret, so a lesson can be attributed without linking a user's org and public identities.
6
+
7
+ **Architecture:** One new librarian lib. A `0600` secret file outside any project key, created once and never regenerated, plus an HMAC derivation with a versioned domain tag. Nothing consumes it yet — `ecosystem-4z8.4` will.
8
+
9
+ **Tech Stack:** bash (macOS bash 3.2 compatible), `openssl` (LibreSSL 3.3.6 on macOS, OpenSSL 3.x on Linux CI), bats.
10
+
11
+ ## Pre-flight — RESOLVED
12
+
13
+ The `author_key` width was recorded as an assumption. **It is confirmed.**
14
+ `ZAuthorKey` in `onlooker/packages/lesson-contract/src/primitives.ts:35-41`
15
+ is `z.string().regex(/^[0-9a-f]{32}$/)` — 32 lowercase hex characters. The
16
+ derivation truncates HMAC-SHA256 to its first 16 bytes, and the golden vectors
17
+ below are correct as written.
18
+
19
+ The contract does not pin the HMAC's `scope` input, so the
20
+ `onlooker.author.v1:` domain tag is ours to choose and is compatible.
21
+
22
+ ## Global Constraints
23
+
24
+ - **The secret is NOT project-keyed.** It lives at `${ONLOOKER_DIR:-$HOME/.onlooker}/author/user_secret`, outside any project directory. Every *other* librarian artifact is project-keyed via `librarian_project_dir`, so the local pattern is the wrong one to copy here — a per-project secret would silently give one user a different identity in every repo.
25
+ - **Never use `$RANDOM` for the secret.** `plugins/archivist/scripts/lib/archivist-ulid.sh:41-44` uses it for ULIDs, which is correct there and disqualifying here. Copy `plugins/assayer/scripts/lib/assayer-ulid.sh:29`'s `openssl rand -hex` instead.
26
+ - **`openssl rand -hex 32` means 32 BYTES, printed as 64 hex characters.** Do not "correct" it to `-hex 16`; that halves the entropy and nothing fails.
27
+ - **Never regenerate an existing secret.** A missing file means first use; an existing file is authoritative. Regenerating orphans every lesson the user has written, including their ability to retract them.
28
+ - Use `${ONLOOKER_DIR:-$HOME/.onlooker}` — the idiom at `plugins/librarian/scripts/lib/librarian-storage.sh:19`. Never a bare hardcoded `~/.onlooker`.
29
+ - On failure, return non-zero and write **nothing to stdout**, with a reason on stderr. Silence is the actual danger in this file.
30
+ - Bash 3.2 compatible: no associative arrays, no `${var^^}`, no `mapfile`.
31
+ - **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.
32
+ - Assert on messages, not just exit codes, wherever a test asserts a refusal. This codebase has shipped six vacuous tests across three branches.
33
+ - No event emission. American English.
34
+ - Commit via the `/commit` contract: `<type>(<scope>): <subject> :emoji:`, subject ≤72 chars including the emoji, why-focused body.
35
+
36
+ ## File Structure
37
+
38
+ | File | Responsibility |
39
+ |---|---|
40
+ | `plugins/librarian/scripts/lib/librarian-author-key.sh` | **Create.** Secret path, secret creation/validation, and the HMAC derivation. |
41
+ | `test/bats/librarian-author-key.bats` | **Create.** Secret-handling tests (Task 1), derivation tests including the golden vector (Task 2). |
42
+ | `docs/lesson-promotion-pipeline.md` | **Modify** (Task 2). Its "Open questions" section still lists `author_key` derivation as unanswered. |
43
+
44
+ ---
45
+
46
+ ### Task 1: The secret
47
+
48
+ **Files:**
49
+ - Create: `plugins/librarian/scripts/lib/librarian-author-key.sh`
50
+ - Test: `test/bats/librarian-author-key.bats`
51
+
52
+ **Interfaces:**
53
+ - Consumes: nothing. This lib is self-contained by design — it must not depend on `librarian_project_dir`, because the secret is not project-scoped.
54
+ - Produces:
55
+ - `librarian_author_secret_path` → echoes the absolute path. Never fails.
56
+ - `librarian_author_secret_ensure` → creates the secret if absent, validates it if present. Echoes the secret on stdout, returns 0. Returns non-zero with empty stdout and a stderr reason if it cannot produce a valid secret.
57
+
58
+ - [ ] **Step 1: Write the failing tests**
59
+
60
+ Create `test/bats/librarian-author-key.bats`:
61
+
62
+ ```bash
63
+ #!/usr/bin/env bats
64
+
65
+ setup() {
66
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
67
+ setup_test_env
68
+
69
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/librarian"
70
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
71
+
72
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
73
+ }
74
+
75
+ @test "the secret lives outside any project directory" {
76
+ # Every other librarian artifact is project-keyed. This one must not be:
77
+ # a per-project secret would give one user a different identity in every
78
+ # repo, silently.
79
+ run librarian_author_secret_path
80
+ [ "$status" -eq 0 ]
81
+ [ "$output" = "${ONLOOKER_DIR}/author/user_secret" ]
82
+ [[ "$output" != *"/librarian/"* ]] || return 1
83
+ }
84
+
85
+ @test "first use creates a secret with 0600 permissions" {
86
+ run librarian_author_secret_ensure
87
+ [ "$status" -eq 0 ]
88
+
89
+ local path
90
+ path=$(librarian_author_secret_path)
91
+ [ -f "$path" ]
92
+ # stat's portable-enough form for mode; %A on GNU, %Sp on BSD — use ls.
93
+ local mode
94
+ mode=$(ls -l "$path" | cut -c1-10)
95
+ [ "$mode" = "-rw-------" ]
96
+ }
97
+
98
+ @test "the generated secret is 64 hex characters" {
99
+ # openssl rand -hex 32 requests 32 BYTES and prints 64 characters.
100
+ # Halving this to -hex 16 would halve the entropy and nothing would fail.
101
+ run librarian_author_secret_ensure
102
+ [ "$status" -eq 0 ]
103
+ [ "${#output}" -eq 64 ]
104
+ printf '%s' "$output" | grep -Eq '^[0-9a-f]{64}$' || return 1
105
+ }
106
+
107
+ @test "an existing secret is never regenerated" {
108
+ # Load-bearing: regenerating silently changes the user's identity and
109
+ # orphans every lesson they have written, including retraction.
110
+ librarian_author_secret_ensure >/dev/null
111
+ local path first second
112
+ path=$(librarian_author_secret_path)
113
+ first=$(cat "$path")
114
+
115
+ librarian_author_secret_ensure >/dev/null
116
+ second=$(cat "$path")
117
+ [ "$first" = "$second" ]
118
+ }
119
+
120
+ @test "an empty secret is refused, not used" {
121
+ # HMAC("", scope) is IDENTICAL for every user in this state — a corrupt
122
+ # secret would silently collapse everyone onto one shared identity.
123
+ local path
124
+ path=$(librarian_author_secret_path)
125
+ mkdir -p "$(dirname "$path")"
126
+ : > "$path"
127
+
128
+ run librarian_author_secret_ensure
129
+ [ "$status" -ne 0 ]
130
+ [ "$output" = "" ]
131
+ }
132
+
133
+ @test "a short secret is refused, naming the problem" {
134
+ local path
135
+ path=$(librarian_author_secret_path)
136
+ mkdir -p "$(dirname "$path")"
137
+ printf 'abc123' > "$path"
138
+
139
+ run librarian_author_secret_ensure
140
+ [ "$status" -ne 0 ]
141
+ [[ "$output" == *"too short"* ]] || return 1
142
+ }
143
+
144
+ @test "a world-readable secret is tightened and warned about" {
145
+ local path
146
+ path=$(librarian_author_secret_path)
147
+ mkdir -p "$(dirname "$path")"
148
+ openssl rand -hex 32 > "$path"
149
+ chmod 0644 "$path"
150
+
151
+ run librarian_author_secret_ensure
152
+ [ "$status" -eq 0 ]
153
+ [ "$(ls -l "$path" | cut -c1-10)" = "-rw-------" ]
154
+ [[ "$output" == *"permissions"* ]] || return 1
155
+ }
156
+
157
+ @test "the lib never uses RANDOM for the secret" {
158
+ # archivist-ulid.sh uses $RANDOM correctly for a sortable id; it is the
159
+ # nearer example in this repo and the wrong one to copy for a secret.
160
+ run grep -c 'RANDOM' "${PLUGIN_ROOT}/scripts/lib/librarian-author-key.sh"
161
+ [ "$output" = "0" ]
162
+ }
163
+ ```
164
+
165
+ - [ ] **Step 2: Run the tests to verify they fail**
166
+
167
+ Run: `bats test/bats/librarian-author-key.bats`
168
+ Expected: every test FAILS — the lib does not exist, so `source` in `setup()` errors.
169
+
170
+ - [ ] **Step 3: Write the secret half of the lib**
171
+
172
+ Create `plugins/librarian/scripts/lib/librarian-author-key.sh`:
173
+
174
+ ```bash
175
+ #!/usr/bin/env bash
176
+ # Author identity for lesson promotion.
177
+ #
178
+ # author_key is derived PER VISIBILITY SCOPE so a user's org identity and their
179
+ # public identity cannot be linked by anyone who does not hold their secret.
180
+ #
181
+ # Breaking that unlinkability breaks it SILENTLY: nothing throws, and the pool
182
+ # fills with well-formed keys that leak the association they exist to prevent.
183
+ # The golden-vector test in test/bats/librarian-author-key.bats is what makes
184
+ # any change to this derivation go red.
185
+ #
186
+ # Exposes:
187
+ # librarian_author_secret_path
188
+ # librarian_author_secret_ensure
189
+ # librarian_author_key <visibility>
190
+
191
+ # Where the secret lives.
192
+ #
193
+ # NOT project-keyed, deliberately. Every other librarian artifact sits under
194
+ # librarian_project_dir; this one must not, because one user is one author
195
+ # across all their repos. A per-project secret would hand the same person a
196
+ # different identity in every project, and nothing would report it.
197
+ librarian_author_secret_path() {
198
+ printf '%s/author/user_secret' "${ONLOOKER_DIR:-$HOME/.onlooker}"
199
+ }
200
+
201
+ # Echo a valid secret, creating one on first use.
202
+ #
203
+ # Returns non-zero with empty stdout if it cannot produce a valid secret. An
204
+ # invalid secret is never "repaired" by regenerating: an existing file is
205
+ # authoritative, because replacing it silently changes the user's identity.
206
+ librarian_author_secret_ensure() {
207
+ local path dir
208
+ path="$(librarian_author_secret_path)"
209
+ dir="$(dirname "$path")"
210
+
211
+ if [[ ! -f "$path" ]]; then
212
+ command -v openssl >/dev/null 2>&1 || {
213
+ printf 'author-key: openssl is required to create a secret.\n' >&2
214
+ return 1
215
+ }
216
+ mkdir -p "$dir" 2>/dev/null || {
217
+ printf 'author-key: cannot create %s\n' "$dir" >&2
218
+ return 1
219
+ }
220
+ # 32 BYTES, printed as 64 hex characters. Not $RANDOM: that is a
221
+ # 15-bit PRNG, fine for a sortable id and disqualifying for a secret.
222
+ local generated
223
+ generated=$(openssl rand -hex 32 2>/dev/null) || {
224
+ printf 'author-key: openssl rand failed.\n' >&2
225
+ return 1
226
+ }
227
+ # Create restricted, then write — never write then chmod, which leaves
228
+ # a window where the secret is world-readable on disk.
229
+ ( umask 077 && printf '%s\n' "$generated" > "$path" ) || {
230
+ printf 'author-key: cannot write %s\n' "$path" >&2
231
+ return 1
232
+ }
233
+ fi
234
+
235
+ # Tighten loose permissions and say so. Refusing would block promotion over
236
+ # something the user cannot fix without guidance; staying silent would hide
237
+ # a real exposure. Tightening does not undo an exposure that already
238
+ # happened — it stops the next one, and the warning is the part that counts.
239
+ local mode
240
+ mode=$(ls -l "$path" 2>/dev/null | cut -c1-10)
241
+ if [[ "$mode" != "-rw-------" ]]; then
242
+ chmod 0600 "$path" 2>/dev/null
243
+ printf 'author-key: tightened permissions on %s (was %s)\n' "$path" "$mode" >&2
244
+ fi
245
+
246
+ local secret
247
+ secret=$(cat "$path" 2>/dev/null | tr -d '\n')
248
+ if [[ -z "$secret" ]]; then
249
+ printf 'author-key: secret at %s is empty; refusing to derive.\n' "$path" >&2
250
+ return 1
251
+ fi
252
+ # A short-but-nonempty secret still derives a plausible key with less
253
+ # entropy than this design claims. 64 is the width openssl rand -hex 32
254
+ # produces.
255
+ if [[ "${#secret}" -lt 64 ]]; then
256
+ printf 'author-key: secret at %s is too short (%d chars, expected 64).\n' \
257
+ "$path" "${#secret}" >&2
258
+ return 1
259
+ fi
260
+
261
+ printf '%s' "$secret"
262
+ }
263
+ ```
264
+
265
+ - [ ] **Step 4: Run the tests to verify they pass**
266
+
267
+ Run: `bats test/bats/librarian-author-key.bats`
268
+ Expected: PASS, 8/8.
269
+
270
+ - [ ] **Step 5: Prove the two security-critical tests discriminate**
271
+
272
+ In a throwaway `git worktree` only — never the shared working tree:
273
+
274
+ ```bash
275
+ git worktree add /tmp/ak-verify HEAD
276
+ # In /tmp/ak-verify, two separate injections, run the suite after each:
277
+ # (a) make librarian_author_secret_ensure regenerate unconditionally
278
+ # (move the generation outside the `if [[ ! -f ... ]]`)
279
+ # Expected: "an existing secret is never regenerated" FAILS
280
+ # (b) delete the empty-secret guard
281
+ # Expected: "an empty secret is refused, not used" FAILS
282
+ git worktree remove --force /tmp/ak-verify
283
+ ```
284
+
285
+ Report each injection's result separately. If either leaves the suite green, that test is not pinning its guard — say so plainly rather than reporting it as proven.
286
+
287
+ - [ ] **Step 6: Lint and commit**
288
+
289
+ ```bash
290
+ npm run lint:check
291
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-author-key.sh
292
+ git add plugins/librarian/scripts/lib/librarian-author-key.sh test/bats/librarian-author-key.bats
293
+ ```
294
+
295
+ Commit subject: `feat(librarian): keep one author secret per user :closed_lock_with_key:`
296
+
297
+ ---
298
+
299
+ ### Task 2: The derivation
300
+
301
+ **Files:**
302
+ - Modify: `plugins/librarian/scripts/lib/librarian-author-key.sh`
303
+ - Modify: `docs/lesson-promotion-pipeline.md`
304
+ - Test: `test/bats/librarian-author-key.bats` (append)
305
+
306
+ **Interfaces:**
307
+ - Consumes: `librarian_author_secret_ensure` from Task 1.
308
+ - Produces: `librarian_author_key <visibility>` → echoes 32 lowercase hex, returns 0. Non-zero with empty stdout on any failure.
309
+
310
+ **Confirm the BLOCKING PRE-FLIGHT above before starting.** If the contract says 64 hex rather than 32, change the truncation and regenerate the golden vector; nothing else moves.
311
+
312
+ - [ ] **Step 1: Write the failing tests**
313
+
314
+ Append to `test/bats/librarian-author-key.bats`:
315
+
316
+ ```bash
317
+ # A fixed secret, so the golden vector below is reproducible.
318
+ _fixed_secret() {
319
+ local path
320
+ path=$(librarian_author_secret_path)
321
+ mkdir -p "$(dirname "$path")"
322
+ printf '%s\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$path"
323
+ chmod 0600 "$path"
324
+ }
325
+
326
+ @test "GOLDEN VECTOR: a fixed secret and visibility produce a fixed key" {
327
+ # THE load-bearing test. Change the domain tag, the truncation width, the
328
+ # hash, or the argument order and this goes red. It is what makes "the
329
+ # derivation is permanent" enforceable rather than aspirational.
330
+ #
331
+ # Regenerate ONLY if the contract's width changes, and say so in the
332
+ # commit — a silent update here defeats the test's whole purpose.
333
+ _fixed_secret
334
+ run librarian_author_key "public"
335
+ [ "$status" -eq 0 ]
336
+ [ "$output" = "11ff8ab7134c834e788ab4a5130f7853" ]
337
+ }
338
+
339
+ @test "GOLDEN VECTOR: org and private are pinned too" {
340
+ # All three, so a change that happens to preserve one scope's output
341
+ # still goes red. Computed independently of the implementation.
342
+ _fixed_secret
343
+ [ "$(librarian_author_key "private")" = "e74674c25190cdf15099604441bb0d4b" ]
344
+ [ "$(librarian_author_key "org")" = "a8cf0203e178702412d37d5f796adbdc" ]
345
+ }
346
+
347
+ @test "the same inputs always produce the same key" {
348
+ # Retraction depends on this: a user must be able to re-derive the key
349
+ # that authored a lesson.
350
+ _fixed_secret
351
+ local a b
352
+ a=$(librarian_author_key "org")
353
+ b=$(librarian_author_key "org")
354
+ [ "$a" = "$b" ]
355
+ }
356
+
357
+ @test "the three visibilities produce three distinct keys" {
358
+ _fixed_secret
359
+ local p o u
360
+ p=$(librarian_author_key "private")
361
+ o=$(librarian_author_key "org")
362
+ u=$(librarian_author_key "public")
363
+ [ "$p" != "$o" ]
364
+ [ "$o" != "$u" ]
365
+ [ "$p" != "$u" ]
366
+ }
367
+
368
+ @test "different secrets produce different keys at the same visibility" {
369
+ # Catches a constant that ignores the secret entirely — which the
370
+ # scope-separation test above would NOT catch.
371
+ _fixed_secret
372
+ local first
373
+ first=$(librarian_author_key "public")
374
+
375
+ local path
376
+ path=$(librarian_author_secret_path)
377
+ printf '%s\n' "1111111111111111111111111111111111111111111111111111111111111111" > "$path"
378
+ local second
379
+ second=$(librarian_author_key "public")
380
+
381
+ [ "$first" != "$second" ]
382
+ }
383
+
384
+ @test "the key is 32 lowercase hex" {
385
+ _fixed_secret
386
+ run librarian_author_key "public"
387
+ [ "$status" -eq 0 ]
388
+ [ "${#output}" -eq 32 ]
389
+ printf '%s' "$output" | grep -Eq '^[0-9a-f]{32}$' || return 1
390
+ }
391
+
392
+ @test "the key is not the secret" {
393
+ # Catches a "derivation" that echoes its input.
394
+ _fixed_secret
395
+ local secret key
396
+ secret=$(librarian_author_secret_ensure)
397
+ key=$(librarian_author_key "public")
398
+ [ "$key" != "$secret" ]
399
+ [[ "$secret" != *"$key"* ]] || return 1
400
+ }
401
+
402
+ @test "an unknown visibility is refused, naming it" {
403
+ _fixed_secret
404
+ run librarian_author_key "everyone"
405
+ [ "$status" -ne 0 ]
406
+ [ "$output" = "" ]
407
+ }
408
+
409
+ @test "a derivation on an empty secret refuses rather than sharing an identity" {
410
+ local path
411
+ path=$(librarian_author_secret_path)
412
+ mkdir -p "$(dirname "$path")"
413
+ : > "$path"
414
+
415
+ run librarian_author_key "public"
416
+ [ "$status" -ne 0 ]
417
+ [ "$output" = "" ]
418
+ }
419
+ ```
420
+
421
+ - [ ] **Step 2: Verify the golden vectors independently**
422
+
423
+ The three expected values above were computed **before any implementation
424
+ existed**, directly from `openssl`, so they are not circular. Confirm them
425
+ yourself before trusting them — a golden vector copied from a buggy
426
+ implementation pins the bug:
427
+
428
+ ```bash
429
+ Z=0000000000000000000000000000000000000000000000000000000000000000
430
+ for v in private org public; do
431
+ printf '%s' "onlooker.author.v1:${v}" \
432
+ | openssl dgst -sha256 -hmac "$Z" -r | cut -d' ' -f1 | cut -c1-32
433
+ done
434
+ ```
435
+
436
+ Expected, in order: `e74674c25190cdf15099604441bb0d4b`,
437
+ `a8cf0203e178702412d37d5f796adbdc`, `11ff8ab7134c834e788ab4a5130f7853`.
438
+
439
+ If your platform's `openssl` disagrees with these, **stop and report it** —
440
+ that is a portability problem in the derivation itself, not a bad test, and it
441
+ means keys would differ between a contributor's machine and CI.
442
+
443
+ - [ ] **Step 3: Run the tests to verify they fail**
444
+
445
+ Run: `bats test/bats/librarian-author-key.bats`
446
+ Expected: the nine new tests FAIL — `librarian_author_key` is not defined.
447
+
448
+ - [ ] **Step 4: Write the derivation**
449
+
450
+ Append to `plugins/librarian/scripts/lib/librarian-author-key.sh`:
451
+
452
+ ```bash
453
+ # Derive this user's author_key for one visibility scope.
454
+ #
455
+ # HMAC-SHA256(secret, "onlooker.author.v1:<visibility>")
456
+ # truncated to 16 bytes, rendered as 32 lowercase hex
457
+ #
458
+ # The domain tag stops this secret's output colliding with any other use of the
459
+ # same secret. The version is what lets a future v2 add an org identity without
460
+ # silently rederiving every existing key: v1 lessons keep validating under v1.
461
+ #
462
+ # No org id in v1 — none exists in this system, and inventing one for an
463
+ # unwritten consumer is the mistake ecosystem-si6 avoided.
464
+ #
465
+ # Usage: librarian_author_key <private|org|public>
466
+ librarian_author_key() {
467
+ local visibility="${1:-}"
468
+ case "$visibility" in
469
+ private|org|public) ;;
470
+ *)
471
+ printf 'author-key: unrecognized visibility: %s\n' "$visibility" >&2
472
+ return 1
473
+ ;;
474
+ esac
475
+
476
+ command -v openssl >/dev/null 2>&1 || {
477
+ printf 'author-key: openssl is required to derive a key.\n' >&2
478
+ return 1
479
+ }
480
+
481
+ local secret
482
+ secret=$(librarian_author_secret_ensure) || return 1
483
+
484
+ local digest
485
+ digest=$(printf '%s' "onlooker.author.v1:${visibility}" \
486
+ | openssl dgst -sha256 -hmac "$secret" -r 2>/dev/null \
487
+ | cut -d' ' -f1) || {
488
+ printf 'author-key: HMAC failed.\n' >&2
489
+ return 1
490
+ }
491
+
492
+ # 64 hex chars in, 32 out. Truncating an HMAC is standard; 128 bits is
493
+ # ample for a collision-resistant pseudonymous identifier.
494
+ [[ "${#digest}" -eq 64 ]] || {
495
+ printf 'author-key: unexpected digest width %d; refusing.\n' "${#digest}" >&2
496
+ return 1
497
+ }
498
+ printf '%s' "${digest:0:32}"
499
+ }
500
+ ```
501
+
502
+ - [ ] **Step 5: Run the tests to verify they pass**
503
+
504
+ Run: `bats test/bats/librarian-author-key.bats`
505
+ Expected: PASS, 17/17 (8 from Task 1, 9 new).
506
+
507
+ - [ ] **Step 6: Prove the golden vector discriminates**
508
+
509
+ In a throwaway `git worktree` only. Three separate injections, running the
510
+ suite after each and reverting between:
511
+
512
+ ```bash
513
+ git worktree add /tmp/ak-verify2 HEAD
514
+ # (a) change the domain tag to "onlooker.author.v2:"
515
+ # (b) change the truncation from :0:32 to :0:64
516
+ # (c) swap the HMAC arguments (hash the secret keyed by the message)
517
+ git worktree remove --force /tmp/ak-verify2
518
+ ```
519
+
520
+ Expected: the golden-vector test FAILS under each. Report all three results
521
+ separately. If any injection leaves it green, the vector is not pinning the
522
+ derivation — say so rather than reporting it as proven.
523
+
524
+ - [ ] **Step 7: Close the doc's open question**
525
+
526
+ `docs/lesson-promotion-pipeline.md` has an "Open questions" section whose
527
+ second bullet still reads that `author_key` derivation is unsettled ("Where
528
+ does `user_secret` live, and how is it created on first use?"). Replace that
529
+ bullet with a one-line statement of the answer and a pointer to
530
+ `docs/superpowers/specs/2026-08-12-author-key-design.md`.
531
+
532
+ Leave the other open questions alone.
533
+
534
+ - [ ] **Step 8: Lint, full suite, and commit**
535
+
536
+ ```bash
537
+ npm run lint:check
538
+ shellcheck -S error -x plugins/librarian/scripts/lib/librarian-author-key.sh
539
+ npm run test:ci
540
+ git add plugins/librarian/scripts/lib/librarian-author-key.sh \
541
+ test/bats/librarian-author-key.bats \
542
+ docs/lesson-promotion-pipeline.md
543
+ ```
544
+
545
+ Read each exit code from `$?` directly — never through a pipe, which reports
546
+ the pipe's last command.
547
+
548
+ Commit subject: `feat(librarian): derive an author key per visibility scope :closed_lock_with_key:`
549
+
550
+ ---
551
+
552
+ ## Spec coverage
553
+
554
+ | Spec requirement | Task |
555
+ |---|---|
556
+ | Secret at `$ONLOOKER_DIR/author/user_secret`, not project-keyed | 1 |
557
+ | `openssl rand -hex 32`, never `$RANDOM` | 1 |
558
+ | Created `0600`; loose permissions tightened with a warning | 1 |
559
+ | Never regenerated when present | 1 |
560
+ | Empty or short secret refused | 1, 2 |
561
+ | `HMAC(secret, "onlooker.author.v1:<visibility>")`, truncated to 32 hex | 2 |
562
+ | All three visibilities derive a key | 2 |
563
+ | Failure returns non-zero with empty stdout and a stderr reason | 1, 2 |
564
+ | Golden vector, determinism, scope separation, secret separation | 2 |
565
+ | Format is exactly 32 lowercase hex | 2 |
566
+ | Key is not the secret | 2 |
567
+ | No `$RANDOM` in the lib | 1 |