@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,79 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/bursar-config.sh"
11
+ }
12
+
13
+ @test "bursar is disabled by default" {
14
+ bursar_config_load ""
15
+ run bursar_config_enabled
16
+ [ "$status" -ne 0 ]
17
+ }
18
+
19
+ @test "user-level settings.json can enable bursar" {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ bursar_config_load ""
23
+ run bursar_config_enabled
24
+ [ "$status" -eq 0 ]
25
+ }
26
+
27
+ @test "repo-level settings.json overrides user-level" {
28
+ mkdir -p "${HOME}/.claude"
29
+ printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
30
+ local repo="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "${repo}/.claude"
32
+ printf '%s\n' '{"bursar":{"enabled":false}}' > "${repo}/.claude/settings.json"
33
+ bursar_config_load "$repo"
34
+ run bursar_config_enabled
35
+ [ "$status" -ne 0 ]
36
+ }
37
+
38
+ @test "default window is rolling_7d" {
39
+ bursar_config_load ""
40
+ [ "$(bursar_config_window)" = "rolling_7d" ]
41
+ }
42
+
43
+ @test "window can be set to calendar_week" {
44
+ mkdir -p "${HOME}/.claude"
45
+ printf '%s\n' '{"bursar":{"window":"calendar_week"}}' > "${HOME}/.claude/settings.json"
46
+ bursar_config_load ""
47
+ [ "$(bursar_config_window)" = "calendar_week" ]
48
+ }
49
+
50
+ @test "an invalid window falls back to rolling_7d" {
51
+ mkdir -p "${HOME}/.claude"
52
+ printf '%s\n' '{"bursar":{"window":"yearly"}}' > "${HOME}/.claude/settings.json"
53
+ bursar_config_load ""
54
+ [ "$(bursar_config_window)" = "rolling_7d" ]
55
+ }
56
+
57
+ @test "default week_start is monday" {
58
+ bursar_config_load ""
59
+ [ "$(bursar_config_week_start)" = "monday" ]
60
+ }
61
+
62
+ @test "week_start can be set to sunday" {
63
+ mkdir -p "${HOME}/.claude"
64
+ printf '%s\n' '{"bursar":{"week_start":"sunday"}}' > "${HOME}/.claude/settings.json"
65
+ bursar_config_load ""
66
+ [ "$(bursar_config_week_start)" = "sunday" ]
67
+ }
68
+
69
+ @test "surfacing is on by default and can be disabled" {
70
+ bursar_config_load ""
71
+ run bursar_config_surface_enabled
72
+ [ "$status" -eq 0 ]
73
+
74
+ mkdir -p "${HOME}/.claude"
75
+ printf '%s\n' '{"bursar":{"surface_at_session_start":false}}' > "${HOME}/.claude/settings.json"
76
+ bursar_config_load ""
77
+ run bursar_config_surface_enabled
78
+ [ "$status" -ne 0 ]
79
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Validates that bursar.* events pass @onlooker-community/schema validation.
4
+
5
+ setup() {
6
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
7
+ setup_test_env
8
+
9
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
10
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
11
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
12
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
13
+
14
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
15
+
16
+ # shellcheck disable=SC1091
17
+ source "${PLUGIN_ROOT}/scripts/lib/bursar-events.sh"
18
+
19
+ export CLAUDE_SESSION_ID="bats-bursar-session-$$"
20
+ PK="proj0123abcd"
21
+ SID="bats-bursar-sid-000"
22
+ }
23
+
24
+ _validate_latest_event() {
25
+ local last
26
+ last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
27
+ [ -n "$last" ] || return 1
28
+ printf '%s' "$last" | ONLOOKER_DIR="$ONLOOKER_DIR" \
29
+ node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
30
+ }
31
+
32
+ @test "bursar.session.recorded with governor present validates" {
33
+ local p
34
+ p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
35
+ '{project_key:$pk, session_id:$sid, governor_present:true,
36
+ cost_usd:0.42, tokens:42000, api_calls:12, model:"claude-opus-4-8"}')
37
+ bursar_emit_event "bursar.session.recorded" "$p" "$SID"
38
+ run _validate_latest_event
39
+ [ "$status" -eq 0 ]
40
+ }
41
+
42
+ @test "bursar.session.recorded with governor absent validates" {
43
+ local p
44
+ p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
45
+ '{project_key:$pk, session_id:$sid, governor_present:false}')
46
+ bursar_emit_event "bursar.session.recorded" "$p" "$SID"
47
+ run _validate_latest_event
48
+ [ "$status" -eq 0 ]
49
+ }
50
+
51
+ @test "bursar.rollup.surfaced validates" {
52
+ local p
53
+ p=$(jq -n --arg pk "$PK" \
54
+ '{project_key:$pk, window:"rolling_7d", total_cost_usd:3.17,
55
+ session_count:8, total_tokens:310000, sessions_with_cost:7,
56
+ window_start:"2026-06-05T00:00:00Z"}')
57
+ bursar_emit_event "bursar.rollup.surfaced" "$p" "$SID"
58
+ run _validate_latest_event
59
+ [ "$status" -eq 0 ]
60
+ }
61
+
62
+ @test "bursar.rollup.skipped validates" {
63
+ local p
64
+ p=$(jq -n --arg pk "$PK" '{reason:"no_data", project_key:$pk}')
65
+ bursar_emit_event "bursar.rollup.skipped" "$p" "$SID"
66
+ run _validate_latest_event
67
+ [ "$status" -eq 0 ]
68
+ }
69
+
70
+ @test "bursar_emit_event returns nonzero for an unknown event type" {
71
+ run bursar_emit_event "bursar.no_such_event" '{"project_key":"x"}' "$SID"
72
+ [ "$status" -ne 0 ]
73
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
8
+ # shellcheck disable=SC1091
9
+ source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
10
+ # shellcheck disable=SC1091
11
+ source "${PLUGIN_ROOT}/scripts/lib/bursar-ledger.sh"
12
+
13
+ KEY="proj0123abcd"
14
+ }
15
+
16
+ _record() {
17
+ # _record <session_id> <cost-or-empty> <tokens-or-empty> <ts_epoch>
18
+ local sid="$1" cost="$2" tokens="$3" ts="$4"
19
+ local rec
20
+ rec=$(jq -n --arg sid "$sid" --arg pk "$KEY" --argjson te "$ts" \
21
+ '{ts:"x", ts_epoch:$te, session_id:$sid, project_key:$pk, governor_present:true}')
22
+ [ -n "$cost" ] && rec=$(printf '%s' "$rec" | jq --argjson v "$cost" '. + {cost_usd:$v}')
23
+ [ -n "$tokens" ] && rec=$(printf '%s' "$rec" | jq --argjson v "$tokens" '. + {tokens:$v}')
24
+ printf '%s' "$rec"
25
+ }
26
+
27
+ @test "recording a session creates a single ledger line" {
28
+ local now
29
+ now=$(date +%s)
30
+ bursar_ledger_record "$KEY" "$(_record s1 1.0 100 "$now")"
31
+ local path
32
+ path=$(bursar_ledger_path "$KEY")
33
+ [ -f "$path" ]
34
+ [ "$(wc -l < "$path")" -eq 1 ]
35
+ }
36
+
37
+ @test "re-recording the same session upserts in place (idempotent)" {
38
+ local now
39
+ now=$(date +%s)
40
+ bursar_ledger_record "$KEY" "$(_record s1 1.0 100 "$now")"
41
+ bursar_ledger_record "$KEY" "$(_record s1 2.5 200 "$now")"
42
+ local path
43
+ path=$(bursar_ledger_path "$KEY")
44
+ [ "$(wc -l < "$path")" -eq 1 ]
45
+ [ "$(jq -r '.cost_usd' "$path")" = "2.5" ]
46
+ }
47
+
48
+ @test "different sessions append distinct lines" {
49
+ local now
50
+ now=$(date +%s)
51
+ bursar_ledger_record "$KEY" "$(_record s1 1.0 100 "$now")"
52
+ bursar_ledger_record "$KEY" "$(_record s2 2.0 200 "$now")"
53
+ [ "$(wc -l < "$(bursar_ledger_path "$KEY")")" -eq 2 ]
54
+ }
55
+
56
+ @test "rolling_7d cutoff is roughly now minus seven days" {
57
+ local now cutoff diff
58
+ now=$(date +%s)
59
+ cutoff=$(bursar_window_cutoff_epoch "rolling_7d" "monday")
60
+ diff=$(( now - cutoff ))
61
+ # 7 days = 604800s; allow a couple of seconds of clock drift across calls.
62
+ [ "$diff" -ge 604798 ]
63
+ [ "$diff" -le 604803 ]
64
+ }
65
+
66
+ @test "calendar_week cutoff is at or before now and not in the future" {
67
+ local now cutoff
68
+ now=$(date +%s)
69
+ cutoff=$(bursar_window_cutoff_epoch "calendar_week" "monday")
70
+ [ "$cutoff" -le "$now" ]
71
+ # Never more than a full week back.
72
+ [ "$(( now - cutoff ))" -le 604800 ]
73
+ }
74
+
75
+ @test "window totals sum cost and tokens, count sessions, and track cost coverage" {
76
+ local now in1 in2 out
77
+ now=$(date +%s)
78
+ in1=$(( now - 100 ))
79
+ in2=$(( now - 200 ))
80
+ out=$(( now - 700000 )) # older than 7 days
81
+
82
+ local dir
83
+ dir=$(bursar_ledger_dir "$KEY")
84
+ mkdir -p "$dir"
85
+ {
86
+ _record withcost 1.0 100 "$in1"
87
+ printf '\n'
88
+ # governor absent: no cost_usd, no tokens
89
+ jq -nc --arg pk "$KEY" --argjson te "$in2" \
90
+ '{ts:"x", ts_epoch:$te, session_id:"nocost", project_key:$pk, governor_present:false}'
91
+ _record stale 50.0 9999 "$out"
92
+ printf '\n'
93
+ } > "${dir}/sessions.jsonl"
94
+
95
+ local cutoff totals
96
+ cutoff=$(bursar_window_cutoff_epoch "rolling_7d" "monday")
97
+ totals=$(bursar_window_totals "$KEY" "$cutoff")
98
+
99
+ [ "$(printf '%s' "$totals" | jq -r '.total_cost_usd')" = "1" ]
100
+ [ "$(printf '%s' "$totals" | jq -r '.total_tokens')" = "100" ]
101
+ [ "$(printf '%s' "$totals" | jq -r '.session_count')" = "2" ]
102
+ [ "$(printf '%s' "$totals" | jq -r '.sessions_with_cost')" = "1" ]
103
+ }
104
+
105
+ @test "window totals are zero when no ledger exists" {
106
+ local totals
107
+ totals=$(bursar_window_totals "nonexistent000" "0")
108
+ [ "$(printf '%s' "$totals" | jq -r '.session_count')" = "0" ]
109
+ [ "$(printf '%s' "$totals" | jq -r '.total_cost_usd')" = "0" ]
110
+ }
111
+
112
+ @test "token formatting is human-friendly" {
113
+ [ "$(bursar_fmt_tokens 800)" = "800" ]
114
+ [ "$(bursar_fmt_tokens 42000)" = "42k" ]
115
+ [ "$(bursar_fmt_tokens 3100000)" = "3.1M" ]
116
+ }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
8
+ # shellcheck disable=SC1091
9
+ source "${PLUGIN_ROOT}/scripts/lib/bursar-project-key.sh"
10
+ }
11
+
12
+ _mk_repo_with_remote() {
13
+ local dir="$1" url="$2"
14
+ mkdir -p "$dir"
15
+ git init -q "$dir" 2>/dev/null
16
+ git -C "$dir" remote add origin "$url" 2>/dev/null
17
+ }
18
+
19
+ @test "key is a 12-char hex string for a repo with a remote" {
20
+ local repo="${BATS_TEST_TMPDIR}/repo-a"
21
+ _mk_repo_with_remote "$repo" "https://example.com/onlooker/a.git"
22
+ run bursar_project_key "$repo"
23
+ [ "$status" -eq 0 ]
24
+ [ "${#output}" -eq 12 ]
25
+ [[ "$output" =~ ^[0-9a-f]{12}$ ]]
26
+ }
27
+
28
+ @test "same cwd yields a stable key" {
29
+ local repo="${BATS_TEST_TMPDIR}/repo-b"
30
+ _mk_repo_with_remote "$repo" "https://example.com/onlooker/b.git"
31
+ local a b
32
+ a=$(bursar_project_key "$repo")
33
+ b=$(bursar_project_key "$repo")
34
+ [ -n "$a" ]
35
+ [ "$a" = "$b" ]
36
+ }
37
+
38
+ @test "different remotes yield different keys" {
39
+ local r1="${BATS_TEST_TMPDIR}/repo-c" r2="${BATS_TEST_TMPDIR}/repo-d"
40
+ _mk_repo_with_remote "$r1" "https://example.com/onlooker/c.git"
41
+ _mk_repo_with_remote "$r2" "https://example.com/onlooker/d.git"
42
+ [ "$(bursar_project_key "$r1")" != "$(bursar_project_key "$r2")" ]
43
+ }
44
+
45
+ @test "empty key for a non-git directory" {
46
+ local plain="${BATS_TEST_TMPDIR}/not-a-repo"
47
+ mkdir -p "$plain"
48
+ run bursar_project_key "$plain"
49
+ [ "$status" -eq 0 ]
50
+ [ -z "$output" ]
51
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises the SessionEnd hook end-to-end against an isolated $ONLOOKER_DIR.
4
+
5
+ setup() {
6
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
7
+ setup_test_env
8
+
9
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
10
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/bursar-session-end.sh"
11
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
12
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
13
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
14
+
15
+ SID="bats-end-001"
16
+ PK="projendabcd12"
17
+ }
18
+
19
+ _enable() {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ }
23
+
24
+ _breadcrumb() {
25
+ local dir="${ONLOOKER_DIR}/bursar/sessions"
26
+ mkdir -p "$dir"
27
+ jq -n --arg pk "$PK" '{project_key:$pk, cwd:"/tmp", started_at:"x"}' > "${dir}/${SID}.json"
28
+ }
29
+
30
+ _seed_governor_event() {
31
+ # A minimal but well-formed governor.session.complete envelope line.
32
+ jq -nc --arg sid "$SID" \
33
+ '{event_type:"governor.session.complete", plugin:"governor", session_id:"outer",
34
+ payload:{session_id:$sid, total_cost_usd:0.42, total_tokens:42000, total_api_calls:12,
35
+ budget_usd:1.0, under_budget:true, duration_ms:0, calls_blocked:0,
36
+ calls_warned:0, ledger_poisoned:false}}' >> "$ONLOOKER_EVENTS_LOG"
37
+ }
38
+
39
+ _ledger_path() { printf '%s/bursar/projects/%s/sessions.jsonl' "$ONLOOKER_DIR" "$PK"; }
40
+
41
+ _run_hook() {
42
+ printf '%s' "{\"session_id\":\"$SID\"}" > "${BATS_TEST_TMPDIR}/in.json"
43
+ run bash "$HOOK" < "${BATS_TEST_TMPDIR}/in.json"
44
+ }
45
+
46
+ @test "records a session's spend from governor.session.complete" {
47
+ _enable
48
+ _breadcrumb
49
+ _seed_governor_event
50
+ _run_hook
51
+ [ "$status" -eq 0 ]
52
+
53
+ local path
54
+ path=$(_ledger_path)
55
+ [ -f "$path" ]
56
+ [ "$(wc -l < "$path")" -eq 1 ]
57
+ [ "$(jq -r '.cost_usd' "$path")" = "0.42" ]
58
+ [ "$(jq -r '.tokens' "$path")" = "42000" ]
59
+ [ "$(jq -r '.api_calls' "$path")" = "12" ]
60
+ [ "$(jq -r '.governor_present' "$path")" = "true" ]
61
+ [ "$(jq -r '.session_id' "$path")" = "$SID" ]
62
+ }
63
+
64
+ @test "removes the breadcrumb after recording" {
65
+ _enable
66
+ _breadcrumb
67
+ _seed_governor_event
68
+ _run_hook
69
+ [ ! -f "${ONLOOKER_DIR}/bursar/sessions/${SID}.json" ]
70
+ }
71
+
72
+ @test "degrades to governor_present:false when no governor event exists" {
73
+ _enable
74
+ _breadcrumb
75
+ # no governor.session.complete seeded
76
+ _run_hook
77
+ [ "$status" -eq 0 ]
78
+
79
+ local path
80
+ path=$(_ledger_path)
81
+ [ -f "$path" ]
82
+ [ "$(jq -r '.governor_present' "$path")" = "false" ]
83
+ [ "$(jq -r 'has("cost_usd")' "$path")" = "false" ]
84
+ }
85
+
86
+ @test "is idempotent across a repeated SessionEnd" {
87
+ _enable
88
+ _breadcrumb
89
+ _seed_governor_event
90
+ _run_hook
91
+ # Breadcrumb is gone now; re-create it to simulate a second SessionEnd.
92
+ _breadcrumb
93
+ _run_hook
94
+ [ "$(wc -l < "$(_ledger_path)")" -eq 1 ]
95
+ }
96
+
97
+ @test "writes nothing when bursar is disabled" {
98
+ # bursar disabled (no settings written)
99
+ _breadcrumb
100
+ _seed_governor_event
101
+ _run_hook
102
+ [ "$status" -eq 0 ]
103
+ [ ! -d "${ONLOOKER_DIR}/bursar/projects" ]
104
+ }
105
+
106
+ @test "keeps the breadcrumb and emits nothing when the ledger write fails" {
107
+ _enable
108
+ _breadcrumb
109
+ _seed_governor_event
110
+ # Force bursar_ledger_record to fail: a file where the projects dir must go,
111
+ # so its `mkdir -p` cannot create the project directory.
112
+ mkdir -p "${ONLOOKER_DIR}/bursar"
113
+ printf 'x' > "${ONLOOKER_DIR}/bursar/projects"
114
+ _run_hook
115
+ [ "$status" -eq 0 ]
116
+ # Breadcrumb retained so the attribution survives for a later attempt.
117
+ [ -f "${ONLOOKER_DIR}/bursar/sessions/${SID}.json" ]
118
+ # No false "recorded" event.
119
+ run grep -c '"event_type":"bursar.session.recorded"' "$ONLOOKER_EVENTS_LOG"
120
+ [ "$output" -eq 0 ]
121
+ }
122
+
123
+ @test "emits bursar.session.recorded" {
124
+ _enable
125
+ _breadcrumb
126
+ _seed_governor_event
127
+ _run_hook
128
+ run grep -c '"event_type":"bursar.session.recorded"' "$ONLOOKER_EVENTS_LOG"
129
+ [ "$status" -eq 0 ]
130
+ [ "$output" -ge 1 ]
131
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises the SessionStart hook end-to-end against an isolated $ONLOOKER_DIR.
4
+
5
+ setup() {
6
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
7
+ setup_test_env
8
+
9
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
10
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/bursar-session-start.sh"
11
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
12
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
13
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
14
+
15
+ # shellcheck disable=SC1091
16
+ source "${PLUGIN_ROOT}/scripts/lib/bursar-project-key.sh"
17
+
18
+ # A real git repo so a project key resolves.
19
+ REPO="${BATS_TEST_TMPDIR}/proj"
20
+ mkdir -p "$REPO"
21
+ git init -q "$REPO" 2>/dev/null
22
+ git -C "$REPO" remote add origin https://example.com/onlooker/bursar-test.git 2>/dev/null
23
+ KEY=$(bursar_project_key "$REPO")
24
+
25
+ SID="bats-start-001"
26
+ }
27
+
28
+ _enable() {
29
+ mkdir -p "${HOME}/.claude"
30
+ printf '%s\n' '{"bursar":{"enabled":true}}' > "${HOME}/.claude/settings.json"
31
+ }
32
+
33
+ _seed_ledger() {
34
+ # One recent recorded session with cost.
35
+ local dir="${ONLOOKER_DIR}/bursar/projects/${KEY}"
36
+ mkdir -p "$dir"
37
+ local now
38
+ now=$(date +%s)
39
+ jq -nc --arg pk "$KEY" --argjson te "$now" \
40
+ '{ts:"x", ts_epoch:$te, session_id:"older", project_key:$pk,
41
+ governor_present:true, cost_usd:0.42, tokens:42000, api_calls:12}' \
42
+ > "${dir}/sessions.jsonl"
43
+ }
44
+
45
+ _run_hook() {
46
+ printf '%s' "{\"session_id\":\"$SID\",\"cwd\":\"$REPO\",\"source\":\"startup\"}" \
47
+ > "${BATS_TEST_TMPDIR}/in.json"
48
+ run bash "$HOOK" < "${BATS_TEST_TMPDIR}/in.json"
49
+ }
50
+
51
+ @test "produces no output when bursar is disabled" {
52
+ _run_hook
53
+ [ "$status" -eq 0 ]
54
+ [ -z "$output" ]
55
+ }
56
+
57
+ @test "writes a breadcrumb carrying the project key" {
58
+ _enable
59
+ _run_hook
60
+ [ "$status" -eq 0 ]
61
+ local bc="${ONLOOKER_DIR}/bursar/sessions/${SID}.json"
62
+ [ -f "$bc" ]
63
+ [ "$(jq -r '.project_key' "$bc")" = "$KEY" ]
64
+ }
65
+
66
+ @test "surfaces no additionalContext when the window is empty" {
67
+ _enable
68
+ _run_hook
69
+ [ "$status" -eq 0 ]
70
+ [[ "$output" != *"hookSpecificOutput"* ]]
71
+ }
72
+
73
+ @test "emits bursar.rollup.skipped when there is no data" {
74
+ _enable
75
+ _run_hook
76
+ run grep -c '"event_type":"bursar.rollup.skipped"' "$ONLOOKER_EVENTS_LOG"
77
+ [ "$status" -eq 0 ]
78
+ [ "$output" -ge 1 ]
79
+ }
80
+
81
+ @test "surfaces the burned total as SessionStart additionalContext" {
82
+ _enable
83
+ _seed_ledger
84
+ _run_hook
85
+ [ "$status" -eq 0 ]
86
+ [[ "$output" == *"hookSpecificOutput"* ]]
87
+ [[ "$output" == *"SessionStart"* ]]
88
+ [[ "$output" == *"burned \$0.42"* ]]
89
+ }
90
+
91
+ @test "reports a tracked \$0.00 total without nudging to enable governor" {
92
+ _enable
93
+ # governor present, but the window's cost is legitimately zero
94
+ local dir="${ONLOOKER_DIR}/bursar/projects/${KEY}"
95
+ mkdir -p "$dir"
96
+ local now
97
+ now=$(date +%s)
98
+ jq -nc --arg pk "$KEY" --argjson te "$now" \
99
+ '{ts:"x", ts_epoch:$te, session_id:"zero", project_key:$pk,
100
+ governor_present:true, cost_usd:0, tokens:0, api_calls:0}' \
101
+ > "${dir}/sessions.jsonl"
102
+ _run_hook
103
+ [ "$status" -eq 0 ]
104
+ [[ "$output" == *"burned \$0.00"* ]]
105
+ [[ "$output" != *"Enable governor"* ]]
106
+ }
107
+
108
+ @test "emits bursar.rollup.surfaced when data exists" {
109
+ _enable
110
+ _seed_ledger
111
+ _run_hook
112
+ run grep -c '"event_type":"bursar.rollup.surfaced"' "$ONLOOKER_EVENTS_LOG"
113
+ [ "$status" -eq 0 ]
114
+ [ "$output" -ge 1 ]
115
+ }
116
+
117
+ @test "stays silent at the surface step but still records the breadcrumb when surfacing is disabled" {
118
+ mkdir -p "${HOME}/.claude"
119
+ printf '%s\n' '{"bursar":{"enabled":true,"surface_at_session_start":false}}' \
120
+ > "${HOME}/.claude/settings.json"
121
+ _seed_ledger
122
+ _run_hook
123
+ [ "$status" -eq 0 ]
124
+ [ -z "$output" ]
125
+ [ -f "${ONLOOKER_DIR}/bursar/sessions/${SID}.json" ]
126
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/bursar"
8
+ # shellcheck disable=SC1091
9
+ source "${PLUGIN_ROOT}/scripts/lib/bursar-ulid.sh"
10
+ }
11
+
12
+ @test "bursar_ulid is 26 characters" {
13
+ run bursar_ulid
14
+ [ "$status" -eq 0 ]
15
+ [ "${#output}" -eq 26 ]
16
+ }
17
+
18
+ @test "bursar_ulid uses only Crockford base32 (no I, L, O, U)" {
19
+ run bursar_ulid
20
+ [[ "$output" =~ ^[0-9A-HJKMNP-TV-Z]+$ ]]
21
+ }
22
+
23
+ @test "two ulids differ" {
24
+ local a b
25
+ a=$(bursar_ulid)
26
+ b=$(bursar_ulid)
27
+ [ "$a" != "$b" ]
28
+ }
@@ -114,6 +114,34 @@ setup() {
114
114
  [ "$output" = "3" ]
115
115
  }
