@onlooker-community/ecosystem 0.43.4 → 0.43.6

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 (66) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.release-please-manifest.json +17 -17
  3. package/AGENTS.md +5 -0
  4. package/CHANGELOG.md +16 -0
  5. package/CLAUDE.md +5 -0
  6. package/docs/architecture.md +2 -2
  7. package/docs/lesson-promotion-pipeline.md +9 -1
  8. package/package.json +1 -1
  9. package/plugins/archivist/.claude-plugin/plugin.json +1 -1
  10. package/plugins/archivist/CHANGELOG.md +7 -0
  11. package/plugins/archivist/scripts/lib/archivist-config.sh +6 -2
  12. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  13. package/plugins/assayer/CHANGELOG.md +7 -0
  14. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -2
  15. package/plugins/bursar/.claude-plugin/plugin.json +1 -1
  16. package/plugins/bursar/CHANGELOG.md +7 -0
  17. package/plugins/bursar/scripts/lib/bursar-config.sh +6 -2
  18. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  19. package/plugins/cartographer/CHANGELOG.md +7 -0
  20. package/plugins/cartographer/scripts/lib/cartographer-config.sh +6 -2
  21. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  22. package/plugins/compass/CHANGELOG.md +7 -0
  23. package/plugins/compass/scripts/lib/compass-config.sh +6 -2
  24. package/plugins/counsel/.claude-plugin/plugin.json +1 -1
  25. package/plugins/counsel/CHANGELOG.md +7 -0
  26. package/plugins/counsel/scripts/lib/counsel-config.sh +6 -2
  27. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  28. package/plugins/curator/CHANGELOG.md +7 -0
  29. package/plugins/curator/scripts/lib/curator-config.sh +6 -2
  30. package/plugins/echo/.claude-plugin/plugin.json +1 -1
  31. package/plugins/echo/CHANGELOG.md +7 -0
  32. package/plugins/echo/scripts/lib/echo-config.sh +6 -2
  33. package/plugins/governor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/governor/CHANGELOG.md +7 -0
  35. package/plugins/governor/scripts/lib/governor-config.sh +6 -2
  36. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  37. package/plugins/historian/CHANGELOG.md +7 -0
  38. package/plugins/historian/scripts/lib/historian-config.sh +6 -2
  39. package/plugins/inspector/.claude-plugin/plugin.json +1 -1
  40. package/plugins/inspector/CHANGELOG.md +14 -0
  41. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +4 -8
  42. package/plugins/inspector/scripts/lib/inspector-config.sh +6 -2
  43. package/plugins/inspector/scripts/lib/inspector-project-key.sh +59 -2
  44. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  45. package/plugins/librarian/CHANGELOG.md +8 -0
  46. package/plugins/librarian/scripts/lib/librarian-config.sh +6 -2
  47. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +22 -2
  48. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +19 -1
  49. package/plugins/lineage/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lineage/CHANGELOG.md +7 -0
  51. package/plugins/lineage/scripts/lib/lineage-config.sh +6 -2
  52. package/plugins/scribe/.claude-plugin/plugin.json +1 -1
  53. package/plugins/scribe/CHANGELOG.md +7 -0
  54. package/plugins/scribe/scripts/lib/scribe-config.sh +6 -2
  55. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  56. package/plugins/tribunal/CHANGELOG.md +7 -0
  57. package/plugins/tribunal/scripts/lib/tribunal-config.sh +6 -2
  58. package/plugins/warden/.claude-plugin/plugin.json +1 -1
  59. package/plugins/warden/CHANGELOG.md +7 -0
  60. package/plugins/warden/scripts/lib/warden-config.sh +6 -2
  61. package/scripts/lib/config-loader.sh +15 -2
  62. package/scripts/lib/validate-path.sh +29 -38
  63. package/test/bats/config-lib-self-locating.bats +124 -0
  64. package/test/bats/inspector-post-write-hook.bats +92 -0
  65. package/test/bats/librarian-lesson-promote.bats +89 -3
  66. package/test/bats/validate-path.bats +46 -0
