@onlooker-community/ecosystem 0.29.2 → 0.29.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +2 -2
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/plugins/bursar/.claude-plugin/plugin.json +1 -1
- package/plugins/bursar/CHANGELOG.md +7 -0
- package/plugins/bursar/scripts/hooks/bursar-session-end.sh +49 -38
- package/plugins/bursar/scripts/lib/bursar-config.sh +35 -32
- package/plugins/bursar/scripts/lib/bursar-ledger.sh +7 -6
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ecosystem",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.3",
|
|
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,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
".": "0.29.
|
|
2
|
+
".": "0.29.3",
|
|
3
3
|
"plugins/archivist": "0.1.0",
|
|
4
4
|
"plugins/tribunal": "1.0.1",
|
|
5
5
|
"plugins/echo": "0.2.0",
|
|
@@ -13,7 +13,7 @@
|
|
|
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.
|
|
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,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.29.3](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.2...ecosystem-v0.29.3) (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.29.2](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.1...ecosystem-v0.29.2) (2026-06-21)
|
|
4
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bursar",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
54
|
-
[[ -z "$CWD" ]] && CWD
|
|
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;
|
|
78
|
-
|
|
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 -
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 |
|
|
91
|
-
[[ -z "$SPEND" ]] && SPEND=$(
|
|
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
|
-
|
|
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
|
|
105
|
-
#
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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")
|