@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.
Files changed (33) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +4 -3
  4. package/CHANGELOG.md +15 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/architecture.md +4 -0
  7. package/package.json +3 -3
  8. package/plugins/bursar/.claude-plugin/plugin.json +14 -0
  9. package/plugins/bursar/CHANGELOG.md +10 -0
  10. package/plugins/bursar/README.md +100 -0
  11. package/plugins/bursar/config.json +11 -0
  12. package/plugins/bursar/hooks/hooks.json +26 -0
  13. package/plugins/bursar/scripts/hooks/bursar-session-end.sh +142 -0
  14. package/plugins/bursar/scripts/hooks/bursar-session-start.sh +130 -0
  15. package/plugins/bursar/scripts/lib/bursar-config.sh +108 -0
  16. package/plugins/bursar/scripts/lib/bursar-events.sh +82 -0
  17. package/plugins/bursar/scripts/lib/bursar-ledger.sh +152 -0
  18. package/plugins/bursar/scripts/lib/bursar-project-key.sh +85 -0
  19. package/plugins/bursar/scripts/lib/bursar-ulid.sh +53 -0
  20. package/plugins/bursar/scripts/lib/portable-lock.sh +59 -0
  21. package/plugins/counsel/.claude-plugin/plugin.json +1 -1
  22. package/plugins/counsel/CHANGELOG.md +8 -0
  23. package/plugins/counsel/scripts/lib/counsel-reader.sh +7 -1
  24. package/plugins/counsel/scripts/lib/counsel-synthesize.sh +8 -1
  25. package/release-please-config.json +16 -0
  26. package/test/bats/bursar-config.bats +79 -0
  27. package/test/bats/bursar-events.bats +73 -0
  28. package/test/bats/bursar-ledger.bats +116 -0
  29. package/test/bats/bursar-project-key.bats +51 -0
  30. package/test/bats/bursar-session-end.bats +131 -0
  31. package/test/bats/bursar-session-start.bats +126 -0
  32. package/test/bats/bursar-ulid.bats +28 -0
  33. 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.0",
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
- local claude_args=(-p --max-turns 1 --model "$model" --max-tokens "$max_tokens")
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"