@@ -155,3 +155,95 @@ EOF
155
155
  # The last argv slot should now hold the resolved touched file path.
156
156
  [[ "$(echo "$argv" | jq -r '.[-1]')" == *"src/sample.ts" ]]
157
157
  }
158
+
159
+ # ── path canonicalization (ecosystem-foi) ────────────────────────────────────
160
+ #
161
+ # The hook decides "is this file inside the repo?" by prefix-matching the
162
+ # canonicalized file path against the repo root. Those two paths used to be
163
+ # canonicalized by INDEPENDENT mechanisms — realpath for the file, `git
164
+ # rev-parse --show-toplevel` for the root — each with its own fallback. On
165
+ # macOS /var is a symlink to /private/var, so whenever either mechanism fell
166
+ # back, the two sides landed in different namespaces, the prefix match failed,
167
+ # and the hook reported not_in_repo for a file plainly inside the repo: exit 0,
168
+ # no agent output, one misleading .skipped event.
169
+ #
170
+ # That is the exact signature of the intermittent failure in ecosystem-foi,
171
+ # where the hook exited 0 but emitted no file header. These tests force each
172
+ # fallback deterministically instead of waiting for the flake.
173
+
174
+ _stub_dir() {
175
+ local d="${BATS_TEST_TMPDIR}/stub-$1"
176
+ mkdir -p "$d"
177
+ printf '%s' "$d"
178
+ }
179
+
180
+ _failing_check_settings() {
181
+ cat <<'EOF' | _settings
182
+ {"inspector":{"checks":{".ts":[
183
+ {"name":"broken","kind":"lint","argv":["sh","-c","echo 'src/sample.ts:1:1 - Bad'; exit 2"]}
184
+ ]}}}
185
+ EOF
186
+ }
187
+
188
+ @test "the repo-root fallback still finds a file inside the repo when git fails" {
189
+ # git rev-parse failing sends inspector_project_repo_root to its $cwd
190
+ # fallback, which is the caller-supplied (unresolved) path.
191
+ _failing_check_settings
192
+ local stub
193
+ stub=$(_stub_dir git)
194
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/git"
195
+ chmod +x "${stub}/git"
196
+
197
+ run bash -c "printf '%s' '$(_input)' | PATH='${stub}:$PATH' ONLOOKER_DIR='$ONLOOKER_DIR' CLAUDE_PLUGIN_ROOT='$PLUGIN_ROOT' bash '$HOOK'"
198
+ [ "$status" -eq 0 ] || return 1
199
+ [[ "$output" == *"inspector: src/sample.ts"* ]] || return 1
200
+ [ "$(_event_count inspector.check.failed)" = "1" ]
201
+ }
202
+
203
+ @test "the file is still found inside the repo when realpath is unavailable" {
204
+ # realpath failing leaves the file path unresolved while the repo root,
205
+ # coming from git, is resolved — the same mismatch from the other side.
206
+ _failing_check_settings
207
+ local stub
208
+ stub=$(_stub_dir realpath)
209
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/realpath"
210
+ chmod +x "${stub}/realpath"
211
+
212
+ run bash -c "printf '%s' '$(_input)' | PATH='${stub}:$PATH' ONLOOKER_DIR='$ONLOOKER_DIR' CLAUDE_PLUGIN_ROOT='$PLUGIN_ROOT' bash '$HOOK'"
213
+ [ "$status" -eq 0 ] || return 1
214
+ [[ "$output" == *"inspector: src/sample.ts"* ]] || return 1
215
+ [ "$(_event_count inspector.check.failed)" = "1" ]
216
+ }
217
+
218
+ @test "a file genuinely outside the repo still reports not_in_repo" {
219
+ # The containment check must still say no when the answer really is no —
220
+ # canonicalizing both sides must not turn it into a rubber stamp.
221
+ _failing_check_settings
222
+ local outside="${BATS_TEST_TMPDIR}/outside.ts"
223
+ printf 'x\n' >"$outside"
224
+
225
+ run _run_hook "$(_input "$REPO" "Edit" "$outside")"
226
+ [ "$status" -eq 0 ] || return 1
227
+ [ -z "$output" ] || return 1
228
+ [ "$(jq -r 'select(.event_type=="inspector.check.skipped").payload.reason' "$ONLOOKER_EVENTS_LOG")" = "not_in_repo" ]
229
+ }
230
+
231
+ @test "canonicalization survives with neither realpath nor readlink -f" {
232
+ # The last resort is `cd` + `pwd -P`, a builtin. Without it, a box with no
233
+ # GNU coreutils would hit the same namespace mismatch through a third door,
234
+ # and no other test forces this branch.
235
+ _failing_check_settings
236
+ local stub
237
+ stub=$(_stub_dir noresolvers)
238
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/realpath"
239
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/readlink"
240
+ chmod +x "${stub}/realpath" "${stub}/readlink"
241
+
242
+ run bash -c "printf '%s' '$(_input)' | PATH='${stub}:$PATH' ONLOOKER_DIR='$ONLOOKER_DIR' CLAUDE_PLUGIN_ROOT='$PLUGIN_ROOT' bash '$HOOK'"
243
+ [ "$status" -eq 0 ] || return 1
244
+ [[ "$output" == *"inspector: src/sample.ts"* ]] || return 1
245
+ # file_path_relative must still be repo-relative, not an absolute path —
246
+ # that is what proves the prefix strip found the root, rather than the
247
+ # header printing off a coincidentally-passing comparison.
248
+ [ "$(jq -r 'select(.event_type=="inspector.check.failed").payload.file_path_relative' "$ONLOOKER_EVENTS_LOG")" = "src/sample.ts" ]
249
+ }
@@ -47,14 +47,20 @@ STUB
47
47
  _dir() { printf '%s' "$(librarian_lessons_dir "$PROJECT_KEY")"; }
