@onlooker-community/ecosystem 0.43.3 → 0.43.5

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 (65) 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 +15 -0
  5. package/CLAUDE.md +5 -0
  6. package/docs/architecture.md +3 -1
  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 +7 -0
  41. package/plugins/inspector/scripts/lib/inspector-config.sh +6 -2
  42. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  43. package/plugins/librarian/CHANGELOG.md +8 -0
  44. package/plugins/librarian/scripts/lib/librarian-config.sh +6 -2
  45. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +22 -2
  46. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +19 -1
  47. package/plugins/lineage/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lineage/CHANGELOG.md +7 -0
  49. package/plugins/lineage/scripts/lib/lineage-config.sh +6 -2
  50. package/plugins/scribe/.claude-plugin/plugin.json +1 -1
  51. package/plugins/scribe/CHANGELOG.md +7 -0
  52. package/plugins/scribe/scripts/lib/scribe-config.sh +6 -2
  53. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  54. package/plugins/tribunal/CHANGELOG.md +7 -0
  55. package/plugins/tribunal/scripts/lib/tribunal-config.sh +6 -2
  56. package/plugins/warden/.claude-plugin/plugin.json +1 -1
  57. package/plugins/warden/CHANGELOG.md +7 -0
  58. package/plugins/warden/scripts/lib/warden-config.sh +6 -2
  59. package/scripts/hooks/prompt-rule-injector.sh +10 -2
  60. package/scripts/lib/config-loader.sh +15 -2
  61. package/scripts/lib/prompt-rules.sh +43 -12
  62. package/test/bats/config-lib-self-locating.bats +124 -0
  63. package/test/bats/librarian-lesson-promote.bats +89 -3
  64. package/test/bats/prompt-rules.bats +61 -24
  65. package/test/bus-coverage.json +2 -2
@@ -16,8 +16,12 @@
16
16
  # Settings overlay only touches the `librarian.*` subtree of settings.json so
17
17
  # it coexists with other plugins' configuration.
18
18
 
19
- # shellcheck source=../../../scripts/lib/config-loader.sh
20
- source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
19
+ # Resolve the shared loader from this file's own location. $PLUGIN_ROOT is
20
+ # whatever the sourcing scope happened to set, so a sub-shell that inherits
21
+ # CLAUDE_PLUGIN_ROOT but not PLUGIN_ROOT would lose every accessor silently.
22
+ _LIBRARIAN_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ # shellcheck source=scripts/lib/config-loader.sh
24
+ source "${_LIBRARIAN_CONFIG_LIB_DIR}/../../../../scripts/lib/config-loader.sh"
21
25
 
22
26
  _LIBRARIAN_CONFIG="{}"
23
27
 
