@onlooker-community/ecosystem 0.29.2 → 0.30.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.29.2",
3
+ "version": "0.30.0",
4
4
  "description": "Observability substrate for Claude Code. Provides the shared $ONLOOKER_DIR storage root (default $HOME/.onlooker), canonical schema-validated event emission, session and tool tracking hooks, and prompt rules. Required by all other Onlooker plugins.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,19 +1,19 @@
1
1
  {
2
- ".": "0.29.2",
3
- "plugins/archivist": "0.1.0",
2
+ ".": "0.30.0",
3
+ "plugins/archivist": "0.2.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
6
6
  "plugins/cartographer": "0.2.1",
7
7
  "plugins/governor": "0.2.1",
8
8
  "plugins/compass": "0.3.0",
9
- "plugins/scribe": "0.2.1",
10
- "plugins/counsel": "0.3.1",
9
+ "plugins/scribe": "0.3.0",
10
+ "plugins/counsel": "0.4.0",
11
11
  "plugins/warden": "0.2.0",
12
- "plugins/librarian": "0.2.0",
12
+ "plugins/librarian": "0.3.0",
13
13
  "plugins/curator": "0.1.0",
14
14
  "plugins/historian": "0.2.0",
15
15
  "plugins/assayer": "1.0.0",
16
- "plugins/bursar": "0.1.0",
16
+ "plugins/bursar": "0.1.1",
17
17
  "plugins/lineage": "0.1.0",
18
18
  "plugins/inspector": "0.2.0"
19
19
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.0](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.3...ecosystem-v0.30.0) (2026-06-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **plugins:** persist structured JSON and emit onlooker.artifact.ready :outbox_tray: ([#103](https://github.com/onlooker-community/ecosystem/issues/103)) ([9b689a4](https://github.com/onlooker-community/ecosystem/commit/9b689a41aa4bdb481fef93b484e6446da731e8f1))
9
+
10
+ ## [0.29.3](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.2...ecosystem-v0.29.3) (2026-06-24)
11
+
12
+
13
+ ### Performance Improvements
14
+
15
+ * **bursar:** collapse process forks in SessionEnd hot path :relieved: ([#101](https://github.com/onlooker-community/ecosystem/issues/101)) ([7a426fe](https://github.com/onlooker-community/ecosystem/commit/7a426fe359785eca35ea1ad61523b05fda79e0da))
16
+
3
17
  ## [0.29.2](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.1...ecosystem-v0.29.2) (2026-06-21)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.29.2",
3
+ "version": "0.30.0",
4
4
  "description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archivist",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Structured session memory across context truncation: extracts decisions, dead ends, and open questions on PreCompact and reinjects the most important items at SessionStart. 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.0](https://github.com/onlooker-community/ecosystem/compare/archivist-v0.1.0...archivist-v0.2.0) (2026-06-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **plugins:** persist structured JSON and emit onlooker.artifact.ready :outbox_tray: ([#103](https://github.com/onlooker-community/ecosystem/issues/103)) ([9b689a4](https://github.com/onlooker-community/ecosystem/commit/9b689a41aa4bdb481fef93b484e6446da731e8f1))
9
+
3
10
  ## [0.1.0](https://github.com/onlooker-community/ecosystem/compare/archivist-v0.0.1...archivist-v0.1.0) (2026-05-23)
4
11
 
5
12
 
@@ -44,6 +44,8 @@ source "${PLUGIN_ROOT}/scripts/lib/archivist-ulid.sh"
44
44
  source "${PLUGIN_ROOT}/scripts/lib/archivist-storage.sh"
45
45
  # shellcheck source=../lib/archivist-config.sh
46
46
  source "${PLUGIN_ROOT}/scripts/lib/archivist-config.sh"
47
+ # shellcheck source=../lib/archivist-events.sh
48
+ CLAUDE_PLUGIN_ROOT="${_ECOSYSTEM_ROOT:-$PLUGIN_ROOT}" source "${PLUGIN_ROOT}/scripts/lib/archivist-events.sh"
47
49
 
48
50
  # Always approve compaction at exit, no matter what happened above.
49
51
  _approve() {
@@ -234,5 +236,37 @@ for KIND_PAIR in "decisions:decision" "dead_ends:dead_end" "open_questions:open_
234
236
  done
235
237
  done
236
238
 
239
+ # Write an aggregate extract JSON for the artifact browser. This is the
240
+ # single-file representation of everything extracted this compact cycle:
241
+ # decisions, dead ends, and open questions in one document. The session_id
242
+ # doubles as the artifact key so the stable uuid from artifact.FromEvent
243
+ # deduplicates re-uploads of the same session.
244
+ if [[ "$WRITE_COUNT" -gt 0 && -n "$SESSION_ID" ]]; then
245
+ EXTRACTS_DIR="$(archivist_project_dir "$PROJECT_KEY")/extracts"
246
+ mkdir -p "$EXTRACTS_DIR" 2>/dev/null || true
247
+ EXTRACT_PATH="${EXTRACTS_DIR}/${SESSION_ID}.json"
248
+
249
+ AGGREGATE=$(jq -n \
250
+ --argjson decisions "$(printf '%s' "$CLEAN_RESPONSE" | jq '.decisions // []')" \
251
+ --argjson dead_ends "$(printf '%s' "$CLEAN_RESPONSE" | jq '.dead_ends // []')" \
252
+ --argjson open_questions "$(printf '%s' "$CLEAN_RESPONSE" | jq '.open_questions // []')" \
253
+ '{decisions: $decisions, dead_ends: $dead_ends, open_questions: $open_questions}') || AGGREGATE=""
254
+
255
+ if [[ -n "$AGGREGATE" ]]; then
256
+ printf '%s\n' "$AGGREGATE" > "$EXTRACT_PATH" 2>/dev/null || true
257
+
258
+ SESSION_SHORT="${SESSION_ID:0:8}"
259
+ ARTIFACT_PAYLOAD=$(jq -n \
260
+ --arg plugin "archivist" \
261
+ --arg artifact_kind "extract" \
262
+ --arg artifact_path "$EXTRACT_PATH" \
263
+ --arg artifact_title "Archivist Extract · $SESSION_SHORT" \
264
+ '{plugin: $plugin, artifact_kind: $artifact_kind,
265
+ artifact_path: $artifact_path, artifact_title: $artifact_title}') || ARTIFACT_PAYLOAD=""
266
+ [[ -n "$ARTIFACT_PAYLOAD" ]] && \
267
+ archivist_emit_event "onlooker.artifact.ready" "$ARTIFACT_PAYLOAD" || true
268
+ fi
269
+ fi
270
+
237
271
  _approve "Archivist: wrote ${WRITE_COUNT} artifacts (trigger=${TRIGGER})"
238
272
  exit 0
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # Canonical archivist.* event emission.
3
+ #
4
+ # Thin wrapper around the ecosystem plugin's onlooker-event.mjs `emit` mode.
5
+ # Every emission is validated against @onlooker-community/schema before being
6
+ # appended to $ONLOOKER_EVENTS_LOG (defaults to $ONLOOKER_DIR/logs/onlooker-events.jsonl).
7
+ #
8
+ # Usage:
9
+ # archivist_emit_event "onlooker.artifact.ready" \
10
+ # '{"plugin":"archivist","artifact_kind":"extract","artifact_path":"...","artifact_title":"..."}'
11
+
12
+ _ARCHIVIST_PLUGIN_NAME="archivist"
13
+
14
+ _archivist_event_js_path() {
15
+ if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
16
+ printf '%s' "$_ONLOOKER_EVENT_JS"
17
+ return 0
18
+ fi
19
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
20
+ local candidates=(
21
+ "${plugin_root}/scripts/lib/onlooker-event.mjs"
22
+ "${plugin_root}/../../scripts/lib/onlooker-event.mjs"
23
+ )
24
+ local c
25
+ for c in "${candidates[@]}"; do
26
+ [[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
27
+ done
28
+ return 1
29
+ }
30
+
31
+ _archivist_session_id() {
32
+ if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
33
+ printf '%s' "$_HOOK_SESSION_ID"
34
+ return 0
35
+ fi
36
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
37
+ printf '%s' "$CLAUDE_SESSION_ID"
38
+ return 0
39
+ fi
40
+ printf 'unknown'
41
+ }
42
+
43
+ archivist_emit_event() {
44
+ local event_type="${1:-}"
45
+ local payload="${2:-}"
46
+
47
+ [[ -z "$event_type" || -z "$payload" ]] && return 1
48
+
49
+ local event_js
50
+ event_js=$(_archivist_event_js_path) || return 1
51
+
52
+ local session_id
53
+ session_id=$(_archivist_session_id)
54
+
55
+ local params
56
+ params=$(jq -n \
57
+ --arg plugin "$_ARCHIVIST_PLUGIN_NAME" \
58
+ --arg sid "$session_id" \
59
+ --arg type "$event_type" \
60
+ --argjson payload "$payload" \
61
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
62
+ 2>/dev/null) || return 1
63
+
64
+ local event stderr_file
65
+ stderr_file=$(mktemp -t archivist-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/archivist-event-err.$$"
66
+ event=$(printf '%s' "$params" \
67
+ | ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
68
+ ONLOOKER_PLUGIN_NAME="$_ARCHIVIST_PLUGIN_NAME" \
69
+ node "$event_js" emit 2>"$stderr_file") || {
70
+ printf 'archivist_emit_event: schema validation failed for %s\n' "$event_type" >&2
71
+ [[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
72
+ rm -f "$stderr_file"
73
+ return 1
74
+ }
75
+ rm -f "$stderr_file"
76
+
77
+ local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
78
+ mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
79
+ printf '%s\n' "$event" >> "$log_path"
80
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bursar",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Multi-session, per-project budget accounting for the Onlooker ecosystem. Rolls each session's spend into a per-project ledger on SessionEnd and surfaces \"this project burned $X this week\" at SessionStart. Where governor regulates a single session, bursar is the cross-session rollup: it reads governor.session.complete off the shared event bus and emits bursar.* events for audit. Named for the officer who keeps the accounts. 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.1.1](https://github.com/onlooker-community/ecosystem/compare/bursar-v0.1.0...bursar-v0.1.1) (2026-06-24)
4
+
5
+
6
+ ### Performance Improvements
7
+
8
+ * **bursar:** collapse process forks in SessionEnd hot path :relieved: ([#101](https://github.com/onlooker-community/ecosystem/issues/101)) ([7a426fe](https://github.com/onlooker-community/ecosystem/commit/7a426fe359785eca35ea1ad61523b05fda79e0da))
9
+
3
10
  ## [0.1.0](https://github.com/onlooker-community/ecosystem/compare/bursar-v0.0.1...bursar-v0.1.0) (2026-06-12)
4
11
 
5
12
 
@@ -33,11 +33,12 @@ source "${PLUGIN_ROOT}/scripts/lib/bursar-project-key.sh"
33
33
  source "${PLUGIN_ROOT}/scripts/lib/bursar-ledger.sh"
34
34
 
35
35
  INPUT=$(cat)
36
- SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
37
- CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
38
36
 
39
37
  _done() { exit 0; }
40
38
 
39
+ # Parse session_id and cwd in a single jq pass (one fork, not two).
40
+ { IFS= read -r SESSION_ID; IFS= read -r CWD; } < <(printf '%s' "$INPUT" | jq -r '.session_id // "", .cwd // ""' 2>/dev/null)
41
+
41
42
  [[ -z "$SESSION_ID" ]] && _done
42
43
 
43
44
  ONLOOKER_DIR="${ONLOOKER_DIR:-${HOME}/.onlooker}"
@@ -47,11 +48,13 @@ TRACKER="${ONLOOKER_DIR}/session-trackers/${SESSION_ID}"
47
48
 
48
49
  # -----------------------------------------------------------------------
49
50
  # Resolve project key + cwd: breadcrumb → substrate tracker → live cwd.
51
+ # The breadcrumb (dropped at SessionStart) usually carries both, which lets us
52
+ # skip the git + shasum project-key derivation entirely in the common case.
50
53
  # -----------------------------------------------------------------------
51
54
  PROJECT_KEY=""
52
55
  if [[ -f "$BREADCRUMB" ]]; then
53
- PROJECT_KEY=$(jq -r '.project_key // ""' "$BREADCRUMB" 2>/dev/null) || PROJECT_KEY=""
54
- [[ -z "$CWD" ]] && CWD=$(jq -r '.cwd // ""' "$BREADCRUMB" 2>/dev/null)
56
+ { IFS= read -r PROJECT_KEY; IFS= read -r bc_cwd; } < <(jq -r '.project_key // "", .cwd // ""' "$BREADCRUMB" 2>/dev/null)
57
+ [[ -z "$CWD" ]] && CWD="$bc_cwd"
55
58
  fi
56
59
  if [[ -z "$CWD" && -f "$TRACKER" ]]; then
57
60
  CWD=$(jq -r '.cwd // ""' "$TRACKER" 2>/dev/null) || CWD=""
@@ -74,66 +77,74 @@ COST=""
74
77
  TOKENS=""
75
78
  CALLS=""
76
79
 
77
- # Reads event-log lines on stdin; echoes the latest matching session.complete payload.
78
- _latest_governor_payload() {
80
+ # Reads event-log lines on stdin; emits one TSV line "cost<TAB>tokens<TAB>calls"
81
+ # for the latest governor.session.complete matching this session (empty if none).
82
+ # grep pre-filters so jq only parses the handful of matching lines, and the
83
+ # field extraction happens in the same jq pass that selects the latest match —
84
+ # replacing the prior select + three separate jq extractions.
85
+ _latest_governor_spend() {
79
86
  grep -F '"governor.session.complete"' 2>/dev/null \
80
- | jq -c --arg sid "$SESSION_ID" \
81
- 'select(.event_type == "governor.session.complete" and .payload.session_id == $sid) | .payload' \
82
- 2>/dev/null \
83
- | tail -n 1
87
+ | jq -rs --arg sid "$SESSION_ID" '
88
+ [ .[]
89
+ | select(.event_type == "governor.session.complete" and .payload.session_id == $sid)
90
+ | .payload ]
91
+ | if length == 0 then empty
92
+ else (.[-1] | [(.total_cost_usd // ""), (.total_tokens // ""), (.total_api_calls // "")] | @tsv)
93
+ end' \
94
+ 2>/dev/null
84
95
  }
85
96
 
86
97
  if [[ -f "$LOG" ]]; then
87
98
  # The matching event was emitted seconds ago (governor's final Stop), so it is
88
99
  # almost always near the tail. Scan a recent slice first to keep this hook
89
100
  # fast as the global log grows; fall back to the full file only on a miss.
90
- SPEND=$(tail -n 2000 "$LOG" 2>/dev/null | _latest_governor_payload)
91
- [[ -z "$SPEND" ]] && SPEND=$(_latest_governor_payload < "$LOG")
101
+ SPEND=$(tail -n 2000 "$LOG" 2>/dev/null | _latest_governor_spend)
102
+ [[ -z "$SPEND" ]] && SPEND=$(_latest_governor_spend < "$LOG")
92
103
  if [[ -n "$SPEND" ]]; then
93
104
  GOV_PRESENT="true"
94
- COST=$(printf '%s' "$SPEND" | jq -r '.total_cost_usd // empty' 2>/dev/null) || COST=""
95
- TOKENS=$(printf '%s' "$SPEND" | jq -r '.total_tokens // empty' 2>/dev/null) || TOKENS=""
96
- CALLS=$(printf '%s' "$SPEND" | jq -r '.total_api_calls // empty' 2>/dev/null) || CALLS=""
105
+ IFS=$'\t' read -r COST TOKENS CALLS <<<"$SPEND"
97
106
  fi
98
107
  fi
99
108
 
100
109
  MODEL=""
101
110
  [[ -f "$TRACKER" ]] && MODEL=$(jq -r '.model // ""' "$TRACKER" 2>/dev/null)
102
111
 
112
+ # One date fork yields both the epoch and the RFC3339 stamp (was two).
113
+ { IFS= read -r NOW_EPOCH; IFS= read -r NOW_ISO; } < <(date -u +'%s%n%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)
114
+ [[ -z "$NOW_EPOCH" ]] && NOW_EPOCH=0
115
+
103
116
  # -----------------------------------------------------------------------
104
- # Build the record and the event payload, attaching spend fields only when
105
- # governor supplied them.
117
+ # Build the ledger record AND the (smaller) event payload in a single jq pass.
118
+ # Spend fields are passed as strings and coerced with tonumber so an empty value
119
+ # is simply omitted — replacing the per-field add_fields() helper that forked a
120
+ # jq for every field, twice. The event payload is the record minus the
121
+ # ledger-only ts/ts_epoch fields.
106
122
  # -----------------------------------------------------------------------
107
- add_fields() {
108
- # Echoes the input JSON ($1) with cost/tokens/calls/model merged in.
109
- local base="$1"
110
- [[ -n "$COST" ]] && base=$(printf '%s' "$base" | jq --argjson v "$COST" '. + {cost_usd: $v}' 2>/dev/null)
111
- [[ -n "$TOKENS" ]] && base=$(printf '%s' "$base" | jq --argjson v "$TOKENS" '. + {tokens: $v}' 2>/dev/null)
112
- [[ -n "$CALLS" ]] && base=$(printf '%s' "$base" | jq --argjson v "$CALLS" '. + {api_calls: $v}' 2>/dev/null)
113
- [[ -n "$MODEL" ]] && base=$(printf '%s' "$base" | jq --arg v "$MODEL" '. + {model: $v}' 2>/dev/null)
114
- printf '%s' "$base"
115
- }
116
-
117
- RECORD=$(jq -n \
118
- --arg ts "$(bursar_now_iso)" \
119
- --argjson te "$(bursar_now_epoch)" \
123
+ { IFS= read -r RECORD; IFS= read -r EV; } < <(jq -rn \
124
+ --arg ts "$NOW_ISO" \
125
+ --argjson te "$NOW_EPOCH" \
120
126
  --arg sid "$SESSION_ID" \
121
127
  --arg pk "$PROJECT_KEY" \
122
128
  --argjson gp "$GOV_PRESENT" \
123
- '{ts: $ts, ts_epoch: $te, session_id: $sid, project_key: $pk, governor_present: $gp}' 2>/dev/null)
124
- RECORD=$(add_fields "$RECORD")
129
+ --arg cost "$COST" \
130
+ --arg tokens "$TOKENS" \
131
+ --arg calls "$CALLS" \
132
+ --arg model "$MODEL" \
133
+ '
134
+ ( {ts: $ts, ts_epoch: $te, session_id: $sid, project_key: $pk, governor_present: $gp}
135
+ + (if $cost != "" then {cost_usd: ($cost | tonumber)} else {} end)
136
+ + (if $tokens != "" then {tokens: ($tokens | tonumber)} else {} end)
137
+ + (if $calls != "" then {api_calls: ($calls | tonumber)} else {} end)
138
+ + (if $model != "" then {model: $model} else {} end)
139
+ ) as $record
140
+ | ($record | tojson), ($record | del(.ts, .ts_epoch) | tojson)
141
+ ' 2>/dev/null)
125
142
 
126
143
  # Only claim the session was recorded — and only drop the breadcrumb — once the
127
144
  # ledger upsert actually succeeds. A failed write (lock timeout, mv failure)
128
145
  # must keep the breadcrumb so the session→project attribution survives for a
129
146
  # later attempt rather than being lost behind a false "recorded" event.
130
147
  if [[ -n "$RECORD" ]] && bursar_ledger_record "$PROJECT_KEY" "$RECORD"; then
131
- EV=$(jq -n \
132
- --arg pk "$PROJECT_KEY" \
133
- --arg sid "$SESSION_ID" \
134
- --argjson gp "$GOV_PRESENT" \
135
- '{project_key: $pk, session_id: $sid, governor_present: $gp}' 2>/dev/null)
136
- EV=$(add_fields "$EV")
137
148
  [[ -n "$EV" ]] && bursar_emit_event "bursar.session.recorded" "$EV" "$SESSION_ID" || true
138
149
 
139
150
  rm -f "$BREADCRUMB" 2>/dev/null || true
@@ -21,40 +21,43 @@ bursar_config_load() {
21
21
  local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
22
22
  local home_dir="${HOME:-}"
23
23
 
24
- local merged="{}"
25
- local file
24
+ # Read each layer's raw text with the no-fork `$(<file)` builtin (NOT `cat`),
25
+ # then deep-merge all three layers in a SINGLE jq invocation. The dominant
26
+ # cost in the SessionEnd hook is jq process startup, not the merge itself, so
27
+ # this collapses what was one-jq-per-file (up to 6 forks) down to one.
28
+ local default_txt="" home_txt="" repo_txt=""
29
+ local default_file="${plugin_root}/config.json"
30
+ local home_file="${home_dir}/.claude/settings.json"
31
+ local repo_file=""
32
+ [[ -n "$repo_root" ]] && repo_file="${repo_root}/.claude/settings.json"
26
33
 
27
- file="${plugin_root}/config.json"
28
- if [[ -f "$file" ]]; then
29
- local defaults
30
- defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
31
- merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
32
- || merged="$defaults"
33
- fi
34
+ [[ -f "$default_file" ]] && default_txt="$(<"$default_file")"
35
+ [[ -f "$home_file" ]] && home_txt="$(<"$home_file")"
36
+ [[ -n "$repo_file" && -f "$repo_file" ]] && repo_txt="$(<"$repo_file")"
34
37
 
35
- local repo_settings=""
36
- [[ -n "$repo_root" ]] && repo_settings="${repo_root}/.claude/settings.json"
37
-
38
- for file in "${home_dir}/.claude/settings.json" "$repo_settings"; do
39
- [[ -n "$file" && -f "$file" ]] || continue
40
- local overlay
41
- overlay=$(jq '{ bursar: (.bursar // {}) }' "$file" 2>/dev/null) || continue
42
- [[ -z "$overlay" ]] && continue
43
- local attempt
44
- if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
45
- def deepmerge($a; $b):
46
- if ($a|type) == "object" and ($b|type) == "object" then
47
- reduce (($a|keys) + ($b|keys) | unique)[] as $k
48
- ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
49
- elif $b == null then $a
50
- else $b end;
51
- deepmerge($a; $b)
52
- ' 2>/dev/null) && [[ -n "$attempt" ]]; then
53
- merged="$attempt"
54
- fi
55
- done
56
-
57
- _BURSAR_CONFIG="$merged"
38
+ # Precedence (latest wins): defaults < home settings < repo settings. The
39
+ # defaults file is merged whole; settings files contribute only their .bursar
40
+ # key. `fromjson? // {}` parses each layer defensively — a missing or malformed
41
+ # file degrades to {} rather than aborting the merge (matches the prior
42
+ # per-file fallback).
43
+ _BURSAR_CONFIG=$(jq -n \
44
+ --arg d "$default_txt" \
45
+ --arg h "$home_txt" \
46
+ --arg r "$repo_txt" \
47
+ '
48
+ def deepmerge($a; $b):
49
+ if ($a|type) == "object" and ($b|type) == "object" then
50
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
51
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
52
+ elif $b == null then $a
53
+ else $b end;
54
+ ($d | fromjson? // {}) as $defaults
55
+ | (($h | fromjson? // {}) | {bursar: (.bursar // {})}) as $home
56
+ | (($r | fromjson? // {}) | {bursar: (.bursar // {})}) as $repo
57
+ | deepmerge(deepmerge($defaults; $home); $repo)
58
+ ' 2>/dev/null) || _BURSAR_CONFIG="{}"
59
+ [[ -z "$_BURSAR_CONFIG" ]] && _BURSAR_CONFIG="{}"
60
+ return 0
58
61
  }
59
62
 
60
63
  bursar_config_get() {
@@ -55,12 +55,13 @@ bursar_ledger_record() {
55
55
  local record="${2:-}"
56
56
  [[ -z "$project_key" || -z "$record" ]] && return 1
57
57
 
58
- local sid
59
- sid=$(printf '%s' "$record" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
60
- [[ -z "$sid" ]] && return 1
61
-
62
- local record_compact
63
- record_compact=$(printf '%s' "$record" | jq -c . 2>/dev/null) || return 1
58
+ # Pull the session_id and the compacted record out in a single jq pass:
59
+ # line 1 is the key, line 2 is the line we will write.
60
+ local sid record_compact
61
+ { IFS= read -r sid; IFS= read -r record_compact; } < <(
62
+ printf '%s' "$record" | jq -r '.session_id // empty, tojson' 2>/dev/null
63
+ )
64
+ [[ -z "$sid" || -z "$record_compact" ]] && return 1
64
65
 
65
66
  local dir ledger_path lock_path
66
67
  dir=$(bursar_ledger_dir "$project_key")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "counsel",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Weekly synthesis and recommendations from the full observability stack. Reads all plugin event logs, produces a structured improvement brief, and injects it at session start when the last brief is stale.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.3.1...counsel-v0.4.0) (2026-06-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **plugins:** persist structured JSON and emit onlooker.artifact.ready :outbox_tray: ([#103](https://github.com/onlooker-community/ecosystem/issues/103)) ([9b689a4](https://github.com/onlooker-community/ecosystem/commit/9b689a41aa4bdb481fef93b484e6446da731e8f1))
9
+
3
10
  ## [0.3.1](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.3.0...counsel-v0.3.1) (2026-06-12)
4
11
 
5
12
 
@@ -248,6 +248,18 @@ counsel_generate_brief() {
248
248
  return 1
249
249
  }
250
250
 
251
+ # Also persist structured JSON alongside the markdown so the agent can
252
+ # upload full brief content without re-parsing markdown.
253
+ local json_path="${briefs_dir}/${week_label}.json"
254
+ local full_brief_json
255
+ full_brief_json=$(printf '%s' "$brief_json" | jq \
256
+ --arg ps "$period_start" \
257
+ --arg pe "$period_end" \
258
+ --argjson src "$sources_json" \
259
+ '. + {period_start: $ps, period_end: $pe, sources_consulted: $src}' 2>/dev/null) \
260
+ || full_brief_json="$brief_json"
261
+ printf '%s\n' "$full_brief_json" > "$json_path" 2>/dev/null || true
262
+
251
263
  # Emit counsel.brief.generated.
252
264
  local rec_count
253
265
  rec_count=$(printf '%s' "$brief_json" | jq '.recommendations | length' 2>/dev/null) || rec_count=0
@@ -268,5 +280,16 @@ counsel_generate_brief() {
268
280
  printf 'counsel_generate_brief: skipped counsel.brief.generated (no valid period bounds)\n' >&2
269
281
  fi
270
282
 
283
+ # Emit onlooker.artifact.ready so the agent can upload the full brief content.
284
+ local artifact_payload
285
+ artifact_payload=$(jq -n \
286
+ --arg plugin "counsel" \
287
+ --arg artifact_kind "brief" \
288
+ --arg artifact_path "$json_path" \
289
+ --arg artifact_title "Counsel Brief · $week_label" \
290
+ '{plugin: $plugin, artifact_kind: $artifact_kind,
291
+ artifact_path: $artifact_path, artifact_title: $artifact_title}') || artifact_payload=""
292
+ [[ -n "$artifact_payload" ]] && counsel_emit_event "onlooker.artifact.ready" "$artifact_payload" || true
293
+
271
294
  printf '%s' "$output_path"
272
295
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "librarian",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Consolidation layer between archivist's per-session artifacts and the user's durable typed memory store. Detects which session decisions, dead-ends, and open questions deserve to live across sessions, classifies them into the user/feedback/project/reference types, and queues them as proposals for explicit confirmation. Auto-promotion is opt-in. 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.0](https://github.com/onlooker-community/ecosystem/compare/librarian-v0.2.0...librarian-v0.3.0) (2026-06-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **plugins:** persist structured JSON and emit onlooker.artifact.ready :outbox_tray: ([#103](https://github.com/onlooker-community/ecosystem/issues/103)) ([9b689a4](https://github.com/onlooker-community/ecosystem/commit/9b689a41aa4bdb481fef93b484e6446da731e8f1))
9
+
3
10
  ## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/librarian-v0.1.0...librarian-v0.2.0) (2026-06-04)
4
11
 
5
12
 
@@ -265,6 +265,43 @@ for ((i = 0; i < KEPT_COUNT; i++)); do
265
265
 
266
266
  PROPOSED_COUNT=$((PROPOSED_COUNT + 1))
267
267
 
268
+ # Write a flat artifact JSON for the artifact browser. The proposal file
269
+ # uses a nested `proposed.*` structure; this flat copy matches the web's
270
+ # LibrarianContent type so the dashboard can render it directly.
271
+ ARTIFACT_CONTENT=$(jq -n \
272
+ --arg type "$MEMORY_TYPE" \
273
+ --arg title "$TITLE" \
274
+ --arg body "$BODY" \
275
+ --argjson classifier_confidence "$CONFIDENCE" \
276
+ --arg conflict_state "none" \
277
+ --argjson source_session_ids \
278
+ "$(if [[ -n "$ARTIFACT_SESSION" ]]; then
279
+ printf '["%s"]' "$ARTIFACT_SESSION"
280
+ else
281
+ printf '[]'
282
+ fi)" \
283
+ '{type: $type, title: $title, body: $body,
284
+ classifier_confidence: $classifier_confidence,
285
+ conflict_state: $conflict_state,
286
+ source_session_ids: $source_session_ids}') || ARTIFACT_CONTENT=""
287
+
288
+ if [[ -n "$ARTIFACT_CONTENT" ]]; then
289
+ ARTIFACTS_DIR="$(librarian_project_dir "$PROJECT_KEY")/artifacts"
290
+ mkdir -p "$ARTIFACTS_DIR" 2>/dev/null || true
291
+ ARTIFACT_PATH="${ARTIFACTS_DIR}/${PROPOSAL_ID}.json"
292
+ printf '%s\n' "$ARTIFACT_CONTENT" > "$ARTIFACT_PATH" 2>/dev/null || ARTIFACT_PATH=""
293
+ fi
294
+
295
+ if [[ -n "${ARTIFACT_PATH:-}" ]]; then
296
+ librarian_emit "onlooker.artifact.ready" "$SESSION_ID" "$(jq -cn \
297
+ --arg plugin "librarian" \
298
+ --arg artifact_kind "proposal" \
299
+ --arg artifact_path "$ARTIFACT_PATH" \
300
+ --arg artifact_title "$TITLE" \
301
+ '{plugin: $plugin, artifact_kind: $artifact_kind,
302
+ artifact_path: $artifact_path, artifact_title: $artifact_title}')"
303
+ fi
304
+
268
305
  librarian_emit "librarian.candidate.proposed" "$SESSION_ID" "$(jq -cn \
269
306
  --arg proposal_id "$PROPOSAL_ID" \
270
307
  --arg memory_type "$MEMORY_TYPE" \
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scribe",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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.3.0](https://github.com/onlooker-community/ecosystem/compare/scribe-v0.2.1...scribe-v0.3.0) (2026-06-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **plugins:** persist structured JSON and emit onlooker.artifact.ready :outbox_tray: ([#103](https://github.com/onlooker-community/ecosystem/issues/103)) ([9b689a4](https://github.com/onlooker-community/ecosystem/commit/9b689a41aa4bdb481fef93b484e6446da731e8f1))
9
+
3
10
  ## [0.2.1](https://github.com/onlooker-community/ecosystem/compare/scribe-v0.2.0...scribe-v0.2.1) (2026-06-04)
4
11
 
5
12
 
@@ -225,6 +225,11 @@ scribe_distill() {
225
225
  fi
226
226
  fi
227
227
 
228
+ # Also persist the structured JSON alongside the markdown so the agent
229
+ # can upload it without re-parsing markdown.
230
+ local json_path="${output_dir}/${date_str}-${session_short}.json"
231
+ printf '%s\n' "$intent_json" > "$json_path" 2>/dev/null || true
232
+
228
233
  # Emit scribe.distill.complete.
229
234
  local payload
230
235
  payload=$(jq -n \
@@ -235,5 +240,16 @@ scribe_distill() {
235
240
 
236
241
  [[ -n "$payload" ]] && scribe_emit_event "scribe.distill.complete" "$payload" || true
237
242
 
243
+ # Emit onlooker.artifact.ready so the agent can upload the structured content.
244
+ local artifact_payload
245
+ artifact_payload=$(jq -n \
246
+ --arg plugin "scribe" \
247
+ --arg artifact_kind "intent" \
248
+ --arg artifact_path "$json_path" \
249
+ --arg artifact_title "Session Intent · $date_str" \
250
+ '{plugin: $plugin, artifact_kind: $artifact_kind,
251
+ artifact_path: $artifact_path, artifact_title: $artifact_title}') || artifact_payload=""
252
+ [[ -n "$artifact_payload" ]] && scribe_emit_event "onlooker.artifact.ready" "$artifact_payload" || true
253
+
238
254
  printf '%s' "$output_path"
239
255
  }