@onlooker-community/ecosystem 0.15.1 → 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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +4 -3
- package/CHANGELOG.md +10 -3
- package/package.json +3 -3
- package/plugins/governor/.claude-plugin/plugin.json +14 -0
- package/plugins/governor/CHANGELOG.md +15 -0
- package/plugins/governor/config.json +19 -0
- package/plugins/governor/hooks/hooks.json +48 -0
- package/plugins/governor/scripts/hooks/governor-post-tool-use.sh +147 -0
- package/plugins/governor/scripts/hooks/governor-pre-tool-use.sh +199 -0
- package/plugins/governor/scripts/hooks/governor-session-start.sh +109 -0
- package/plugins/governor/scripts/hooks/governor-stop.sh +108 -0
- package/plugins/governor/scripts/lib/governor-config.sh +79 -0
- package/plugins/governor/scripts/lib/governor-estimate.sh +116 -0
- package/plugins/governor/scripts/lib/governor-events.sh +81 -0
- package/plugins/governor/scripts/lib/governor-ledger.sh +172 -0
- package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
- package/plugins/tribunal/CHANGELOG.md +7 -0
- package/plugins/tribunal/skills/tribunal/SKILL.md +26 -4
- package/release-please-config.json +16 -0
- package/test/bats/governor-config.bats +106 -0
- package/test/bats/governor-estimate.bats +86 -0
- package/test/bats/governor-events.bats +238 -0
- package/test/bats/governor-ledger.bats +220 -0
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tribunal",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Multi-agent execution with LLM-as-a-Judge quality gates. An Actor performs work; a jury of typed Judges scores it against a project-overridable rubric; a Meta-Judge reviews the jury for bias; the gate decides accept, retry, or exhaust. Grounded in LLM-as-a-Judge (Zheng et al. 2023) and LLM-as-a-Meta-Judge (Wu et al. 2024). Builds on the Onlooker ecosystem plugin.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.1](https://github.com/onlooker-community/ecosystem/compare/tribunal-v1.0.0...tribunal-v1.0.1) (2026-05-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **tribunal:** persist all artifacts on every iteration including retries :relieved: ([#41](https://github.com/onlooker-community/ecosystem/issues/41)) ([1636105](https://github.com/onlooker-community/ecosystem/commit/163610535a4ce0fa73c8fb82dc5c6296d2d1065a))
|
|
9
|
+
|
|
3
10
|
## 1.0.0 (2026-05-24)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -50,14 +50,22 @@ Emit `tribunal.session.start` with the resolved config (`judge_types`, `gate_pol
|
|
|
50
50
|
|
|
51
51
|
For `iteration_number` from `0` while `iteration_number < max_iterations`:
|
|
52
52
|
|
|
53
|
-
1. **Iteration start.** Generate `iteration_id=$(tribunal_ulid)`. `trigger` is `"initial"` for n=0, `"gate_blocked"` for retries.
|
|
53
|
+
1. **Iteration start.** Generate `iteration_id=$(tribunal_ulid)`. `trigger` is `"initial"` for n=0, `"gate_blocked"` for retries. Initialize the iteration directory (creates `verdicts/` subdirectory):
|
|
54
|
+
```bash
|
|
55
|
+
tribunal_init_iteration "$project_key" "$task_id" "$iteration_id"
|
|
56
|
+
```
|
|
57
|
+
Emit `tribunal.iteration.start`.
|
|
54
58
|
|
|
55
59
|
2. **Actor.** Emit `tribunal.actor.start`. Use the Task tool to spawn `tribunal-actor` with:
|
|
56
60
|
- The task description.
|
|
57
61
|
- The rubric criteria (just `name` + `weight` + `min_pass`).
|
|
58
62
|
- On retries: a digest of the prior iteration's consensus, dissent (if any), and Meta-Judge override.
|
|
59
63
|
|
|
60
|
-
Capture the Actor's final output.
|
|
64
|
+
Capture the Actor's final output. **`$actor_output` must be the verbatim, complete text returned by the Task tool — never a summary, paraphrase, or placeholder string.** Persist it:
|
|
65
|
+
```bash
|
|
66
|
+
tribunal_write_actor_output "$project_key" "$task_id" "$iteration_id" "$actor_output"
|
|
67
|
+
```
|
|
68
|
+
Emit `tribunal.actor.complete` with `success: true` and the inferred `artifact_kind` (`file` / `patch` / `message` / `command`).
|
|
61
69
|
|
|
62
70
|
3. **Empanel the jury.** Resolve the panel from configured types:
|
|
63
71
|
```bash
|
|
@@ -67,17 +75,31 @@ For `iteration_number` from `0` while `iteration_number < max_iterations`:
|
|
|
67
75
|
[[ -n "$rubric_types" && "$rubric_types" != "null" ]] && types="$rubric_types"
|
|
68
76
|
jury=$(tribunal_jury_empanel "$types")
|
|
69
77
|
```
|
|
70
|
-
Persist the jury
|
|
78
|
+
Persist the jury and emit `tribunal.jury.empaneled`:
|
|
79
|
+
```bash
|
|
80
|
+
tribunal_write_iteration_artifact "$project_key" "$task_id" "$iteration_id" "jury" "$jury"
|
|
81
|
+
schema_judges=$(tribunal_jury_to_schema_judges "$jury") # pass $schema_judges as judges[] in the event
|
|
82
|
+
```
|
|
71
83
|
|
|
72
84
|
4. **Run each Judge.** For each entry in the jury panel:
|
|
73
85
|
- Emit `tribunal.judge.start` with `judge_id`, `judge_type`, `judge_model_id`.
|
|
74
86
|
- Spawn the judge subagent (`.subagent` field) with the Actor output + rubric.
|
|
75
87
|
- Parse the JSON object the judge returns. Augment it with `task_id`, `iteration_id`, `judge_id`, `judge_model_id` from the panel entry, and `judge_type` from the panel entry (canonical, overriding what the agent self-reported).
|
|
76
88
|
- Emit `tribunal.verdict` with that payload.
|
|
77
|
-
- Persist
|
|
89
|
+
- **Persist the verdict. This call is required for every judge on every iteration — including retries:**
|
|
90
|
+
```bash
|
|
91
|
+
tribunal_write_judge_verdict "$project_key" "$task_id" "$iteration_id" "$judge_id" "$verdict_json"
|
|
92
|
+
```
|
|
78
93
|
|
|
79
94
|
Collect the verdicts into a JSON array `verdicts`.
|
|
80
95
|
|
|
96
|
+
**Before moving to step 5, verify all artifacts written so far are on disk:**
|
|
97
|
+
- `iteration-<id>/actor.md` — verbatim actor output (written in step 2)
|
|
98
|
+
- `iteration-<id>/jury.json` — jury panel (written in step 3)
|
|
99
|
+
- `iteration-<id>/verdicts/<judge_id>.json` — one file per judge (written in step 4)
|
|
100
|
+
|
|
101
|
+
(`iteration-<id>/gate.json` is written in step 7 — verify it there.)
|
|
102
|
+
|
|
81
103
|
5. **Aggregate + dissent.**
|
|
82
104
|
```bash
|
|
83
105
|
method=$(printf '%s' "$rubric" | jq -r '.aggregation_method // "weighted_mean"')
|
|
@@ -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"
|