@onlooker-community/ecosystem 0.26.0 → 0.27.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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -3
- package/CHANGELOG.md +15 -0
- package/CLAUDE.md +2 -0
- package/docs/architecture.md +4 -0
- package/package.json +3 -3
- package/plugins/bursar/.claude-plugin/plugin.json +14 -0
- package/plugins/bursar/CHANGELOG.md +10 -0
- package/plugins/bursar/README.md +100 -0
- package/plugins/bursar/config.json +11 -0
- package/plugins/bursar/hooks/hooks.json +26 -0
- package/plugins/bursar/scripts/hooks/bursar-session-end.sh +142 -0
- package/plugins/bursar/scripts/hooks/bursar-session-start.sh +130 -0
- package/plugins/bursar/scripts/lib/bursar-config.sh +108 -0
- package/plugins/bursar/scripts/lib/bursar-events.sh +82 -0
- package/plugins/bursar/scripts/lib/bursar-ledger.sh +152 -0
- package/plugins/bursar/scripts/lib/bursar-project-key.sh +85 -0
- package/plugins/bursar/scripts/lib/bursar-ulid.sh +53 -0
- package/plugins/bursar/scripts/lib/portable-lock.sh +59 -0
- package/plugins/counsel/.claude-plugin/plugin.json +1 -1
- package/plugins/counsel/CHANGELOG.md +8 -0
- package/plugins/counsel/scripts/lib/counsel-reader.sh +7 -1
- package/plugins/counsel/scripts/lib/counsel-synthesize.sh +8 -1
- package/release-please-config.json +16 -0
- package/test/bats/bursar-config.bats +79 -0
- package/test/bats/bursar-events.bats +73 -0
- package/test/bats/bursar-ledger.bats +116 -0
- package/test/bats/bursar-project-key.bats +51 -0
- package/test/bats/bursar-session-end.bats +131 -0
- package/test/bats/bursar-session-start.bats +126 -0
- package/test/bats/bursar-ulid.bats +28 -0
- package/test/bats/counsel-reader.bats +28 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Config resolution for bursar.
|
|
3
|
+
#
|
|
4
|
+
# Reads three layers, latest wins:
|
|
5
|
+
# 1. plugins/bursar/config.json (defaults shipped with the plugin)
|
|
6
|
+
# 2. ~/.claude/settings.json
|
|
7
|
+
# 3. <repo>/.claude/settings.json
|
|
8
|
+
#
|
|
9
|
+
# Exposes:
|
|
10
|
+
# bursar_config_load <repo_root> # populates _BURSAR_CONFIG (JSON)
|
|
11
|
+
# bursar_config_get <jq-path> # echoes string value (empty if unset)
|
|
12
|
+
# bursar_config_get_json <jq-path> # echoes JSON value (null if unset)
|
|
13
|
+
# bursar_config_enabled # 0 if bursar.enabled is true
|
|
14
|
+
# bursar_config_window # echoes "rolling_7d" or "calendar_week"
|
|
15
|
+
# bursar_config_week_start # echoes "monday" or "sunday"
|
|
16
|
+
|
|
17
|
+
_BURSAR_CONFIG="{}"
|
|
18
|
+
|
|
19
|
+
bursar_config_load() {
|
|
20
|
+
local repo_root="${1:-}"
|
|
21
|
+
local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
|
|
22
|
+
local home_dir="${HOME:-}"
|
|
23
|
+
|
|
24
|
+
local merged="{}"
|
|
25
|
+
local file
|
|
26
|
+
|
|
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
|
+
|
|
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"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
bursar_config_get() {
|
|
61
|
+
local path="$1"
|
|
62
|
+
printf '%s' "$_BURSAR_CONFIG" | jq -r "${path} // empty" 2>/dev/null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
bursar_config_get_json() {
|
|
66
|
+
local path="$1"
|
|
67
|
+
printf '%s' "$_BURSAR_CONFIG" | jq -c "${path}" 2>/dev/null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
bursar_config_enabled() {
|
|
71
|
+
local v
|
|
72
|
+
v=$(bursar_config_get '.bursar.enabled')
|
|
73
|
+
[[ "$v" == "true" ]]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
bursar_config_surface_enabled() {
|
|
77
|
+
# Use the JSON getter, not bursar_config_get: jq's `//` treats a literal
|
|
78
|
+
# `false` as empty, which would mask an explicit opt-out. The JSON getter
|
|
79
|
+
# returns the raw `false`/`true`/`null` so the default-on behavior holds.
|
|
80
|
+
local v
|
|
81
|
+
v=$(bursar_config_get_json '.bursar.surface_at_session_start')
|
|
82
|
+
# Default to surfacing unless explicitly set to false.
|
|
83
|
+
[[ "$v" != "false" ]]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
bursar_config_window() {
|
|
87
|
+
local v
|
|
88
|
+
v=$(bursar_config_get '.bursar.window')
|
|
89
|
+
case "$v" in
|
|
90
|
+
calendar_week) printf 'calendar_week' ;;
|
|
91
|
+
*) printf 'rolling_7d' ;;
|
|
92
|
+
esac
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
bursar_config_week_start() {
|
|
96
|
+
local v
|
|
97
|
+
v=$(bursar_config_get '.bursar.week_start')
|
|
98
|
+
case "$v" in
|
|
99
|
+
sunday) printf 'sunday' ;;
|
|
100
|
+
*) printf 'monday' ;;
|
|
101
|
+
esac
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
bursar_config_min_cost() {
|
|
105
|
+
local v
|
|
106
|
+
v=$(bursar_config_get '.bursar.min_cost_to_surface_usd')
|
|
107
|
+
printf '%s' "${v:-0}"
|
|
108
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Canonical bursar.* 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 (>= 2.7.0,
|
|
6
|
+
# which registers the bursar.* event types) before being appended to
|
|
7
|
+
# ~/.onlooker/logs/onlooker-events.jsonl.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# bursar_emit_event "bursar.session.recorded" '{"project_key":"...",...}' "$SESSION_ID"
|
|
11
|
+
|
|
12
|
+
_BURSAR_PLUGIN_NAME="bursar"
|
|
13
|
+
|
|
14
|
+
_bursar_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
|
+
_bursar_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
|
+
# Emit a single bursar.* event. Returns 0 on success, non-zero on failure.
|
|
44
|
+
# Usage: bursar_emit_event <event_type> <payload_json> [session_id]
|
|
45
|
+
bursar_emit_event() {
|
|
46
|
+
local event_type="${1:-}"
|
|
47
|
+
local payload="${2:-}"
|
|
48
|
+
local session_id="${3:-}"
|
|
49
|
+
|
|
50
|
+
[[ -z "$event_type" || -z "$payload" ]] && return 1
|
|
51
|
+
[[ -z "$session_id" ]] && session_id=$(_bursar_session_id)
|
|
52
|
+
|
|
53
|
+
local event_js
|
|
54
|
+
event_js=$(_bursar_event_js_path) || return 1
|
|
55
|
+
|
|
56
|
+
local params
|
|
57
|
+
params=$(jq -n \
|
|
58
|
+
--arg plugin "$_BURSAR_PLUGIN_NAME" \
|
|
59
|
+
--arg sid "$session_id" \
|
|
60
|
+
--arg type "$event_type" \
|
|
61
|
+
--argjson payload "$payload" \
|
|
62
|
+
'{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
|
|
63
|
+
2>/dev/null) || return 1
|
|
64
|
+
|
|
65
|
+
local event
|
|
66
|
+
local stderr_file
|
|
67
|
+
stderr_file=$(mktemp -t bursar-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/bursar-event-err.$$"
|
|
68
|
+
event=$(printf '%s' "$params" \
|
|
69
|
+
| ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
|
|
70
|
+
ONLOOKER_PLUGIN_NAME="$_BURSAR_PLUGIN_NAME" \
|
|
71
|
+
node "$event_js" emit 2>"$stderr_file") || {
|
|
72
|
+
printf 'bursar_emit_event: schema validation failed for %s\n' "$event_type" >&2
|
|
73
|
+
[[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
|
|
74
|
+
rm -f "$stderr_file"
|
|
75
|
+
return 1
|
|
76
|
+
}
|
|
77
|
+
rm -f "$stderr_file"
|
|
78
|
+
|
|
79
|
+
local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
|
|
80
|
+
mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
|
|
81
|
+
printf '%s\n' "$event" >> "$log_path"
|
|
82
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Per-project, multi-session rollup ledger for bursar.
|
|
3
|
+
#
|
|
4
|
+
# Where governor keeps a per-session ledger under
|
|
5
|
+
# ~/.onlooker/governance/ledgers/<session-id>.jsonl, bursar keeps one ledger
|
|
6
|
+
# per project under:
|
|
7
|
+
#
|
|
8
|
+
# $ONLOOKER_DIR/bursar/projects/<project_key>/sessions.jsonl
|
|
9
|
+
#
|
|
10
|
+
# Each line is one session's spend, recorded once on SessionEnd:
|
|
11
|
+
#
|
|
12
|
+
# { ts, ts_epoch, session_id, project_key,
|
|
13
|
+
# cost_usd?, tokens?, api_calls?, governor_present, model? }
|
|
14
|
+
#
|
|
15
|
+
# cost/tokens/api_calls are omitted when governor was not running for the
|
|
16
|
+
# session (governor_present:false) — bursar degrades to a session count.
|
|
17
|
+
#
|
|
18
|
+
# Records are keyed by session_id: re-recording a session replaces its line
|
|
19
|
+
# rather than appending, so a SessionEnd that fires more than once is
|
|
20
|
+
# idempotent.
|
|
21
|
+
#
|
|
22
|
+
# ts_epoch (seconds) is stored alongside the RFC3339 ts so window filtering is
|
|
23
|
+
# a portable numeric jq compare with no date parsing.
|
|
24
|
+
#
|
|
25
|
+
# Requires portable-lock.sh to be sourced beforehand.
|
|
26
|
+
|
|
27
|
+
BURSAR_LEDGER_LOCK_TIMEOUT=5
|
|
28
|
+
|
|
29
|
+
bursar_ledger_dir() {
|
|
30
|
+
local project_key="${1:-unknown}"
|
|
31
|
+
local safe_key
|
|
32
|
+
safe_key=$(printf '%s' "$project_key" | tr -c 'a-zA-Z0-9-' '_')
|
|
33
|
+
printf '%s/bursar/projects/%s' "${ONLOOKER_DIR:-${HOME}/.onlooker}" "$safe_key"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
bursar_ledger_path() {
|
|
37
|
+
printf '%s/sessions.jsonl' "$(bursar_ledger_dir "$1")"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Current wall-clock helpers (portable across macOS/Linux).
|
|
41
|
+
bursar_now_epoch() { date +%s 2>/dev/null || printf '0'; }
|
|
42
|
+
bursar_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || printf ''; }
|
|
43
|
+
|
|
44
|
+
bursar_epoch_to_iso() {
|
|
45
|
+
local e="${1:-0}"
|
|
46
|
+
date -u -r "$e" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|
|
47
|
+
|| date -u -d "@$e" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|
|
48
|
+
|| printf ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Upsert a session record into the project ledger, keyed by session_id.
|
|
52
|
+
# Usage: bursar_ledger_record "$project_key" "$record_json"
|
|
53
|
+
bursar_ledger_record() {
|
|
54
|
+
local project_key="${1:-}"
|
|
55
|
+
local record="${2:-}"
|
|
56
|
+
[[ -z "$project_key" || -z "$record" ]] && return 1
|
|
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
|
|
64
|
+
|
|
65
|
+
local dir ledger_path lock_path
|
|
66
|
+
dir=$(bursar_ledger_dir "$project_key")
|
|
67
|
+
ledger_path="${dir}/sessions.jsonl"
|
|
68
|
+
lock_path="${ledger_path}.lock"
|
|
69
|
+
mkdir -p "$dir" 2>/dev/null || return 1
|
|
70
|
+
|
|
71
|
+
if ! lock_acquire "$lock_path" "$BURSAR_LEDGER_LOCK_TIMEOUT"; then
|
|
72
|
+
return 1
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
local tmp
|
|
76
|
+
tmp=$(mktemp "${dir}/.sessions.XXXXXX" 2>/dev/null) || { lock_release "$lock_path"; return 1; }
|
|
77
|
+
|
|
78
|
+
if [[ -f "$ledger_path" ]]; then
|
|
79
|
+
# Keep every line whose session_id differs from the one being recorded.
|
|
80
|
+
jq -c --arg sid "$sid" 'select(.session_id != $sid)' "$ledger_path" 2>/dev/null >>"$tmp"
|
|
81
|
+
fi
|
|
82
|
+
printf '%s\n' "$record_compact" >>"$tmp"
|
|
83
|
+
|
|
84
|
+
mv "$tmp" "$ledger_path" 2>/dev/null || { rm -f "$tmp"; lock_release "$lock_path"; return 1; }
|
|
85
|
+
lock_release "$lock_path"
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Compute the inclusive lower-bound epoch for the active window.
|
|
90
|
+
# Usage: bursar_window_cutoff_epoch <rolling_7d|calendar_week> <monday|sunday>
|
|
91
|
+
bursar_window_cutoff_epoch() {
|
|
92
|
+
local window="${1:-rolling_7d}"
|
|
93
|
+
local week_start="${2:-monday}"
|
|
94
|
+
local now
|
|
95
|
+
now=$(bursar_now_epoch)
|
|
96
|
+
|
|
97
|
+
if [[ "$window" == "calendar_week" ]]; then
|
|
98
|
+
# Local midnight today, computed portably from H/M/S (no GNU/BSD-only flags).
|
|
99
|
+
local h m s secs_today today_midnight dow days_since
|
|
100
|
+
h=$(date +%H 2>/dev/null) || h=0
|
|
101
|
+
m=$(date +%M 2>/dev/null) || m=0
|
|
102
|
+
s=$(date +%S 2>/dev/null) || s=0
|
|
103
|
+
secs_today=$(( 10#$h * 3600 + 10#$m * 60 + 10#$s ))
|
|
104
|
+
today_midnight=$(( now - secs_today ))
|
|
105
|
+
|
|
106
|
+
dow=$(date +%u 2>/dev/null) || dow=1 # 1=Mon .. 7=Sun
|
|
107
|
+
if [[ "$week_start" == "sunday" ]]; then
|
|
108
|
+
days_since=$(( dow % 7 )) # Sun(7)->0, Mon(1)->1 .. Sat(6)->6
|
|
109
|
+
else
|
|
110
|
+
days_since=$(( dow - 1 )) # Mon(1)->0 .. Sun(7)->6
|
|
111
|
+
fi
|
|
112
|
+
printf '%d' "$(( today_midnight - days_since * 86400 ))"
|
|
113
|
+
else
|
|
114
|
+
printf '%d' "$(( now - 604800 ))" # rolling trailing 7 days
|
|
115
|
+
fi
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Aggregate spend over the window. Prints a JSON object:
|
|
119
|
+
# {total_cost_usd, total_tokens, session_count, sessions_with_cost}
|
|
120
|
+
# Usage: bursar_window_totals "$project_key" "$cutoff_epoch"
|
|
121
|
+
bursar_window_totals() {
|
|
122
|
+
local project_key="${1:-}"
|
|
123
|
+
local cutoff="${2:-0}"
|
|
124
|
+
local ledger_path
|
|
125
|
+
ledger_path=$(bursar_ledger_path "$project_key")
|
|
126
|
+
|
|
127
|
+
if [[ ! -f "$ledger_path" ]]; then
|
|
128
|
+
printf '{"total_cost_usd":0,"total_tokens":0,"session_count":0,"sessions_with_cost":0}'
|
|
129
|
+
return 0
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
jq -s --argjson cutoff "$cutoff" '
|
|
133
|
+
[ .[] | select((.ts_epoch // 0) >= $cutoff) ] as $w
|
|
134
|
+
| {
|
|
135
|
+
total_cost_usd: ([ $w[] | (.cost_usd // 0) ] | add // 0),
|
|
136
|
+
total_tokens: ([ $w[] | (.tokens // 0) ] | add // 0),
|
|
137
|
+
session_count: ($w | length),
|
|
138
|
+
sessions_with_cost: ([ $w[] | select(.cost_usd != null) ] | length)
|
|
139
|
+
}
|
|
140
|
+
' "$ledger_path" 2>/dev/null \
|
|
141
|
+
|| printf '{"total_cost_usd":0,"total_tokens":0,"session_count":0,"sessions_with_cost":0}'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Human-friendly token count: 1234567 -> "1.2M", 42000 -> "42k", 800 -> "800".
|
|
145
|
+
bursar_fmt_tokens() {
|
|
146
|
+
local n="${1:-0}"
|
|
147
|
+
awk -v n="$n" 'BEGIN {
|
|
148
|
+
if (n >= 1000000) printf "%.1fM", n/1000000;
|
|
149
|
+
else if (n >= 1000) printf "%.0fk", n/1000;
|
|
150
|
+
else printf "%d", n;
|
|
151
|
+
}'
|
|
152
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Project key derivation for bursar.
|
|
3
|
+
#
|
|
4
|
+
# Mirrors the archivist/tribunal project-key scheme so the plugins partition
|
|
5
|
+
# storage identically. A project key is a stable 12-char hex identifier that
|
|
6
|
+
# survives:
|
|
7
|
+
# - local rename of the repo directory
|
|
8
|
+
# - cloning the same repo to a different path on the same machine
|
|
9
|
+
# - moving the repo between machines (as long as the git remote is preserved)
|
|
10
|
+
# - worktrees (a worktree shares its parent repo's key)
|
|
11
|
+
#
|
|
12
|
+
# Resolution order:
|
|
13
|
+
# 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
|
|
14
|
+
# 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
|
|
15
|
+
# without an origin remote (greenfield local-only work)
|
|
16
|
+
#
|
|
17
|
+
# Returns the first 12 hex chars. Returns empty string if neither resolution
|
|
18
|
+
# path works.
|
|
19
|
+
|
|
20
|
+
_bursar_sha256_first12() {
|
|
21
|
+
local input="$1"
|
|
22
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
23
|
+
printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
|
|
24
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
25
|
+
printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
|
|
26
|
+
else
|
|
27
|
+
return 1
|
|
28
|
+
fi
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
bursar_project_remote_url() {
|
|
32
|
+
local cwd="${1:-}"
|
|
33
|
+
[[ -z "$cwd" || ! -d "$cwd" ]] && return 0
|
|
34
|
+
git -C "$cwd" remote get-url origin 2>/dev/null || true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Worktree-aware: uses common-dir so worktrees share a key with the main repo.
|
|
38
|
+
bursar_project_repo_root() {
|
|
39
|
+
local cwd="${1:-}"
|
|
40
|
+
[[ -z "$cwd" || ! -d "$cwd" ]] && return 0
|
|
41
|
+
|
|
42
|
+
if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
43
|
+
return 0
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
local common_dir toplevel
|
|
47
|
+
common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
|
|
48
|
+
|
|
49
|
+
if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
|
|
50
|
+
common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
if [[ -n "$common_dir" && -d "$common_dir" ]]; then
|
|
54
|
+
toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
if [[ -z "$toplevel" ]]; then
|
|
58
|
+
toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
|
|
59
|
+
[[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
printf '%s' "$toplevel"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Compute the project key for the given cwd. Prints the key or empty string.
|
|
66
|
+
bursar_project_key() {
|
|
67
|
+
local cwd="${1:-}"
|
|
68
|
+
[[ -z "$cwd" ]] && cwd="$(pwd)"
|
|
69
|
+
|
|
70
|
+
local remote
|
|
71
|
+
remote=$(bursar_project_remote_url "$cwd")
|
|
72
|
+
if [[ -n "$remote" ]]; then
|
|
73
|
+
_bursar_sha256_first12 "remote:$remote"
|
|
74
|
+
return 0
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
local root
|
|
78
|
+
root=$(bursar_project_repo_root "$cwd")
|
|
79
|
+
if [[ -n "$root" ]]; then
|
|
80
|
+
_bursar_sha256_first12 "root:$root"
|
|
81
|
+
return 0
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Minimal ULID generator for bursar record ids.
|
|
3
|
+
#
|
|
4
|
+
# Spec: https://github.com/ulid/spec
|
|
5
|
+
# - 48-bit timestamp (ms since epoch) → 10 chars Crockford Base32
|
|
6
|
+
# - 80-bit randomness → 16 chars Crockford Base32
|
|
7
|
+
# - lexicographically sortable, time-ordered
|
|
8
|
+
#
|
|
9
|
+
# Copied from plugins/tribunal/scripts/lib/tribunal-ulid.sh and renamed; the
|
|
10
|
+
# ecosystem ships one *_ulid helper per plugin rather than a shared one.
|
|
11
|
+
|
|
12
|
+
_BURSAR_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
13
|
+
|
|
14
|
+
_bursar_ulid_encode() {
|
|
15
|
+
local n="$1"
|
|
16
|
+
local len="$2"
|
|
17
|
+
local out=""
|
|
18
|
+
local i
|
|
19
|
+
for ((i = 0; i < len; i++)); do
|
|
20
|
+
out="${_BURSAR_ULID_ALPHABET:$((n % 32)):1}${out}"
|
|
21
|
+
n=$((n / 32))
|
|
22
|
+
done
|
|
23
|
+
printf '%s' "$out"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
bursar_ulid() {
|
|
27
|
+
local now_ms
|
|
28
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
29
|
+
now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
|
|
30
|
+
|| now_ms=$(($(date +%s) * 1000))
|
|
31
|
+
else
|
|
32
|
+
now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
local rand_hex rand_hi rand_lo
|
|
36
|
+
rand_hex=$(openssl rand -hex 10 2>/dev/null)
|
|
37
|
+
if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
|
|
38
|
+
rand_hi=$((16#${rand_hex:0:10}))
|
|
39
|
+
rand_lo=$((16#${rand_hex:10:10}))
|
|
40
|
+
else
|
|
41
|
+
rand_hi=$((RANDOM * 32768 + RANDOM))
|
|
42
|
+
rand_lo=$((RANDOM * 32768 + RANDOM))
|
|
43
|
+
rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
44
|
+
rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
local ts_part hi_part lo_part
|
|
48
|
+
ts_part=$(_bursar_ulid_encode "$now_ms" 10)
|
|
49
|
+
hi_part=$(_bursar_ulid_encode "$rand_hi" 8)
|
|
50
|
+
lo_part=$(_bursar_ulid_encode "$rand_lo" 8)
|
|
51
|
+
|
|
52
|
+
printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
|
|
53
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# portable-lock.sh — vendored copy of the ecosystem substrate's portable lock.
|
|
3
|
+
#
|
|
4
|
+
# Vendored into the bursar plugin so the per-project ledger's atomic upserts
|
|
5
|
+
# keep working when bursar is installed standalone from the marketplace: the
|
|
6
|
+
# cache layout (~/.claude/plugins/cache/<owner>/bursar/<version>/) does not
|
|
7
|
+
# include the ecosystem repo's top-level scripts/lib/. Without a local copy,
|
|
8
|
+
# lock_acquire would be undefined and the SessionEnd upsert could clobber a
|
|
9
|
+
# concurrent writer. This mirrors the per-plugin vendoring of bursar-ulid.sh
|
|
10
|
+
# and friends.
|
|
11
|
+
# Keep in sync with scripts/lib/portable-lock.sh at the repo root.
|
|
12
|
+
#
|
|
13
|
+
# Portable advisory file locking via mkdir() atomicity.
|
|
14
|
+
#
|
|
15
|
+
# Replaces flock(1), which ships with util-linux on Linux but is not present
|
|
16
|
+
# in stock macOS. This matters because the Onlooker hooks run on user
|
|
17
|
+
# machines, not just in CI: a macOS user without util-linux would otherwise
|
|
18
|
+
# see concurrent writes to $ONLOOKER_DIR silently clobber each other.
|
|
19
|
+
#
|
|
20
|
+
# mkdir() is atomic on POSIX local filesystems, which is the only place
|
|
21
|
+
# $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
|
|
22
|
+
# atomicity, but Claude Code state is local-only.
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
# lock_acquire "/path/to/file.lock" [timeout_seconds=5]
|
|
26
|
+
# # ... critical section ...
|
|
27
|
+
# lock_release "/path/to/file.lock"
|
|
28
|
+
#
|
|
29
|
+
# Avoid associative arrays so bash 3.2 (macOS default) keeps working.
|
|
30
|
+
|
|
31
|
+
# Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
|
|
32
|
+
lock_acquire() {
|
|
33
|
+
local lockpath="${1:-}"
|
|
34
|
+
local timeout="${2:-5}"
|
|
35
|
+
[[ -z "$lockpath" ]] && return 1
|
|
36
|
+
|
|
37
|
+
local lockdir="${lockpath}.d"
|
|
38
|
+
local waited=0
|
|
39
|
+
# Poll at 10 Hz so a 5s timeout = 50 attempts.
|
|
40
|
+
local max_iter=$((timeout * 10))
|
|
41
|
+
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
42
|
+
if ((waited >= max_iter)); then
|
|
43
|
+
return 1
|
|
44
|
+
fi
|
|
45
|
+
# `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
|
|
46
|
+
# fallback for embedded shells that only accept integer seconds.
|
|
47
|
+
sleep 0.1 2>/dev/null || sleep 1
|
|
48
|
+
waited=$((waited + 1))
|
|
49
|
+
done
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Release the lock previously acquired for LOCKPATH. Safe to call when the
|
|
54
|
+
# lock is not held (no-op in that case).
|
|
55
|
+
lock_release() {
|
|
56
|
+
local lockpath="${1:-}"
|
|
57
|
+
[[ -z "$lockpath" ]] && return 0
|
|
58
|
+
rmdir "${lockpath}.d" 2>/dev/null || true
|
|
59
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "counsel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.1](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.3.0...counsel-v0.3.1) (2026-06-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **counsel:** drop unsupported --max-tokens flag from claude synthesis call :relieved: ([#79](https://github.com/onlooker-community/ecosystem/issues/79)) ([ade85ce](https://github.com/onlooker-community/ecosystem/commit/ade85cecb3243781f47e14fea4990ce31e69e8f4))
|
|
9
|
+
* **counsel:** stop pipefail from discarding all events on large logs :relieved: ([#78](https://github.com/onlooker-community/ecosystem/issues/78)) ([638347d](https://github.com/onlooker-community/ecosystem/commit/638347dec3b9df740b7a85c3e475fa2ffe5d054b))
|
|
10
|
+
|
|
3
11
|
## [0.3.0](https://github.com/onlooker-community/ecosystem/compare/counsel-v0.2.0...counsel-v0.3.0) (2026-06-11)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -46,9 +46,15 @@ counsel_read_events() {
|
|
|
46
46
|
# Filter to events within the lookback window. If cutoff_ts is empty (date
|
|
47
47
|
# command unavailable) fall through and include all events.
|
|
48
48
|
local summary
|
|
49
|
+
# Run inside a subshell with pipefail disabled. head -c closes the pipe once
|
|
50
|
+
# chars_max bytes have arrived, which sends jq SIGPIPE; under the caller's
|
|
51
|
+
# `set -o pipefail` (the SessionStart hook and the /counsel skill both set it)
|
|
52
|
+
# that marks the whole pipeline failed, and the `|| summary=""` fallback would
|
|
53
|
+
# then discard *every* event on any log large enough to exceed chars_max.
|
|
54
|
+
# Disabling pipefail locally keeps the truncated output.
|
|
49
55
|
# -rc: compact output keeps each object on one line (JSONL-shaped), which
|
|
50
56
|
# downstream counsel_count_events and counsel_sources_from_events require.
|
|
51
|
-
summary=$(jq -rc --arg cutoff "$cutoff_ts" '
|
|
57
|
+
summary=$(set +o pipefail; jq -rc --arg cutoff "$cutoff_ts" '
|
|
52
58
|
select(.timestamp != null) |
|
|
53
59
|
select($cutoff == "" or .timestamp >= $cutoff) |
|
|
54
60
|
{
|
|
@@ -53,7 +53,11 @@ counsel_synthesize() {
|
|
|
53
53
|
local events_text="${1:-}"
|
|
54
54
|
local model="${2:-claude-haiku-4-5-20251001}"
|
|
55
55
|
local timeout_s="${3:-90}"
|
|
56
|
+
# shellcheck disable=SC2034 # accepted for call-site compatibility; the
|
|
57
|
+
# claude CLI print mode exposes no max-tokens/temperature flags, so neither
|
|
58
|
+
# is forwarded (see claude_args below).
|
|
56
59
|
local max_tokens="${4:-4096}"
|
|
60
|
+
# shellcheck disable=SC2034
|
|
57
61
|
local temperature="${5:-0.4}"
|
|
58
62
|
|
|
59
63
|
[[ -z "$events_text" ]] && return 1
|
|
@@ -74,7 +78,10 @@ counsel_synthesize() {
|
|
|
74
78
|
printf '</event_log>\n'
|
|
75
79
|
} > "$prompt_file"
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
# NOTE: `claude -p` does not accept --max-tokens (it errors with "unknown
|
|
82
|
+
# option") and has no temperature flag, so we pass neither. Output length is
|
|
83
|
+
# governed by the model/prompt; the synthesis prompt asks for terse JSON.
|
|
84
|
+
local claude_args=(-p --max-turns 1 --model "$model")
|
|
78
85
|
|
|
79
86
|
local response=""
|
|
80
87
|
if command -v timeout >/dev/null 2>&1; then
|
|
@@ -222,6 +222,22 @@
|
|
|
222
222
|
"jsonpath": "$.version"
|
|
223
223
|
}
|
|
224
224
|
]
|
|
225
|
+
},
|
|
226
|
+
"plugins/bursar": {
|
|
227
|
+
"changelog-path": "CHANGELOG.md",
|
|
228
|
+
"release-type": "simple",
|
|
229
|
+
"bump-minor-pre-major": true,
|
|
230
|
+
"bump-patch-for-minor-pre-major": false,
|
|
231
|
+
"component": "bursar",
|
|
232
|
+
"draft": false,
|
|
233
|
+
"prerelease": false,
|
|
234
|
+
"extra-files": [
|
|
235
|
+
{
|
|
236
|
+
"type": "json",
|
|
237
|
+
"path": ".claude-plugin/plugin.json",
|
|
238
|
+
"jsonpath": "$.version"
|
|
239
|
+
}
|
|
240
|
+
]
|
|
225
241
|
}
|
|
226
242
|
},
|
|
227
243
|
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|