@onlooker-community/ecosystem 0.15.2 → 0.16.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.
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ # Governor Stop hook.
3
+ #
4
+ # Fires at session end. Emits governor.session.complete with cumulative
5
+ # spend totals from the JSONL ledger.
6
+ #
7
+ # Hook contract:
8
+ # - Always exits 0. Never blocks Stop.
9
+ # - Skips silently when governor.enabled is false.
10
+ # - Errors from ledger reads are swallowed; emits best-effort totals.
11
+
12
+ set -uo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
16
+
17
+ _ECOSYSTEM_ROOT="${ONLOOKER_ECOSYSTEM_ROOT:-}"
18
+ if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
19
+ _candidate="$(cd "${PLUGIN_ROOT}/../.." 2>/dev/null && pwd)"
20
+ if [[ -f "${_candidate}/scripts/lib/validate-path.sh" ]]; then
21
+ _ECOSYSTEM_ROOT="$_candidate"
22
+ fi
23
+ fi
24
+
25
+ if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
26
+ # shellcheck disable=SC1091
27
+ CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
28
+ # shellcheck disable=SC1091
29
+ CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
30
+ fi
31
+
32
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
33
+
34
+ # shellcheck source=../lib/governor-config.sh
35
+ source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
36
+ # shellcheck source=../lib/governor-events.sh
37
+ source "${PLUGIN_ROOT}/scripts/lib/governor-events.sh"
38
+ # shellcheck source=../lib/governor-ledger.sh
39
+ source "${PLUGIN_ROOT}/scripts/lib/governor-ledger.sh"
40
+
41
+ _done() { exit 0; }
42
+
43
+ INPUT=$(cat)
44
+ SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
45
+ [[ -z "$SESSION_ID" ]] && SESSION_ID="${CLAUDE_SESSION_ID:-unknown}"
46
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
47
+
48
+ governor_config_load "$CWD"
49
+
50
+ if ! governor_config_enabled; then
51
+ _done
52
+ fi
53
+
54
+ # -----------------------------------------------------------------------
55
+ # Read session totals from the ledger.
56
+ # -----------------------------------------------------------------------
57
+ TOTAL_TOKENS=$(governor_ledger_total_tokens "$SESSION_ID")
58
+ TOTAL_COST=$(governor_ledger_total_cost "$SESSION_ID")
59
+ TOTAL_CALLS=$(governor_ledger_call_count "$SESSION_ID")
60
+ LEDGER_POISONED=$(governor_ledger_is_poisoned "$SESSION_ID" && printf 'true' || printf 'false')
61
+
62
+ TOKENS_BUDGET=$(governor_config_get '.governor.session.tokens_default')
63
+ TOKENS_BUDGET="${TOKENS_BUDGET:-100000}"
64
+ COST_BUDGET=$(governor_config_get '.governor.session.cost_usd_default')
65
+ COST_BUDGET="${COST_BUDGET:-1.0}"
66
+
67
+ if [[ -n "${ONLOOKER_SESSION_BUDGET_TOKENS:-}" ]]; then
68
+ TOKENS_BUDGET="$ONLOOKER_SESSION_BUDGET_TOKENS"
69
+ fi
70
+
71
+ UNDER_BUDGET="true"
72
+ TOTAL_TOKENS_INT=$(printf '%s' "${TOTAL_TOKENS:-0}" | grep -oE '^[0-9]+' || printf '0')
73
+ TOKENS_BUDGET_INT=$(printf '%s' "${TOKENS_BUDGET:-0}" | grep -oE '^[0-9]+' || printf '0')
74
+ (( TOTAL_TOKENS_INT > TOKENS_BUDGET_INT )) && UNDER_BUDGET="false"
75
+
76
+ # Also check the cost dimension (float comparison via awk).
77
+ if [[ "$UNDER_BUDGET" == "true" ]]; then
78
+ COST_OVER=$(awk "BEGIN { print (${TOTAL_COST:-0} > ${COST_BUDGET:-1.0}) ? 1 : 0 }" 2>/dev/null) || COST_OVER=0
79
+ [[ "$COST_OVER" == "1" ]] && UNDER_BUDGET="false"
80
+ fi
81
+
82
+ SESSION_PAYLOAD=$(jq -n \
83
+ --argjson total_cost "${TOTAL_COST:-0}" \
84
+ --argjson budget_usd "${COST_BUDGET:-1.0}" \
85
+ --argjson under "$( [[ "$UNDER_BUDGET" == "true" ]] && printf 'true' || printf 'false')" \
86
+ --arg sid "$SESSION_ID" \
87
+ --argjson total_tokens "${TOTAL_TOKENS_INT:-0}" \
88
+ --argjson total_calls "${TOTAL_CALLS:-0}" \
89
+ --argjson dur 0 \
90
+ --argjson calls_blocked 0 \
91
+ --argjson calls_warned 0 \
92
+ --argjson poisoned "$( [[ "$LEDGER_POISONED" == "true" ]] && printf 'true' || printf 'false')" \
93
+ '{
94
+ total_cost_usd: $total_cost,
95
+ budget_usd: $budget_usd,
96
+ under_budget: $under,
97
+ session_id: $sid,
98
+ total_tokens: $total_tokens,
99
+ total_api_calls: $total_calls,
100
+ duration_ms: $dur,
101
+ calls_blocked: $calls_blocked,
102
+ calls_warned: $calls_warned,
103
+ ledger_poisoned: $poisoned
104
+ }' 2>/dev/null) || SESSION_PAYLOAD="{}"
105
+
106
+ governor_emit_event "governor.session.complete" "$SESSION_PAYLOAD" || true
107
+
108
+ _done
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # Config resolution for Governor.
3
+ #
4
+ # Reads three layers, latest wins:
5
+ # 1. plugins/governor/config.json (defaults shipped with the plugin)
6
+ # 2. ~/.claude/settings.json
7
+ # 3. <repo>/.claude/settings.json
8
+ #
9
+ # Exposes:
10
+ # governor_config_load <repo_root> # populates _GOVERNOR_CONFIG (JSON)
11
+ # governor_config_get <jq-path> # echoes string value (empty if unset)
12
+ # governor_config_get_json <jq-path> # echoes JSON value (null if unset)
13
+ # governor_config_enabled # 0 if governor.enabled is true
14
+ # governor_config_enforcement # echoes "soft" or "hard"
15
+
16
+ _GOVERNOR_CONFIG="{}"
17
+
18
+ governor_config_load() {
19
+ local repo_root="${1:-}"
20
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
21
+ local home_dir="${HOME:-}"
22
+
23
+ local merged="{}"
24
+ local file
25
+
26
+ file="${plugin_root}/config.json"
27
+ if [[ -f "$file" ]]; then
28
+ local defaults
29
+ defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
30
+ merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
31
+ || merged="$defaults"
32
+ fi
33
+
34
+ local repo_settings=""
35
+ [[ -n "$repo_root" ]] && repo_settings="${repo_root}/.claude/settings.json"
36
+
37
+ for file in "${home_dir}/.claude/settings.json" "$repo_settings"; do
38
+ [[ -n "$file" && -f "$file" ]] || continue
39
+ local overlay
40
+ overlay=$(jq '{ governor: (.governor // {}) }' "$file" 2>/dev/null) || continue
41
+ [[ -z "$overlay" ]] && continue
42
+ local attempt
43
+ if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
44
+ def deepmerge($a; $b):
45
+ if ($a|type) == "object" and ($b|type) == "object" then
46
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
47
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
48
+ elif $b == null then $a
49
+ else $b end;
50
+ deepmerge($a; $b)
51
+ ' 2>/dev/null) && [[ -n "$attempt" ]]; then
52
+ merged="$attempt"
53
+ fi
54
+ done
55
+
56
+ _GOVERNOR_CONFIG="$merged"
57
+ }
58
+
59
+ governor_config_get() {
60
+ local path="$1"
61
+ printf '%s' "$_GOVERNOR_CONFIG" | jq -r "${path} // empty" 2>/dev/null
62
+ }
63
+
64
+ governor_config_get_json() {
65
+ local path="$1"
66
+ printf '%s' "$_GOVERNOR_CONFIG" | jq -c "${path}" 2>/dev/null
67
+ }
68
+
69
+ governor_config_enabled() {
70
+ local v
71
+ v=$(governor_config_get '.governor.enabled')
72
+ [[ "$v" == "true" ]]
73
+ }
74
+
75
+ governor_config_enforcement() {
76
+ local v
77
+ v=$(governor_config_get '.governor.enforcement')
78
+ printf '%s' "${v:-soft}"
79
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bash
2
+ # Token estimation for the governor pre-call gate.
3
+ #
4
+ # Uses a tier-table approach: estimate from tool input JSON size with a
5
+ # per-content-type characters-per-token ratio, then multiply by a
6
+ # configurable safety margin.
7
+ #
8
+ # Tier table (characters per token):
9
+ # ASCII prose 4.0
10
+ # code / JSON 3.0
11
+ # mixed 2.5
12
+ # non-Latin 1.5
13
+ #
14
+ # Safety margin (config: governor.estimation.safety_margin, default 1.3)
15
+ # is applied before the gate check. Hard stop margin
16
+ # (governor.estimation.hard_stop_margin, default 1.5) is the threshold
17
+ # for a blocking decision regardless of enforcement mode.
18
+ #
19
+ # Estimation method tag emitted in governor.gate.checked: "tier_table"
20
+ #
21
+ # Exposes:
22
+ # governor_estimate_tokens <json_input> # echoes integer estimate
23
+ # governor_estimate_cost <tokens> <model> # echoes float USD estimate
24
+ # governor_estimate_method # echoes "tier_table"
25
+
26
+ governor_estimate_method() {
27
+ printf 'tier_table'
28
+ }
29
+
30
+ # Detect content tier from a sample of text.
31
+ # Returns one of: ascii_prose code_json mixed non_latin
32
+ _governor_detect_tier() {
33
+ local sample="${1:-}"
34
+ local len=${#sample}
35
+ [[ $len -eq 0 ]] && { printf 'ascii_prose'; return 0; }
36
+
37
+ # Check for structural characters that signal code/JSON
38
+ local struct_count
39
+ struct_count=$(printf '%s' "$sample" | tr -cd '{}[]():;=><' | wc -c 2>/dev/null) \
40
+ || struct_count=0
41
+ struct_count=$(printf '%s' "$struct_count" | tr -d ' ')
42
+
43
+ # >= 10% structural → code/JSON
44
+ if (( struct_count * 10 >= len )); then
45
+ printf 'code_json'
46
+ return 0
47
+ fi
48
+
49
+ # Non-ASCII byte presence signals non-Latin
50
+ local ascii_count
51
+ ascii_count=$(printf '%s' "$sample" | tr -cd '[:print:][:space:]' | wc -c 2>/dev/null) \
52
+ || ascii_count=$len
53
+ ascii_count=$(printf '%s' "$ascii_count" | tr -d ' ')
54
+
55
+ if (( ascii_count * 10 < len * 7 )); then
56
+ printf 'non_latin'
57
+ return 0
58
+ fi
59
+
60
+ # 5–9% structural → mixed (prose with embedded code/JSON)
61
+ if (( struct_count * 20 >= len )); then
62
+ printf 'mixed'
63
+ return 0
64
+ fi
65
+
66
+ printf 'ascii_prose'
67
+ }
68
+
69
+ # Estimate token count from a JSON input string.
70
+ # Usage: tokens=$(governor_estimate_tokens "$json_input")
71
+ governor_estimate_tokens() {
72
+ local json_input="${1:-}"
73
+ local safety_margin="${2:-}"
74
+
75
+ [[ -z "$safety_margin" ]] && {
76
+ safety_margin=$(governor_config_get '.governor.estimation.safety_margin' 2>/dev/null)
77
+ safety_margin="${safety_margin:-1.3}"
78
+ }
79
+
80
+ local char_count=${#json_input}
81
+ [[ $char_count -eq 0 ]] && { printf '100'; return 0; }
82
+
83
+ local sample="${json_input:0:2000}"
84
+ local tier
85
+ tier=$(_governor_detect_tier "$sample")
86
+
87
+ local chars_per_token
88
+ case "$tier" in
89
+ code_json) chars_per_token="3.0" ;;
90
+ mixed) chars_per_token="2.5" ;;
91
+ non_latin) chars_per_token="1.5" ;;
92
+ *) chars_per_token="4.0" ;;
93
+ esac
94
+
95
+ # Single awk pass for fractional chars_per_token and safety margin
96
+ local tokens
97
+ tokens=$(awk "BEGIN { printf \"%d\", int($char_count / $chars_per_token * $safety_margin + 0.999) }" 2>/dev/null) \
98
+ || tokens=$(( char_count * 2 ))
99
+ (( tokens < 1 )) && tokens=1
100
+
101
+ printf '%s' "$tokens"
102
+ }
103
+
104
+ # Rough USD cost estimate from token count.
105
+ # Uses Sonnet-class pricing as a conservative default (~$3/M input, $15/M output).
106
+ # governor is not aware of the actual model being spawned, so this is a
107
+ # planning-time upper bound.
108
+ #
109
+ # Usage: cost=$(governor_estimate_cost 5000)
110
+ governor_estimate_cost() {
111
+ local tokens="${1:-0}"
112
+
113
+ # $3 per 1M input + $15 per 1M output; assume 50/50 split → ~$9/M blended
114
+ awk "BEGIN { printf \"%.6f\", ($tokens / 1000000.0) * 9.0 }" 2>/dev/null \
115
+ || printf '0.0'
116
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # Canonical governor.* 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 v2.4.0+
6
+ # before being appended to ~/.onlooker/logs/onlooker-events.jsonl.
7
+ #
8
+ # Usage:
9
+ # governor_emit_event "governor.gate.checked" '{"session_id":"...","decision":"allow",...}'
10
+
11
+ _GOVERNOR_PLUGIN_NAME="governor"
12
+
13
+ _governor_event_js_path() {
14
+ if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
15
+ printf '%s' "$_ONLOOKER_EVENT_JS"
16
+ return 0
17
+ fi
18
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
19
+ local candidates=(
20
+ "${plugin_root}/scripts/lib/onlooker-event.mjs"
21
+ "${plugin_root}/../../scripts/lib/onlooker-event.mjs"
22
+ )
23
+ local c
24
+ for c in "${candidates[@]}"; do
25
+ [[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
26
+ done
27
+ return 1
28
+ }
29
+
30
+ _governor_session_id() {
31
+ if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
32
+ printf '%s' "$_HOOK_SESSION_ID"
33
+ return 0
34
+ fi
35
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
36
+ printf '%s' "$CLAUDE_SESSION_ID"
37
+ return 0
38
+ fi
39
+ printf 'unknown'
40
+ }
41
+
42
+ # Emit a single governor.* event. Returns 0 on success, non-zero on failure.
43
+ governor_emit_event() {
44
+ local event_type="${1:-}"
45
+ local payload="${2:-}"
46
+
47
+ [[ -z "$event_type" || -z "$payload" ]] && return 1
48
+
49
+ local event_js
50
+ event_js=$(_governor_event_js_path) || return 1
51
+
52
+ local session_id
53
+ session_id=$(_governor_session_id)
54
+
55
+ local params
56
+ params=$(jq -n \
57
+ --arg plugin "$_GOVERNOR_PLUGIN_NAME" \
58
+ --arg sid "$session_id" \
59
+ --arg type "$event_type" \
60
+ --argjson payload "$payload" \
61
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}' \
62
+ 2>/dev/null) || return 1
63
+
64
+ local event
65
+ local stderr_file
66
+ stderr_file=$(mktemp -t governor-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/governor-event-err.$$"
67
+ event=$(printf '%s' "$params" \
68
+ | ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
69
+ ONLOOKER_PLUGIN_NAME="$_GOVERNOR_PLUGIN_NAME" \
70
+ node "$event_js" emit 2>"$stderr_file") || {
71
+ printf 'governor_emit_event: schema validation failed for %s\n' "$event_type" >&2
72
+ [[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
73
+ rm -f "$stderr_file"
74
+ return 1
75
+ }
76
+ rm -f "$stderr_file"
77
+
78
+ local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
79
+ mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
80
+ printf '%s\n' "$event" >> "$log_path"
81
+ }
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env bash
2
+ # JSONL ledger read/write for the governor plugin.
3
+ #
4
+ # Two-phase accounting model:
5
+ #
6
+ # 1. PreToolUse (gate): writes a "reservation" record inside the gate
7
+ # lock with estimated_tokens > 0. This ensures concurrent spawns
8
+ # each see the others' in-flight cost before deciding to allow.
9
+ #
10
+ # 2. PostToolUse (completion): writes a "Task" record with
11
+ # estimated_tokens = -(original estimate) to cancel the reservation,
12
+ # plus actual_tokens = observed count (if available). Net effect:
13
+ #
14
+ # in-flight: estimated_tokens(reservation) = N_est
15
+ # completed: estimated_tokens(Task) = -N_est, actual_tokens = N_act
16
+ # total: N_est + (-N_est) + N_act = N_act ✓
17
+ #
18
+ # governor_ledger_total_tokens sums (.estimated_tokens + .actual_tokens)
19
+ # across all records so the running total is always:
20
+ # (in-flight reservation estimates) + (completed actuals)
21
+ #
22
+ # Requires portable-lock.sh to be sourced beforehand.
23
+
24
+ GOVERNOR_LEDGER_MAX_RETRIES=3
25
+ GOVERNOR_LEDGER_LOCK_TIMEOUT=5
26
+
27
+ governor_ledger_path() {
28
+ local session_id="${1:-unknown}"
29
+ local dir="${ONLOOKER_DIR:-${HOME}/.onlooker}/governance/ledgers"
30
+ local safe_id
31
+ safe_id=$(printf '%s' "$session_id" | tr -c 'a-zA-Z0-9-' '_')
32
+ printf '%s/%s.jsonl' "$dir" "$safe_id"
33
+ }
34
+
35
+ governor_ledger_poison_path() {
36
+ local ledger_path="${1:-}"
37
+ printf '%s.poisoned' "$ledger_path"
38
+ }
39
+
40
+ governor_ledger_is_poisoned() {
41
+ local session_id="${1:-}"
42
+ local ledger_path
43
+ ledger_path=$(governor_ledger_path "$session_id")
44
+ [[ -f "$(governor_ledger_poison_path "$ledger_path")" ]]
45
+ }
46
+
47
+ # Append a record to the ledger under the ledger's own write lock.
48
+ # Safe to call from PostToolUse and other hooks that do not already hold
49
+ # the gate lock. For writing inside the gate lock use governor_ledger_write_direct.
50
+ #
51
+ # Usage: governor_ledger_append "$session_id" "$record_json"
52
+ governor_ledger_append() {
53
+ local session_id="${1:-}"
54
+ local record="${2:-}"
55
+
56
+ [[ -z "$session_id" || -z "$record" ]] && return 1
57
+
58
+ local ledger_path
59
+ ledger_path=$(governor_ledger_path "$session_id")
60
+ local lock_path="${ledger_path}.lock"
61
+
62
+ mkdir -p "$(dirname "$ledger_path")" 2>/dev/null || return 1
63
+
64
+ local attempt=0
65
+ local unrecorded_tokens=0
66
+ unrecorded_tokens=$(printf '%s' "$record" | jq -r '.estimated_tokens // 0' 2>/dev/null) \
67
+ || unrecorded_tokens=0
68
+
69
+ while (( attempt < GOVERNOR_LEDGER_MAX_RETRIES )); do
70
+ if lock_acquire "$lock_path" "$GOVERNOR_LEDGER_LOCK_TIMEOUT"; then
71
+ printf '%s\n' "$(printf '%s' "$record" | jq -c . 2>/dev/null)" >> "$ledger_path" 2>/dev/null
72
+ local write_ok=$?
73
+ lock_release "$lock_path"
74
+ if (( write_ok == 0 )); then
75
+ return 0
76
+ fi
77
+ fi
78
+ attempt=$(( attempt + 1 ))
79
+ done
80
+
81
+ _governor_ledger_poison "$session_id" "$ledger_path" "$unrecorded_tokens"
82
+ return 1
83
+ }
84
+
85
+ # Write a record directly to the ledger file without acquiring the write
86
+ # lock. ONLY call this when you already hold the gate lock, which serializes
87
+ # access. The gate lock is the same as the write lock (same .lock path), so
88
+ # re-acquiring it here would deadlock.
89
+ #
90
+ # Usage: governor_ledger_write_direct "$ledger_path" "$record_json"
91
+ governor_ledger_write_direct() {
92
+ local ledger_path="${1:-}"
93
+ local record="${2:-}"
94
+
95
+ [[ -z "$ledger_path" || -z "$record" ]] && return 1
96
+
97
+ mkdir -p "$(dirname "$ledger_path")" 2>/dev/null || return 1
98
+ printf '%s\n' "$(printf '%s' "$record" | jq -c . 2>/dev/null)" >> "$ledger_path" 2>/dev/null
99
+ }
100
+
101
+ _governor_ledger_poison() {
102
+ local session_id="${1:-}"
103
+ local ledger_path="${2:-}"
104
+ local unrecorded_tokens="${3:-0}"
105
+
106
+ touch "$(governor_ledger_poison_path "$ledger_path")" 2>/dev/null || true
107
+
108
+ local poison_payload
109
+ poison_payload=$(jq -n \
110
+ --arg sid "$session_id" \
111
+ --arg aid "${CLAUDE_SESSION_ID:-unknown}" \
112
+ --arg err "write failed after ${GOVERNOR_LEDGER_MAX_RETRIES} attempts" \
113
+ --argjson retries "$GOVERNOR_LEDGER_MAX_RETRIES" \
114
+ --argjson tok "$unrecorded_tokens" \
115
+ '{
116
+ session_id: $sid,
117
+ agent_id: $aid,
118
+ error: $err,
119
+ retry_count: $retries,
120
+ ledger_poisoned: true,
121
+ unrecorded_tokens: $tok
122
+ }' 2>/dev/null) || poison_payload="{}"
123
+
124
+ governor_emit_event "governor.ledger.write_failed" "$poison_payload" || true
125
+ }
126
+
127
+ # Running total of tokens for a session.
128
+ #
129
+ # Uses the two-phase model: each record contributes
130
+ # .estimated_tokens + (.actual_tokens // 0)
131
+ #
132
+ # In-flight reservations: estimated_tokens > 0, no actual_tokens → counts N_est
133
+ # Completed Task records: estimated_tokens = -N_est, actual_tokens = N_act → counts N_act
134
+ # Net: in-flight estimates + completed actuals.
135
+ #
136
+ # Usage: tokens=$(governor_ledger_total_tokens "$session_id")
137
+ governor_ledger_total_tokens() {
138
+ local session_id="${1:-}"
139
+ local ledger_path
140
+ ledger_path=$(governor_ledger_path "$session_id")
141
+
142
+ [[ -f "$ledger_path" ]] || { printf '0'; return 0; }
143
+
144
+ jq -s '[.[] | ((.estimated_tokens // 0) + (.actual_tokens // 0))] | add // 0' \
145
+ "$ledger_path" 2>/dev/null || printf '0'
146
+ }
147
+
148
+ # Running total of cost for a session (same two-phase logic as tokens).
149
+ # Usage: cost=$(governor_ledger_total_cost "$session_id")
150
+ governor_ledger_total_cost() {
151
+ local session_id="${1:-}"
152
+ local ledger_path
153
+ ledger_path=$(governor_ledger_path "$session_id")
154
+
155
+ [[ -f "$ledger_path" ]] || { printf '0'; return 0; }
156
+
157
+ jq -s '[.[] | ((.cost_usd_estimated // 0) + (.cost_usd_actual // 0))] | add // 0' \
158
+ "$ledger_path" 2>/dev/null || printf '0'
159
+ }
160
+
161
+ # Count completed Task calls (excludes reservation records).
162
+ # Usage: calls=$(governor_ledger_call_count "$session_id")
163
+ governor_ledger_call_count() {
164
+ local session_id="${1:-}"
165
+ local ledger_path
166
+ ledger_path=$(governor_ledger_path "$session_id")
167
+
168
+ [[ -f "$ledger_path" ]] || { printf '0'; return 0; }
169
+
170
+ jq -s '[.[] | select(.agent_type == "Task" and .record_type != "reservation")] | length' \
171
+ "$ledger_path" 2>/dev/null || printf '0'
172
+ }
@@ -78,6 +78,22 @@
78
78
  "jsonpath": "$.version"
79
79
  }
80
80
  ]
81
+ },
82
+ "plugins/governor": {
83
+ "changelog-path": "CHANGELOG.md",
84
+ "release-type": "simple",
85
+ "bump-minor-pre-major": true,
86
+ "bump-patch-for-minor-pre-major": false,
87
+ "component": "governor",
88
+ "draft": false,
89
+ "prerelease": false,
90
+ "extra-files": [
91
+ {
92
+ "type": "json",
93
+ "path": ".claude-plugin/plugin.json",
94
+ "jsonpath": "$.version"
95
+ }
96
+ ]
81
97
  }
82
98
  },
83
99
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
@@ -0,0 +1,106 @@
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/governor"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
11
+ }
12
+
13
+ @test "governor is disabled by default" {
14
+ governor_config_load ""
15
+ run governor_config_enabled
16
+ [ "$status" -ne 0 ]
17
+ }
18
+
19
+ @test "user-level settings.json can enable governor" {
20
+ mkdir -p "${HOME}/.claude"
21
+ printf '%s\n' '{"governor":{"enabled":true}}' > "${HOME}/.claude/settings.json"
22
+ governor_config_load ""
23
+ run governor_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' '{"governor":{"enabled":true}}' > "${HOME}/.claude/settings.json"
30
+ local repo="${BATS_TEST_TMPDIR}/repo"
31
+ mkdir -p "${repo}/.claude"
32
+ printf '%s\n' '{"governor":{"enabled":false}}' > "${repo}/.claude/settings.json"
33
+ governor_config_load "$repo"
34
+ run governor_config_enabled
35
+ [ "$status" -ne 0 ]
36
+ }
37
+
38
+ @test "default enforcement is soft" {
39
+ governor_config_load ""
40
+ local v
41
+ v=$(governor_config_enforcement)
42
+ [ "$v" = "soft" ]
43
+ }
44
+
45
+ @test "enforcement can be overridden to hard" {
46
+ mkdir -p "${HOME}/.claude"
47
+ printf '%s\n' '{"governor":{"enforcement":"hard"}}' > "${HOME}/.claude/settings.json"
48
+ governor_config_load ""
49
+ local v
50
+ v=$(governor_config_enforcement)
51
+ [ "$v" = "hard" ]
52
+ }
53
+
54
+ @test "default tokens budget is 100000" {
55
+ governor_config_load ""
56
+ local v
57
+ v=$(governor_config_get '.governor.session.tokens_default')
58
+ [ "$v" = "100000" ]
59
+ }
60
+
61
+ @test "default cost budget is 1.0" {
62
+ governor_config_load ""
63
+ local v
64
+ v=$(governor_config_get '.governor.session.cost_usd_default')
65
+ [ "$v" = "1.0" ]
66
+ }
67
+
68
+ @test "default safety margin is 1.3" {
69
+ governor_config_load ""
70
+ local v
71
+ v=$(governor_config_get '.governor.estimation.safety_margin')
72
+ [ "$v" = "1.3" ]
73
+ }
74
+
75
+ @test "default hard_stop_margin is 1.5" {
76
+ governor_config_load ""
77
+ local v
78
+ v=$(governor_config_get '.governor.estimation.hard_stop_margin')
79
+ [ "$v" = "1.5" ]
80
+ }
81
+
82
+ @test "default estimation method is tier_table" {
83
+ governor_config_load ""
84
+ local v
85
+ v=$(governor_config_get '.governor.estimation.method')
86
+ [ "$v" = "tier_table" ]
87
+ }
88
+
89
+ @test "governor_config_get returns empty for missing key" {
90
+ governor_config_load ""
91
+ local v
92
+ v=$(governor_config_get '.governor.no_such_key')
93
+ [ -z "$v" ]
94
+ }
95
+
96
+ @test "empty repo_root does not load /.claude/settings.json" {
97
+ # Place a settings.json at the absolute root path that an empty repo_root would produce.
98
+ # On a real machine this won't exist, but in CI it might; the guard should skip it.
99
+ # We verify that a file at / does not influence config by confirming the default holds.
100
+ governor_config_load ""
101
+ run governor_config_enabled
102
+ # Default is disabled — if /.claude/settings.json were loaded with {enabled:true}
103
+ # this would fail. We can't plant a file at / in tests, so we assert the default
104
+ # is intact (regression guard rather than direct injection test).
105
+ [ "$status" -ne 0 ]
106
+ }