116
116
 
117
+ @test "read_events survives caller pipefail when output exceeds chars_max" {
118
+ # Regression: head -c closes the pipe once chars_max bytes arrive, sending jq
119
+ # SIGPIPE. Under the caller's `set -o pipefail` (as the hook and skill run),
120
+ # the reader must still return the truncated output, not discard everything.
121
+ local log="${BATS_TEST_TMPDIR}/big-events.jsonl"
122
+ local ts
123
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) || ts="2099-01-01T00:00:00Z"
124
+ : > "$log"
125
+ local i
126
+ for ((i = 0; i < 60; i++)); do
127
+ printf '%s\n' \
128
+ "{\"event_type\":\"tribunal.gate.blocked\",\"timestamp\":\"${ts}\",\"session_id\":\"s${i}\",\"payload\":{\"k\":\"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvv\"}}" \
129
+ >> "$log"
130
+ done
131
+ export ONLOOKER_EVENTS_LOG="$log"
132
+
133
+ # Tiny cap so head closes the pipe after the first event or two.
134
+ local out
135
+ set -o pipefail
136
+ out=$(counsel_read_events "30" "200")
137
+ set +o pipefail
138
+
139
+ [ -n "$out" ]
140
+ run counsel_count_events "$out"
141
+ [ "$status" -eq 0 ]
142
+ [ "$output" -ge 1 ]
143
+ }
144
+
117
145
  @test "read_events output preserves source types for sources_from_events" {
118
146
  local log="${BATS_TEST_TMPDIR}/source-events.jsonl"
119
147
  local ts