@@ -183,19 +183,39 @@ librarian_lesson_promote() {
183
183
  # declined.jsonl, double-counting a rejected artifact in the
184
184
  # rubric-tuning signal the ledger exists to carry.
185
185
  #
186
+ # Keyed on lesson_id, not artifact_id. The approved branch's guard is
187
+ # approved/<lesson_id>.json — per-proposal by construction — and this
188
+ # one has to match it. Keyed on artifact_id it could not tell "this
189
+ # artifact was already declined" from "a DIFFERENT proposal for the
190
+ # same artifact was already declined", so the second of two colliding
191
+ # proposals skipped its append and returned 0: both stamped, one
192
+ # verdict on disk. That is the inverse of the double-write this guard
193
+ # exists to stop, and still loses the per-judge detail (ecosystem-bkj).
194
+ #
195
+ # The artifact_id clause is the fallback for rows that legitimately
196
+ # carry no lesson_id: those written before this change, and those the
197
+ # transform writes for an artifact rejected before any proposal
198
+ # existed. Dropping it would leave an unstamped pre-change proposal
199
+ # double-writing on re-run — trading this bug for the older one.
200
+ #
186
201
  # Same scan shape as librarian_lesson_seen: -R/fromjson? survives a
187
202
  # truncated trailing line from a process killed mid-append, and
188
203
  # `objects` stops a valid-but-non-object line from erroring the whole
189
204
  # invocation.
190
205
  already_declined=false
191
206
  if [[ -f "$declined_path" ]] \
192
- && jq -Re --arg a "$artifact_id" 'fromjson? | objects | select(.artifact_id == $a)' \
207
+ && jq -Re --arg a "$artifact_id" --arg l "$lesson_id" \
208
+ 'fromjson? | objects
209
+ | select(
210
+ ($l != "" and .lesson_id == $l)
211
+ or ((.lesson_id // null) == null and .artifact_id == $a)
212
+ )' \
193
213
  "$declined_path" >/dev/null 2>&1; then
194
214
  already_declined=true
195
215
  fi
196
216
 
197
217
  if [[ "$already_declined" == false ]]; then
198
- librarian_lesson_append_declined "$key" "$artifact_id" "$reason" "" "$verdict" || {
218
+ librarian_lesson_append_declined "$key" "$artifact_id" "$reason" "" "$verdict" "$lesson_id" || {
199
219
  printf 'Lesson %s: cannot append to the declined ledger.\n' "$lesson_id" >&2
200
220
  return 1
201
221
  }
@@ -83,13 +83,27 @@ librarian_lesson_write_proposal() {
83
83
  # written by the transform have no verdict and simply lack the key — a format
84
84
  # failure has no jury.
85
85
  #
86
- # Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail] [verdict_json]
86
+ # `lesson_id` names the PROPOSAL this verdict came from, and is null when there
87
+ # is no proposal to name. The transform declines an artifact before any proposal
88
+ # exists (transform_invalid, schema_invalid, and friends), so its rows carry
89
+ # null here and are identified by artifact_id alone.
90
+ #
91
+ # It exists so promote's double-write guard can key on the proposal rather than
92
+ # the artifact. Keyed on artifact_id, that guard could not tell "this artifact
93
+ # was already declined" from "a DIFFERENT proposal for the same artifact was
94
+ # already declined", and silently dropped the second verdict — see
95
+ # ecosystem-bkj. Note this is the opposite of what librarian_lesson_seen wants:
96
+ # that function asks "was this ARTIFACT already handled?" and correctly stays
97
+ # keyed on artifact_id.
98
+ #
99
+ # Usage: librarian_lesson_append_declined <key> <artifact_id> <reason> [detail] [verdict_json] [lesson_id]
87
100
  librarian_lesson_append_declined() {
88
101
  local key="$1"
89
102
  local artifact_id="$2"
90
103
  local reason="$3"
91
104
  local detail="${4:-}"
92
105
  local verdict="${5:-}"
106
+ local lesson_id="${6:-}"
93
107
  [[ -z "$key" || -z "$artifact_id" || -z "$reason" ]] && return 1
94
108
 
95
109
  librarian_lesson_storage_init "$key" || return 1
@@ -99,12 +113,14 @@ librarian_lesson_append_declined() {
99
113
  if [[ -n "$verdict" ]]; then
100
114
  line=$(jq -cn \
101
115
  --arg artifact_id "$artifact_id" \
116
+ --arg lesson_id "$lesson_id" \
102
117
  --arg reason "$reason" \
103
118
  --arg detail "$detail" \
104
119
  --arg at "$now" \
105
120
  --argjson verdict "$verdict" \
106
121
  '{
107
122
  artifact_id: $artifact_id,
123
+ lesson_id: (if $lesson_id == "" then null else $lesson_id end),
108
124
  reason: $reason,
109
125
  detail: (if $detail == "" then null else $detail end),
110
126
  declined_at: $at,
@@ -113,11 +129,13 @@ librarian_lesson_append_declined() {
113
129
  else
114
130
  line=$(jq -cn \
115
131
  --arg artifact_id "$artifact_id" \
132
+ --arg lesson_id "$lesson_id" \
116
133
  --arg reason "$reason" \
117
134
  --arg detail "$detail" \
118
135
  --arg at "$now" \
119
136
  '{
120
137
  artifact_id: $artifact_id,
138
+ lesson_id: (if $lesson_id == "" then null else $lesson_id end),
121
139
  reason: $reason,
122
140
  detail: (if $detail == "" then null else $detail end),
123
141
  declined_at: $at
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lineage",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Per-change provenance for the Onlooker ecosystem. Records session/turn metadata plus a secret-redacted, size-capped snippet for every Edit/Write/MultiEdit at PostToolUse, then answers \"why does this line exist?\" via /lineage by joining change records to historian-preserved transcripts. Emits lineage.* events for audit. Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.3](https://github.com/onlooker-community/ecosystem/compare/lineage-v0.2.2...lineage-v0.2.3) (2026-08-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **config:** let every config lib find the loader from its own path :broom: ([#198](https://github.com/onlooker-community/ecosystem/issues/198)) ([249fe41](https://github.com/onlooker-community/ecosystem/commit/249fe41bf191db48239c2028ba77a10b3dcb03af))
9
+
3
10
  ## [0.2.2](https://github.com/onlooker-community/ecosystem/compare/lineage-v0.2.1...lineage-v0.2.2) (2026-08-02)
4
11
 
5
12
 
@@ -13,8 +13,12 @@
13
13
  # lineage_config_get <jq-path> # echoes string value (empty if unset)
14
14
  # lineage_config_get_json <jq-path> # echoes JSON value (null if unset)
15
15
 
16
- # shellcheck source=../../../scripts/lib/config-loader.sh
17
- source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
16
+ # Resolve the shared loader from this file's own location. $PLUGIN_ROOT is
17
+ # whatever the sourcing scope happened to set, so a sub-shell that inherits
18
+ # CLAUDE_PLUGIN_ROOT but not PLUGIN_ROOT would lose every accessor silently.
19
+ _LINEAGE_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ # shellcheck source=scripts/lib/config-loader.sh
21
+ source "${_LINEAGE_CONFIG_LIB_DIR}/../../../../scripts/lib/config-loader.sh"
18
22
 
19
23
  _lineage_CONFIG="{}"
20
24
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scribe",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Intent documentation from agent activity. Captures why changes were made — problem context, decisions, tradeoffs — and distills them into readable artifacts at session end.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.3](https://github.com/onlooker-community/ecosystem/compare/scribe-v0.4.2...scribe-v0.4.3) (2026-08-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **config:** let every config lib find the loader from its own path :broom: ([#198](https://github.com/onlooker-community/ecosystem/issues/198)) ([249fe41](https://github.com/onlooker-community/ecosystem/commit/249fe41bf191db48239c2028ba77a10b3dcb03af))
9
+
3
10
  ## [0.4.2](https://github.com/onlooker-community/ecosystem/compare/scribe-v0.4.1...scribe-v0.4.2) (2026-08-02)
4
11
 
5
12
 
@@ -13,8 +13,12 @@
13
13
  # scribe_config_get <jq-path> # echoes string value (empty if unset)
14
14
  # scribe_config_get_json <jq-path> # echoes JSON value (null if unset)
15
15
 
16
- # shellcheck source=../../../scripts/lib/config-loader.sh
17
- source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
16
+ # Resolve the shared loader from this file's own location. $PLUGIN_ROOT is
17
+ # whatever the sourcing scope happened to set, so a sub-shell that inherits
18
+ # CLAUDE_PLUGIN_ROOT but not PLUGIN_ROOT would lose every accessor silently.
19
+ _SCRIBE_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ # shellcheck source=scripts/lib/config-loader.sh
21
+ source "${_SCRIBE_CONFIG_LIB_DIR}/../../../../scripts/lib/config-loader.sh"
18
22
 
19
23
  _scribe_CONFIG="{}"
20
24
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tribunal",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Multi-agent execution with LLM-as-a-Judge quality gates. An Actor performs work; a jury of typed Judges scores it against a project-overridable rubric; a Meta-Judge reviews the jury for bias; the gate decides accept, retry, or exhaust. Grounded in LLM-as-a-Judge (Zheng et al. 2023) and LLM-as-a-Meta-Judge (Wu et al. 2024). Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.8](https://github.com/onlooker-community/ecosystem/compare/tribunal-v1.2.7...tribunal-v1.2.8) (2026-08-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **config:** let every config lib find the loader from its own path :broom: ([#198](https://github.com/onlooker-community/ecosystem/issues/198)) ([249fe41](https://github.com/onlooker-community/ecosystem/commit/249fe41bf191db48239c2028ba77a10b3dcb03af))
9
+
3
10
  ## [1.2.7](https://github.com/onlooker-community/ecosystem/compare/tribunal-v1.2.6...tribunal-v1.2.7) (2026-08-16)
4
11
 
5
12
 
@@ -13,8 +13,12 @@
13
13
  # tribunal_config_get <jq-path> # echoes string value (empty if unset)
14
14
  # tribunal_config_get_json <jq-path> # echoes JSON value (null if unset)
15
15
 
16
- # shellcheck source=../../../scripts/lib/config-loader.sh
17
- source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
16
+ # Resolve the shared loader from this file's own location. $PLUGIN_ROOT is
17
+ # whatever the sourcing scope happened to set, so a sub-shell that inherits
18
+ # CLAUDE_PLUGIN_ROOT but not PLUGIN_ROOT would lose every accessor silently.
19
+ _TRIBUNAL_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ # shellcheck source=scripts/lib/config-loader.sh
21
+ source "${_TRIBUNAL_CONFIG_LIB_DIR}/../../../../scripts/lib/config-loader.sh"
18
22
 
19
23
  _TRIBUNAL_CONFIG="{}"
20
24
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "warden",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Untrusted-content gate. Scans content flowing in through WebFetch and Read for prompt-injection patterns, and when a threat is detected closes a session-scoped gate that blocks Write, Edit, and Bash until the user explicitly clears it. Grounded in Meta's Agents Rule of Two: an agent should hold no more than two of {private data, external actions, untrusted content} at once — warden removes the external-actions property while untrusted content is in play. Builds on the Onlooker ecosystem plugin.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2](https://github.com/onlooker-community/ecosystem/compare/warden-v0.3.1...warden-v0.3.2) (2026-08-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **config:** let every config lib find the loader from its own path :broom: ([#198](https://github.com/onlooker-community/ecosystem/issues/198)) ([249fe41](https://github.com/onlooker-community/ecosystem/commit/249fe41bf191db48239c2028ba77a10b3dcb03af))
9
+
3
10
  ## [0.3.1](https://github.com/onlooker-community/ecosystem/compare/warden-v0.3.0...warden-v0.3.1) (2026-08-01)
4
11
 
5
12
 
@@ -13,8 +13,12 @@
13
13
  # warden_config_get <jq-path> # echoes string value (empty if unset)
14
14
  # warden_config_get_json <jq-path> # echoes JSON value (null if unset)
15
15
 
16
- # shellcheck source=../../../scripts/lib/config-loader.sh
17
- source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
16
+ # Resolve the shared loader from this file's own location. $PLUGIN_ROOT is
17
+ # whatever the sourcing scope happened to set, so a sub-shell that inherits
18
+ # CLAUDE_PLUGIN_ROOT but not PLUGIN_ROOT would lose every accessor silently.
19
+ _WARDEN_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ # shellcheck source=scripts/lib/config-loader.sh
21
+ source "${_WARDEN_CONFIG_LIB_DIR}/../../../../scripts/lib/config-loader.sh"
18
22
 
19
23
  _warden_CONFIG="{}"
20
24
 
@@ -6,9 +6,11 @@
6
6
  # and injects guidance for rules whose POSIX-ERE pattern matches the prompt.
7
7
  # Each rule fires at most once per session per rule_id.
8
8
  #
9
- # Emits canonical-ish events to ~/.onlooker/logs/onlooker-events.jsonl:
9
+ # Emits canonical events to ~/.onlooker/logs/onlooker-events.jsonl:
10
10
  # prompt_rule.matched — every match (including subsequent matches in-session)
11
11
  # prompt_rule.applied — only when guidance is actually injected
12
+ # Both route through onlooker-event.mjs and are schema-validated; a rejected
13
+ # event is dropped rather than written, and never blocks prompt submission.
12
14
  #
13
15
  # Usage:
14
16
  # echo "$INPUT" | prompt-rule-injector.sh
@@ -74,8 +76,14 @@ while IFS= read -r rule; do
74
76
  continue
75
77
  fi
76
78
 
79
+ # match_type and trigger_source are required by the prompt_rule.matched
80
+ # payload schema. Both are fixed for this hook rather than derived: rules
81
+ # match with bash `[[ =~ ]]`, which is POSIX ERE, and the only thing this
82
+ # UserPromptSubmit hook ever matches against is the prompt itself. A future
83
+ # vocabulary or file-path matcher would set them from the rule.
77
84
  prompt_rules_emit "$SESSION_ID" "prompt_rule.matched" \
78
- "$(jq -cn --arg id "$RULE_ID" '{rule_id: $id}')" || true
85
+ "$(jq -cn --arg id "$RULE_ID" \
86
+ '{rule_id: $id, match_type: "regex", trigger_source: "prompt"}')" || true
79
87
 
80
88
  ALREADY_FIRED=$(echo "$FIRED" | jq --arg id "$RULE_ID" 'index($id) != null' 2>/dev/null)
81
89
  if [[ "$FIRE_ONCE" == "true" && "$ALREADY_FIRED" == "true" ]]; then
@@ -6,8 +6,21 @@
6
6
  # across the ecosystem.
7
7
  #
8
8
  # Usage:
9
- # # In your plugin's config loader (e.g., plugins/bursar/scripts/lib/bursar-config.sh):
10
- # source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
9
+ # # In your plugin's config loader (e.g., plugins/bursar/scripts/lib/bursar-config.sh).
10
+ # # Locate this file from the sourcing file's OWN path, never from a
11
+ # # caller-supplied $PLUGIN_ROOT: that variable is read at source time from
12
+ # # whatever scope did the sourcing, so a sub-shell that inherits
13
+ # # CLAUDE_PLUGIN_ROOT but not PLUGIN_ROOT loses every accessor below while
14
+ # # still exiting 0 (see ecosystem-88v, ecosystem-7bj). Four levels up from
15
+ # # plugins/<name>/scripts/lib is the repo root.
16
+ # _BURSAR_CONFIG_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ # # shellcheck source=scripts/lib/config-loader.sh
18
+ # source "${_BURSAR_CONFIG_LIB_DIR}/../../../../scripts/lib/config-loader.sh"
19
+ #
20
+ # The shellcheck directive is repo-root-relative, not file-relative: shellcheck
21
+ # resolves source= against its own working directory, and `npm run
22
+ # test:shellcheck` runs it from the repo root. A file-relative path there
23
+ # silently degrades to SC1091 "not following", which `-S error` hides.
11
24
  # config_load_plugin "bursar" "$repo_root" "_BURSAR_CONFIG"
12
25
  # config_get "_BURSAR_CONFIG" '.bursar.window' # returns value or empty string
13
26
  #
@@ -26,6 +26,13 @@
26
26
  export ONLOOKER_PROMPT_RULES_DIR="${ONLOOKER_PROMPT_RULES_DIR:-$ONLOOKER_DIR/prompt-rules}"
27
27
  export ONLOOKER_PROMPT_RULES_SESSIONS_DIR="$ONLOOKER_PROMPT_RULES_DIR/sessions"
28
28
 
29
+ # The canonical emitter is a sibling file, so resolve it from this script's own
30
+ # location rather than from a caller-supplied $CLAUDE_PLUGIN_ROOT. Sourcing this
31
+ # lib from a scope that never set that variable then still yields a working
32
+ # emit path instead of a silent no-op.
33
+ _PROMPT_RULES_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ _PROMPT_RULES_EVENT_JS="$_PROMPT_RULES_LIB_DIR/onlooker-event.mjs"
35
+
29
36
  # Path to the global rules file.
30
37
  # Usage: path=$(prompt_rules_global_path)
31
38
  prompt_rules_global_path() {
@@ -142,8 +149,24 @@ prompt_rules_pattern_matches() {
142
149
  }
143
150
 
144
151
  # Append a prompt-rule event to the global events log.
145
- # These event types (prompt_rule.matched, prompt_rule.applied) are not yet
146
- # in @onlooker-community/schema; once added, swap to onlooker_append_event.
152
+ #
153
+ # Routes through onlooker-event.mjs like every other emit path in the repo, so
154
+ # the envelope is built once, in one place, and validated against
155
+ # @onlooker-community/schema wherever that package resolves (dev, CI, tests).
156
+ #
157
+ # This used to hand-build a five-field envelope with jq. That envelope was
158
+ # never valid: it omitted every required id/schema_version/runtime/machine_id/
159
+ # sequence field, and it added a `turn` field the envelope forbids outright
160
+ # (event.v1.json is additionalProperties:false and has no `turn`). The canonical
161
+ # emitter carries a turn as `turn_number` INSIDE the payload, and neither
162
+ # prompt_rule payload schema has that property, so a turn number has nowhere
163
+ # valid to go on these two event types and is no longer recorded.
164
+ #
165
+ # Fails closed: if the emitter is missing or rejects the payload, nothing is
166
+ # written. A dropped telemetry event beats an unparseable line on the bus, and
167
+ # both callers in prompt-rule-injector.sh already discard the return with
168
+ # `|| true` so a rejection can never block prompt submission.
169
+ #
147
170
  # Usage: prompt_rules_emit "$session_id" "prompt_rule.matched" "$payload_json"
148
171
  prompt_rules_emit() {
149
172
  local session_id="${1:-unknown}"
@@ -155,22 +178,30 @@ prompt_rules_emit() {
155
178
  local payload_json="${3:-}"
156
179
  [ -z "$payload_json" ] && payload_json='{}'
157
180
  [[ -z "$event_type" ]] && return 1
158
- ensure_file_exists "$ONLOOKER_EVENTS_LOG" || return 1
159
181
 
160
- local timestamp plugin
161
- timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
182
+ local event_js="$_PROMPT_RULES_EVENT_JS"
183
+ [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "${_ONLOOKER_EVENT_JS}" ]] && event_js="$_ONLOOKER_EVENT_JS"
184
+ [[ -f "$event_js" ]] || return 1
185
+
186
+ local plugin
162
187
  plugin="${ONLOOKER_PLUGIN_NAME:-onlooker}"
163
188
 
164
- jq -cn \
165
- --arg ts "$timestamp" \
166
- --arg sid "$session_id" \
189
+ local params
190
+ params=$(jq -cn \
167
191
  --arg plugin "$plugin" \
192
+ --arg sid "$session_id" \
168
193
  --arg type "$event_type" \
169
194
  --argjson payload "$payload_json" \
170
- --arg turn "${ONLOOKER_TURN_NUMBER:-}" \
171
- '{timestamp: $ts, session_id: $sid, plugin: $plugin, event_type: $type, payload: $payload}
172
- + (if $turn != "" then {turn: ($turn | tonumber)} else {} end)
173
- ' >> "$ONLOOKER_EVENTS_LOG"
195
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}') || return 1
196
+
197
+ local event
198
+ event=$(printf '%s' "$params" \
199
+ | ONLOOKER_DIR="$ONLOOKER_DIR" ONLOOKER_PLUGIN_NAME="$plugin" \
200
+ node "$event_js" emit 2>/dev/null) || return 1
201
+ [[ -z "$event" ]] && return 1
202
+
203
+ ensure_file_exists "$ONLOOKER_EVENTS_LOG" || return 1
204
+ printf '%s\n' "$event" >> "$ONLOOKER_EVENTS_LOG"
174
205
  }
175
206
 
176
207
  # Print a human-readable table of merged rules with their fired status.
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bats
2
+ #
3
+ # Guards that every plugin config lib locates config-loader.sh from its OWN
4
+ # path, not from a caller-supplied $PLUGIN_ROOT.
5
+ #
6
+ # These libs used to open with:
7
+ #
8
+ # source "${PLUGIN_ROOT}/../../scripts/lib/config-loader.sh"
9
+ #
10
+ # PLUGIN_ROOT is read at source time from whatever scope did the sourcing. From
11
+ # a scope that never set it, the path collapses to
12
+ # /../../scripts/lib/config-loader.sh, the source fails, and config_load_plugin
13
+ # and config_get never get defined. The caller then gets empty strings from
14
+ # every accessor while the script keeps going and exits 0 — the config silently
15
+ # falls back to defaults instead of failing loudly.
16
+ #
17
+ # Cartographer hit exactly this (ecosystem-88v): run-audit.sh and both hooks
18
+ # sourced the config lib inside `bash -c` sub-shells that inherited
19
+ # CLAUDE_PLUGIN_ROOT but not PLUGIN_ROOT. It went unnoticed for months because
20
+ # the analyzers take their settings as positional parameters and kept working.
21
+ #
22
+ # The sweep in ecosystem-7bj made every lib self-locating via BASH_SOURCE, which
23
+ # is correct regardless of caller scope, cwd, or sub-shell nesting. These tests
24
+ # cover all 16 at once so plugin 17 cannot quietly reintroduce the pattern.
25
+
26
+ setup() {
27
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
28
+ setup_test_env
29
+ }
30
+
31
+ # Every plugins/<name>/scripts/lib/<name>-config.sh in the repo.
32
+ _config_libs() {
33
+ find "${REPO_ROOT}/plugins" -path '*/scripts/lib/*-config.sh' -type f | sort
34
+ }
35
+
36
+ # Without this, a typo in _config_libs would make every test below pass over an
37
+ # empty list and report coverage that does not exist. Not pinned to 16, so
38
+ # adding or removing a plugin does not fail it.
39
+ @test "the config-lib glob matches at least one file" {
40
+ local count
41
+ count=$(_config_libs | wc -l | tr -d ' ')
42
+ [ "$count" -gt 0 ]
43
+ }
44
+
45
+ @test "no config lib resolves config-loader.sh through \$PLUGIN_ROOT" {
46
+ local offenders=""
47
+ local lib
48
+ while IFS= read -r lib; do
49
+ if grep -q 'source "\${PLUGIN_ROOT}' "$lib"; then
50
+ offenders+="${lib#"${REPO_ROOT}/"}"$'\n'
51
+ fi
52
+ done < <(_config_libs)
53
+
54
+ [ -z "$offenders" ] || {
55
+ printf 'config libs still sourcing through $PLUGIN_ROOT:\n%s' "$offenders" >&2
56
+ return 1
57
+ }
58
+ }
59
+
60
+ @test "every config lib derives its own directory from BASH_SOURCE" {
61
+ local missing=""
62
+ local lib
63
+ while IFS= read -r lib; do
64
+ grep -q 'BASH_SOURCE\[0\]' "$lib" || missing+="${lib#"${REPO_ROOT}/"}"$'\n'
65
+ done < <(_config_libs)
66
+
67
+ [ -z "$missing" ] || {
68
+ printf 'config libs not self-locating via BASH_SOURCE:\n%s' "$missing" >&2
69
+ return 1
70
+ }
71
+ }
72
+
73
+ # The behavioral test the source-text guards above stand in for. Sources each
74
+ # lib in a sub-shell with PLUGIN_ROOT unset and asserts the shared accessors it
75
+ # depends on actually got defined.
76
+ @test "every config lib yields working accessors with PLUGIN_ROOT unset" {
77
+ local failures=""
78
+ local lib
79
+ while IFS= read -r lib; do
80
+ local plugin_dir plugin_name out
81
+ plugin_dir="${lib%/scripts/lib/*}"
82
+ plugin_name="$(basename "$plugin_dir")"
83
+
84
+ # CLAUDE_PLUGIN_ROOT is set because config_load_plugin genuinely needs it to
85
+ # find the plugin's own config.json; PLUGIN_ROOT is deliberately absent,
86
+ # which is the exact shape of the sub-shell that broke cartographer.
87
+ out=$(env -u PLUGIN_ROOT CLAUDE_PLUGIN_ROOT="$plugin_dir" HOME="$HOME" \
88
+ bash -c '
89
+ set -u
90
+ source "$1" 2>&1 || exit 1
91
+ type -t config_load_plugin >/dev/null 2>&1 || { echo "config_load_plugin undefined"; exit 1; }
92
+ type -t config_get >/dev/null 2>&1 || { echo "config_get undefined"; exit 1; }
93
+ config_load_plugin "$2" "" _PROBE || { echo "config_load_plugin failed"; exit 1; }
94
+ # A plugin ships a config.json, so the merged config must be a non-empty
95
+ # JSON object. "{}" here would mean the load degraded to defaults.
96
+ printf "%s" "$_PROBE" | jq -e "type == \"object\" and (length > 0)" >/dev/null \
97
+ || { echo "merged config empty: $_PROBE"; exit 1; }
98
+ ' _ "$lib" "$plugin_name" 2>&1) \
99
+ || failures+="${plugin_name}: ${out}"$'\n'
100
+ done < <(_config_libs)
101
+
102
+ [ -z "$failures" ] || {
103
+ printf 'config libs broken when PLUGIN_ROOT is unset:\n%s' "$failures" >&2
104
+ return 1
105
+ }
106
+ }
107
+
108
+ # Regression guard for the specific reported failure, kept separate so a break
109
+ # names cartographer directly rather than appearing in a list of 16.
110
+ @test "cartographer config survives the sub-shell shape that broke it" {
111
+ local plugin_dir="${REPO_ROOT}/plugins/cartographer"
112
+ run env -u PLUGIN_ROOT CLAUDE_PLUGIN_ROOT="$plugin_dir" bash -c '
113
+ source "'"${plugin_dir}"'/scripts/lib/cartographer-config.sh"
114
+ cartographer_config_load ""
115
+ cartographer_config_audit_interval_hours
116
+ '
117
+ [ "$status" -eq 0 ] || return 1
118
+ # The shipped default. Under the old pattern this came back empty, because
119
+ # the accessor was never defined and the failure was swallowed.
120
+ [[ -n "$output" ]] || return 1
121
+ [[ "$output" != *"No such file or directory"* ]] || return 1
122
+ [[ "$output" != *"command not found"* ]] || return 1
123
+ [ "$output" -eq "$output" ] 2>/dev/null
124
+ }
@@ -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
+ }