48
48
 
49
49
  # A judged proposal. $1 = id, $2 = visibility, $3 = status,
50
- # $4 = verdict judges JSON array.
50
+ # $4 = verdict judges JSON array, $5 = artifact_id (defaults to "art-<id>").
51
+ #
52
+ # $5 exists so a test can seed two DISTINCT proposals that share one
53
+ # artifact_id. The default derives the artifact from the id, which makes that
54
+ # collision unreachable — and the collision is the whole subject of the
55
+ # declined-ledger guard tests below (ecosystem-bkj).
51
56
  _seed_judged() {
52
57
  local id="$1" visibility="$2" status="$3" judges="$4"
58
+ local artifact_id="${5:-art-$1}"
53
59
  local now
54
60
  now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
55
61
  jq -n --arg id "$id" --arg v "$visibility" --arg s "$status" \
56
- --arg t "$now" --argjson j "$judges" \
57
- '{ id: $id, artifact_id: "art-\($id)", status: $s, visibility: $v,
62
+ --arg t "$now" --argjson j "$judges" --arg aid "$artifact_id" \
63
+ '{ id: $id, artifact_id: $aid, status: $s, visibility: $v,
58
64
  confirmed_at: $t, judged_at: $t,
59
65
  candidate: { claim: "Prefer jq -c for compact output",
60
66
  rationale: "Readable diffs",
@@ -550,3 +556,83 @@ _promote_refuses_shape() {
550
556
  [ "$status" -eq 0 ]
551
557
  [ -f "$(_dir)/approved/skill01.json" ]
552
558
  }
559
+
560
+ # ── declined-ledger guard: per-proposal, not per-artifact (ecosystem-bkj) ─────
561
+ #
562
+ # The guard that stops a double-write into declined.jsonl used to key on
563
+ # artifact_id. That cannot tell "this artifact was already declined" apart from
564
+ # "a DIFFERENT proposal for the same artifact was already declined", so the
565
+ # second of two colliding proposals found a matching row, skipped its append,
566
+ # and returned 0 — both proposals stamped, one verdict on disk. The inverse of
567
+ # the double-write the guard exists to prevent, and still data loss in the
568
+ # per-judge detail declined.jsonl carries for rubric tuning.
569
+ #
570
+ # The approved branch never had this: its guard is approved/<lesson_id>.json,
571
+ # which is per-proposal by construction. These tests hold the declined branch
572
+ # to the same standard.
573
+
574
+ @test "two rejected proposals sharing an artifact_id both reach the ledger" {
575
+ _seed_judged "dupa" "public" "rejected" "$(_split)" "art-shared"
576
+ _seed_judged "dupb" "public" "rejected" "$(_split)" "art-shared"
577
+
578
+ run librarian_lesson_promote "$PROJECT_KEY" "dupa"
579
+ [ "$status" -eq 0 ] || return 1
580
+ run librarian_lesson_promote "$PROJECT_KEY" "dupb"
581
+ [ "$status" -eq 0 ] || return 1
582
+
583
+ # Two distinct proposals were judged and rejected, so two verdicts are
584
+ # owed. Under the artifact_id-keyed guard this was 1.
585
+ [ "$(grep -c 'art-shared' "$(_dir)/declined.jsonl")" -eq 2 ] || return 1
586
+
587
+ # And they are distinguishable — a row per proposal, not two identical
588
+ # rows, which is what makes the ledger usable for rubric tuning.
589
+ local ids
590
+ ids=$(jq -r '.lesson_id' "$(_dir)/declined.jsonl" | sort | tr '\n' ' ')
591
+ [ "$ids" = "dupa dupb " ]
592
+ }
593
+
594
+ @test "the declined guard still blocks a true double-write for one proposal" {
595
+ # The behavior the guard was added for, which the re-key must not lose:
596
+ # a stamp that fails AFTER the declined row lands, then a standalone
597
+ # re-run. Same chmod shape as the artifact_id-keyed test above.
598
+ _seed_judged "rekey01" "public" "rejected" "$(_split)"
599
+
600
+ chmod 0500 "$(_dir)/proposals"
601
+ run --separate-stderr librarian_lesson_promote "$PROJECT_KEY" "rekey01"
602
+ chmod 0700 "$(_dir)/proposals"
603
+ [ "$status" -ne 0 ] || return 1
604
+ [ "$(grep -c 'art-rekey01' "$(_dir)/declined.jsonl")" -eq 1 ] || return 1
605
+
606
+ run librarian_lesson_promote "$PROJECT_KEY" "rekey01"
607
+ [ "$status" -eq 0 ] || return 1
608
+ [ "$(grep -c 'art-rekey01' "$(_dir)/declined.jsonl")" -eq 1 ]
609
+ }
610
+
611
+ @test "a legacy declined row without lesson_id still dedups by artifact_id" {
612
+ # Rows written before this change carry no lesson_id. Keying purely on
613
+ # lesson_id would make them match nothing, so an unstamped proposal left
614
+ # over from a historical stamp failure would double-write on re-run —
615
+ # trading this bug for the one the guard was built to stop. The fallback
616
+ # keeps the old artifact_id behavior for exactly those rows.
617
+ _seed_judged "legacy01" "public" "rejected" "$(_split)"
618
+ printf '%s\n' '{"artifact_id":"art-legacy01","reason":"rejected","detail":null,"declined_at":"2020-01-01T00:00:00Z"}' \
619
+ >> "$(_dir)/declined.jsonl"
620
+
621
+ run librarian_lesson_promote "$PROJECT_KEY" "legacy01"
622
+ [ "$status" -eq 0 ] || return 1
623
+ [ "$(grep -c 'art-legacy01' "$(_dir)/declined.jsonl")" -eq 1 ]
624
+ }
625
+
626
+ @test "librarian_lesson_seen still matches on artifact_id, not lesson_id" {
627
+ # seen() answers "was this ARTIFACT already handled?", which is correctly
628
+ # artifact-scoped — it is what stops transform from re-proposing. Adding
629
+ # lesson_id to the row must not perturb it.
630
+ _seed_judged "seen01" "public" "rejected" "$(_split)" "art-seen-shared"
631
+ run librarian_lesson_promote "$PROJECT_KEY" "seen01"
632
+ [ "$status" -eq 0 ] || return 1
633
+
634
+ run librarian_lesson_seen "$PROJECT_KEY" "art-seen-shared"
635
+ [ "$status" -eq 0 ] || return 1
636
+ run librarian_lesson_seen "$PROJECT_KEY" "art-never-declined"
637
+ [ "$status" -ne 0 ]
638
+ }
@@ -108,6 +108,52 @@ setup() {
108
108
  >/dev/null
109
109
  }
110
110
 
111
+ @test "safe_emit's line validates as a canonical envelope, not just a lookalike" {
112
+ # The assertion above spot-checks four fields. A hand-built envelope can pass
113
+ # that while still being rejected by the schema, which is exactly how the
114
+ # fallback below stayed broken. Validate the whole line instead.
115
+ export _HOOK_SESSION_ID="emit-session"
116
+ safe_emit "tool.file.read" '{"path":"/tmp/example.txt","read_mode":"full"}'
117
+ tail -n 1 "$ONLOOKER_EVENTS_LOG" \
118
+ | ONLOOKER_DIR="$ONLOOKER_DIR" node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
119
+ }
120
+
121
+ @test "safe_emit writes nothing when the emit script is missing" {
122
+ # The degraded path. It used to hand-build an envelope with jq that omitted
123
+ # every required id/schema_version/runtime/machine_id/sequence field and added
124
+ # four the schema forbids (hook_type, tool_name, turn, tool_call_seq), on an
125
+ # envelope that is additionalProperties:false. An unparseable line on the bus
126
+ # is worse than a missing one, so this path now writes nothing at all.
127
+ # See ecosystem-0tm.
128
+ export _HOOK_SESSION_ID="emit-session"
129
+ export ONLOOKER_HOOK_TYPE="PreToolUse"
130
+ export ONLOOKER_TOOL_NAME="Read"
131
+ export ONLOOKER_TURN_NUMBER=4
132
+ export ONLOOKER_TURN_TOOL_SEQ=2
133
+ export ONLOOKER_EMIT="${BATS_TEST_TMPDIR}/no-such-emit.sh"
134
+
135
+ : >"$ONLOOKER_EVENTS_LOG"
136
+ run safe_emit "tool.file.read" '{"path":"/tmp/example.txt","read_mode":"full"}'
137
+ [ "$status" -ne 0 ] || return 1
138
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
139
+ }
140
+
141
+ @test "safe_emit's failure to emit never aborts the calling hook" {
142
+ # safe_emit is substrate: returning non-zero must be absorbable by a caller
143
+ # running under `set -e`, the shape every hook uses. Assert the caller
144
+ # survives and reaches its next statement.
145
+ export ONLOOKER_EMIT="${BATS_TEST_TMPDIR}/no-such-emit.sh"
146
+ run bash -c "
147
+ set -euo pipefail
148
+ source '${REPO_ROOT}/scripts/lib/validate-path.sh'
149
+ export ONLOOKER_EMIT='${BATS_TEST_TMPDIR}/no-such-emit.sh'
150
+ safe_emit 'tool.file.read' '{\"path\":\"/x\",\"read_mode\":\"full\"}' || true
151
+ echo REACHED
152
+ "
153
+ [ "$status" -eq 0 ] || return 1
154
+ [[ "$output" == *"REACHED"* ]]
155
+ }
156
+
111
157
  # ----------------------------------------------------------------------------
112
158
  # Hook health instrumentation: hook_register / hook_success / hook_failure
113
159
  # ----------------------------------------------------------------------------