@onlooker-community/ecosystem 0.10.0 → 0.14.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +39 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.github/copilot-instructions.md +46 -0
  4. package/.github/workflows/coverage.yml +78 -0
  5. package/.github/workflows/release.yml +24 -8
  6. package/.github/workflows/test.yml +3 -0
  7. package/.markdownlintignore +3 -0
  8. package/.release-please-manifest.json +4 -1
  9. package/CHANGELOG.md +37 -0
  10. package/README.md +57 -13
  11. package/config.json +6 -1
  12. package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
  13. package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
  14. package/docs/adr/003-ulid-over-uuid.md +40 -0
  15. package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
  16. package/docs/architecture.md +117 -0
  17. package/hooks/hooks.json +4 -0
  18. package/package.json +13 -7
  19. package/plugins/archivist/.claude-plugin/plugin.json +14 -0
  20. package/plugins/archivist/CHANGELOG.md +8 -0
  21. package/plugins/archivist/README.md +105 -0
  22. package/plugins/archivist/config.json +18 -0
  23. package/plugins/archivist/hooks/hooks.json +35 -0
  24. package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
  25. package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
  26. package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
  27. package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
  28. package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
  29. package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
  30. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  31. package/plugins/echo/CHANGELOG.md +24 -0
  32. package/plugins/echo/README.md +110 -0
  33. package/plugins/echo/config.json +15 -0
  34. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  35. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  36. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  37. package/plugins/echo/hooks/hooks.json +15 -0
  38. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  39. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  40. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  41. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  42. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  43. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  44. package/plugins/tribunal/CHANGELOG.md +10 -0
  45. package/plugins/tribunal/README.md +134 -0
  46. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  47. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  48. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  49. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  50. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  51. package/plugins/tribunal/config.json +50 -0
  52. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  53. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  54. package/plugins/tribunal/hooks/hooks.json +15 -0
  55. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  56. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  57. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  58. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  59. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  60. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  61. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  62. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  63. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  64. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  65. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  66. package/release-please-config.json +43 -5
  67. package/scripts/coverage/bash-coverage.mjs +169 -0
  68. package/scripts/coverage/format-comment.mjs +120 -0
  69. package/scripts/coverage/run-coverage.mjs +151 -0
  70. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  71. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  72. package/scripts/lib/portable-lock.sh +48 -0
  73. package/scripts/lib/prompt-rules.sh +207 -0
  74. package/scripts/lib/tool-history.sh +7 -8
  75. package/scripts/lib/validate-path.sh +4 -0
  76. package/scripts/lint/check-manifests.mjs +314 -0
  77. package/scripts/lint/check-references.mjs +311 -0
  78. package/skills/list-prompt-rules/SKILL.md +15 -0
  79. package/test/bats/archivist-config-files.bats +60 -0
  80. package/test/bats/archivist-config.bats +54 -0
  81. package/test/bats/archivist-inject.bats +73 -0
  82. package/test/bats/archivist-project-key.bats +75 -0
  83. package/test/bats/archivist-storage.bats +119 -0
  84. package/test/bats/archivist-ulid.bats +36 -0
  85. package/test/bats/config.bats +10 -10
  86. package/test/bats/echo-config.bats +90 -0
  87. package/test/bats/echo-events.bats +121 -0
  88. package/test/bats/echo-project-key.bats +115 -0
  89. package/test/bats/echo-stop-hook.bats +101 -0
  90. package/test/bats/echo-ulid.bats +38 -0
  91. package/test/bats/portable-lock.bats +62 -0
  92. package/test/bats/prompt-rules.bats +269 -0
  93. package/test/bats/tribunal-aggregate.bats +77 -0
  94. package/test/bats/tribunal-config.bats +86 -0
  95. package/test/bats/tribunal-events.bats +209 -0
  96. package/test/bats/tribunal-gate.bats +95 -0
  97. package/test/bats/tribunal-jury.bats +80 -0
  98. package/test/bats/tribunal-rubric.bats +119 -0
  99. package/test/bats/tribunal-stop-hook.bats +73 -0
  100. package/test/bats/tribunal-verdict.bats +71 -0
  101. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  102. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  103. package/test/helpers/setup.bash +9 -0
  104. package/test/node/check-manifests.test.mjs +173 -0
  105. package/test/node/check-references.test.mjs +279 -0
  106. package/test/node/coverage.test.mjs +143 -0
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # Canonical tribunal.* 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.1.0+ before
6
+ # it is appended to ~/.onlooker/logs/onlooker-events.jsonl.
7
+ #
8
+ # Why a per-plugin wrapper: bash hooks should not have to know about node
9
+ # invocation paths or env wiring. This module centralizes that detail so the
10
+ # orchestrator and Stop hook both call `tribunal_emit_event <type> <payload>`.
11
+ #
12
+ # Requires:
13
+ # - $ONLOOKER_DIR (set by the ecosystem onlooker-schema.sh)
14
+ # - $ONLOOKER_EVENTS_LOG (same)
15
+ # - $_HOOK_SESSION_ID or $CLAUDE_SESSION_ID for the session id
16
+ # - The ecosystem onlooker-event.mjs reachable via $_ONLOOKER_EVENT_JS or
17
+ # under the ecosystem plugin root.
18
+ #
19
+ # Usage:
20
+ # tribunal_emit_event "tribunal.session.start" '{"task_id":"01J...","gate_policy":"majority"}'
21
+
22
+ _TRIBUNAL_PLUGIN_NAME="tribunal"
23
+
24
+ # Resolve the ecosystem onlooker-event.mjs even when CLAUDE_PLUGIN_ROOT points
25
+ # at the tribunal plugin (the wrapper script lives under ecosystem/scripts/lib).
26
+ _tribunal_event_js_path() {
27
+ if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
28
+ printf '%s' "$_ONLOOKER_EVENT_JS"
29
+ return 0
30
+ fi
31
+ local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
32
+ local candidates=(
33
+ "${plugin_root}/scripts/lib/onlooker-event.mjs"
34
+ "${plugin_root}/../../scripts/lib/onlooker-event.mjs"
35
+ )
36
+ local c
37
+ for c in "${candidates[@]}"; do
38
+ [[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
39
+ done
40
+ return 1
41
+ }
42
+
43
+ _tribunal_session_id() {
44
+ if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
45
+ printf '%s' "$_HOOK_SESSION_ID"
46
+ return 0
47
+ fi
48
+ if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
49
+ printf '%s' "$CLAUDE_SESSION_ID"
50
+ return 0
51
+ fi
52
+ printf 'unknown'
53
+ }
54
+
55
+ # Emit a single tribunal event. Returns 0 on success, non-zero on validation
56
+ # failure (so callers can decide whether to abort the loop). Validation errors
57
+ # are written to stderr.
58
+ tribunal_emit_event() {
59
+ local event_type="${1:-}"
60
+ local payload="${2:-}"
61
+ [[ -z "$event_type" || -z "$payload" ]] && return 1
62
+
63
+ local event_js
64
+ event_js=$(_tribunal_event_js_path) || {
65
+ printf 'tribunal-events: cannot locate onlooker-event.mjs\n' >&2
66
+ return 1
67
+ }
68
+
69
+ local session_id
70
+ session_id=$(_tribunal_session_id)
71
+
72
+ local params
73
+ params=$(jq -n \
74
+ --arg plugin "$_TRIBUNAL_PLUGIN_NAME" \
75
+ --arg sid "$session_id" \
76
+ --arg type "$event_type" \
77
+ --argjson payload "$payload" \
78
+ '{plugin: $plugin, session_id: $sid, event_type: $type, payload: $payload}')
79
+
80
+ local event
81
+ local stderr_file
82
+ stderr_file=$(mktemp -t tribunal-event-err.XXXXXX 2>/dev/null) || stderr_file="/tmp/tribunal-event-err.$$"
83
+ event=$(printf '%s' "$params" \
84
+ | ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
85
+ ONLOOKER_PLUGIN_NAME="$_TRIBUNAL_PLUGIN_NAME" \
86
+ node "$event_js" emit 2>"$stderr_file") || {
87
+ printf 'tribunal-events: schema validation failed for %s\n' "$event_type" >&2
88
+ [[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
89
+ rm -f "$stderr_file"
90
+ return 1
91
+ }
92
+ rm -f "$stderr_file"
93
+
94
+ local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
95
+ mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
96
+ printf '%s\n' "$event" >>"$log_path"
97
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env bash
2
+ # Gate decision for Tribunal.
3
+ #
4
+ # Resolves a gate verdict (passed | blocked + reason) from the four schema
5
+ # gate_policy values: strict, majority, unanimous, meta_override.
6
+ #
7
+ # Each policy operates over:
8
+ # - verdicts: JSON array of { judge_id, score, passed } from each Judge
9
+ # - aggregated_score: float, output of tribunal_aggregate
10
+ # - score_threshold: float from the active rubric
11
+ # - meta: JSON of the Meta-Judge's TribunalMetaCompletePayload (for bias_detected
12
+ # and override_recommendation)
13
+ # - dissent_score: float, output of tribunal_disagreement
14
+ # - dissent_threshold: float from rubric/config
15
+ #
16
+ # Echoes a JSON object: { passed: bool, reason?: string }
17
+ # reason is one of: low_score | meta_override | bias_detected | dissent_unresolved
18
+ #
19
+ # Usage: result=$(tribunal_gate_decide "$policy" "$verdicts" "$agg" "$thr" "$meta" "$dissent" "$dissent_thr")
20
+
21
+ tribunal_gate_decide() {
22
+ local policy="${1:-majority}"
23
+ local verdicts="${2:-[]}"
24
+ local aggregated_score="${3:-0}"
25
+ local score_threshold="${4:-0.75}"
26
+ local meta="${5:-{}}"
27
+ local dissent_score="${6:-0}"
28
+ local dissent_threshold="${7:-0.25}"
29
+
30
+ local meta_bias_detected meta_override
31
+ meta_bias_detected=$(printf '%s' "$meta" | jq -r '.bias_detected // false' 2>/dev/null)
32
+ meta_override=$(printf '%s' "$meta" | jq -r '.override_recommendation // empty' 2>/dev/null)
33
+
34
+ # meta_override policy: the Meta-Judge wins, regardless of jury.
35
+ if [[ "$policy" == "meta_override" ]]; then
36
+ case "$meta_override" in
37
+ accept)
38
+ printf '{"passed":true}'
39
+ return 0
40
+ ;;
41
+ reject)
42
+ printf '{"passed":false,"reason":"meta_override"}'
43
+ return 0
44
+ ;;
45
+ re-evaluate|"")
46
+ # No clear override → fall through to score-based decision.
47
+ ;;
48
+ esac
49
+ fi
50
+
51
+ # Bias detection short-circuit (any policy).
52
+ if [[ "$meta_bias_detected" == "true" && "$meta_override" == "reject" ]]; then
53
+ printf '{"passed":false,"reason":"bias_detected"}'
54
+ return 0
55
+ fi
56
+
57
+ # Dissent short-circuit: if judges disagree past threshold AND the Meta-Judge
58
+ # has not provided an override, block with dissent_unresolved so the loop
59
+ # retries with a fresh Actor pass.
60
+ if awk -v d="$dissent_score" -v t="$dissent_threshold" 'BEGIN { exit !(d > t) }' \
61
+ && [[ -z "$meta_override" || "$meta_override" == "re-evaluate" ]]; then
62
+ printf '{"passed":false,"reason":"dissent_unresolved"}'
63
+ return 0
64
+ fi
65
+
66
+ local count passed_count
67
+ count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
68
+ passed_count=$(printf '%s' "$verdicts" | jq '[.[] | select(.passed == true)] | length' 2>/dev/null) || passed_count=0
69
+
70
+ local jury_ok=1 # 0 = ok, 1 = not ok (shell convention)
71
+ case "$policy" in
72
+ strict|unanimous)
73
+ [[ "$count" -gt 0 && "$passed_count" -eq "$count" ]] && jury_ok=0
74
+ ;;
75
+ majority)
76
+ # strictly greater than half
77
+ [[ "$count" -gt 0 ]] && (( passed_count * 2 > count )) && jury_ok=0
78
+ ;;
79
+ meta_override)
80
+ # Already handled accept/reject above; fall back to majority for the
81
+ # re-evaluate / unset case.
82
+ [[ "$count" -gt 0 ]] && (( passed_count * 2 > count )) && jury_ok=0
83
+ ;;
84
+ *)
85
+ printf 'tribunal-gate: unknown policy %s, falling back to majority\n' \
86
+ "$policy" >&2
87
+ [[ "$count" -gt 0 ]] && (( passed_count * 2 > count )) && jury_ok=0
88
+ ;;
89
+ esac
90
+
91
+ local score_ok=1
92
+ awk -v s="$aggregated_score" -v t="$score_threshold" 'BEGIN { exit !(s >= t) }' && score_ok=0
93
+
94
+ if [[ "$jury_ok" -eq 0 && "$score_ok" -eq 0 ]]; then
95
+ printf '{"passed":true}'
96
+ return 0
97
+ fi
98
+
99
+ # Pick the most informative blocking reason.
100
+ if [[ "$score_ok" -ne 0 ]]; then
101
+ printf '{"passed":false,"reason":"low_score"}'
102
+ else
103
+ # Jury did not pass even though score cleared threshold — surface as
104
+ # meta_override when meta said reject, else dissent_unresolved.
105
+ if [[ "$meta_override" == "reject" ]]; then
106
+ printf '{"passed":false,"reason":"meta_override"}'
107
+ else
108
+ printf '{"passed":false,"reason":"dissent_unresolved"}'
109
+ fi
110
+ fi
111
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # Jury resolution for Tribunal.
3
+ #
4
+ # Maps a list of judge_types (from a rubric or config) into a panel of judges
5
+ # the orchestrator can spawn. Each panel entry carries:
6
+ # - judge_id: ULID, unique per panel member
7
+ # - judge_type: schema enum (standard, security, maintainability, adversarial,
8
+ # domain, meta)
9
+ # - subagent: name of the subagent MD to invoke
10
+ # - model: resolved per-judge-type model from config
11
+ #
12
+ # Two judge_types are recognized by schema but not yet shipped as subagents:
13
+ # `maintainability` and `domain`. The jury degrades them to `standard` and emits
14
+ # a warning on stderr so the orchestrator can log it.
15
+ #
16
+ # Requires tribunal-config.sh and tribunal-ulid.sh to be sourced.
17
+
18
+ # Map a judge_type to the shipped subagent name. Echoes empty if there is no
19
+ # shipped subagent.
20
+ _tribunal_jury_subagent_for_type() {
21
+ case "$1" in
22
+ standard) printf 'tribunal-judge-standard' ;;
23
+ security) printf 'tribunal-judge-security' ;;
24
+ adversarial) printf 'tribunal-judge-adversarial' ;;
25
+ meta) printf 'tribunal-meta-judge' ;;
26
+ *) return 0 ;;
27
+ esac
28
+ }
29
+
30
+ # Build a jury from a JSON array of judge_types.
31
+ # Echoes a JSON array of { judge_id, judge_type, subagent, model } objects.
32
+ # Unsupported types (maintainability, domain) are remapped to standard with a
33
+ # warning on stderr.
34
+ #
35
+ # Usage: jury=$(tribunal_jury_empanel '["standard","adversarial"]')
36
+ tribunal_jury_empanel() {
37
+ local types_json="${1:-[]}"
38
+ [[ -z "$types_json" ]] && types_json="[]"
39
+
40
+ local panel='[]'
41
+ local count
42
+ count=$(printf '%s' "$types_json" | jq 'length' 2>/dev/null) || count=0
43
+
44
+ local i raw_type judge_type subagent model judge_id
45
+ for ((i = 0; i < count; i++)); do
46
+ raw_type=$(printf '%s' "$types_json" | jq -r ".[$i]" 2>/dev/null) || continue
47
+ [[ -z "$raw_type" || "$raw_type" == "null" ]] && continue
48
+
49
+ # Schema-known types we don't ship yet degrade to standard.
50
+ case "$raw_type" in
51
+ maintainability|domain)
52
+ printf 'tribunal-jury: judge_type "%s" not shipped in v0.1, degrading to standard\n' \
53
+ "$raw_type" >&2
54
+ judge_type="standard"
55
+ ;;
56
+ meta)
57
+ # Meta is the Meta-Judge — never goes in the jury panel.
58
+ printf 'tribunal-jury: refusing to add judge_type "meta" to the jury (meta is the Meta-Judge)\n' >&2
59
+ continue
60
+ ;;
61
+ standard|security|adversarial)
62
+ judge_type="$raw_type"
63
+ ;;
64
+ *)
65
+ printf 'tribunal-jury: unknown judge_type "%s", degrading to standard\n' \
66
+ "$raw_type" >&2
67
+ judge_type="standard"
68
+ ;;
69
+ esac
70
+
71
+ subagent=$(_tribunal_jury_subagent_for_type "$judge_type")
72
+ [[ -z "$subagent" ]] && continue
73
+
74
+ model=$(tribunal_config_judge_model "$judge_type")
75
+ judge_id=$(tribunal_ulid)
76
+
77
+ panel=$(printf '%s' "$panel" | jq -c \
78
+ --arg id "$judge_id" \
79
+ --arg type "$judge_type" \
80
+ --arg sub "$subagent" \
81
+ --arg model "$model" \
82
+ '. + [{
83
+ judge_id: $id,
84
+ judge_type: $type,
85
+ subagent: $sub,
86
+ model: (if $model == "" then null else $model end)
87
+ }]')
88
+ done
89
+
90
+ printf '%s' "$panel"
91
+ }
92
+
93
+ # Render a jury into the schema's TribunalJuryEmpaneledPayload.judges shape:
94
+ # [{ judge_id, judge_type, model_id }, ...]
95
+ tribunal_jury_to_schema_judges() {
96
+ local panel="${1:-[]}"
97
+ printf '%s' "$panel" | jq -c '[.[] | {
98
+ judge_id: .judge_id,
99
+ judge_type: .judge_type,
100
+ model_id: .model
101
+ } | with_entries(select(.value != null))]'
102
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # Project key derivation for Tribunal.
3
+ #
4
+ # Mirrors the archivist project-key scheme so the two plugins partition storage
5
+ # identically. A project key is a stable 12-char hex identifier that survives:
6
+ # - local rename of the repo directory
7
+ # - cloning the same repo to a different path on the same machine
8
+ # - moving the repo between machines (as long as the git remote is preserved)
9
+ # - worktrees (a worktree shares its parent repo's key)
10
+ #
11
+ # Resolution order:
12
+ # 1. SHA256(`git remote get-url origin`) — preferred, machine-portable
13
+ # 2. SHA256(realpath of `git rev-parse --show-toplevel`) — fallback for repos
14
+ # without an origin remote (greenfield local-only work)
15
+ #
16
+ # Returns the first 12 hex chars. Returns empty string if neither resolution
17
+ # path works.
18
+
19
+ _tribunal_sha256_first12() {
20
+ local input="$1"
21
+ if command -v shasum >/dev/null 2>&1; then
22
+ printf '%s' "$input" | shasum -a 256 2>/dev/null | cut -c1-12
23
+ elif command -v sha256sum >/dev/null 2>&1; then
24
+ printf '%s' "$input" | sha256sum 2>/dev/null | cut -c1-12
25
+ else
26
+ return 1
27
+ fi
28
+ }
29
+
30
+ tribunal_project_remote_url() {
31
+ local cwd="${1:-}"
32
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
33
+ git -C "$cwd" remote get-url origin 2>/dev/null || true
34
+ }
35
+
36
+ # Worktree-aware: uses common-dir so worktrees share a key with the main repo.
37
+ tribunal_project_repo_root() {
38
+ local cwd="${1:-}"
39
+ [[ -z "$cwd" || ! -d "$cwd" ]] && return 0
40
+
41
+ if ! git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
42
+ return 0
43
+ fi
44
+
45
+ local common_dir toplevel
46
+ common_dir=$(git -C "$cwd" rev-parse --git-common-dir 2>/dev/null) || return 0
47
+
48
+ if [[ -n "$common_dir" && "$common_dir" != /* ]]; then
49
+ common_dir="$(cd "$cwd" && cd "$common_dir" 2>/dev/null && pwd -P)" || common_dir=""
50
+ fi
51
+
52
+ if [[ -n "$common_dir" && -d "$common_dir" ]]; then
53
+ toplevel="$(cd "$common_dir/.." 2>/dev/null && pwd -P)" || toplevel=""
54
+ fi
55
+
56
+ if [[ -z "$toplevel" ]]; then
57
+ toplevel=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || true)
58
+ [[ -n "$toplevel" ]] && toplevel="$(cd "$toplevel" 2>/dev/null && pwd -P)"
59
+ fi
60
+
61
+ printf '%s' "$toplevel"
62
+ }
63
+
64
+ # Compute the project key for the given cwd. Prints the key or empty string.
65
+ tribunal_project_key() {
66
+ local cwd="${1:-}"
67
+ [[ -z "$cwd" ]] && cwd="$(pwd)"
68
+
69
+ local remote
70
+ remote=$(tribunal_project_remote_url "$cwd")
71
+ if [[ -n "$remote" ]]; then
72
+ _tribunal_sha256_first12 "remote:$remote"
73
+ return 0
74
+ fi
75
+
76
+ local root
77
+ root=$(tribunal_project_repo_root "$cwd")
78
+ if [[ -n "$root" ]]; then
79
+ _tribunal_sha256_first12 "root:$root"
80
+ return 0
81
+ fi
82
+
83
+ return 0
84
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bash
2
+ # Rubric resolution for Tribunal.
3
+ #
4
+ # Layered lookup (latest wins, by rubric id):
5
+ # 1. Built-in rubrics from plugins/tribunal/config.json (.tribunal.rubric.builtins)
6
+ # 2. ~/.onlooker/tribunal.json (.rubrics)
7
+ # 3. <repo>/.claude/tribunal.json (.rubrics)
8
+ #
9
+ # Exposes:
10
+ # tribunal_rubric_load <repo_root> # populates _TRIBUNAL_RUBRICS (JSON array)
11
+ # tribunal_rubric_get <id> # echoes a single rubric as JSON, or empty
12
+ # tribunal_rubric_default_id # default rubric id from config
13
+ # tribunal_rubric_validate <rubric_json> # exits 0 if valid, 1 otherwise; prints reasons to stderr
14
+ #
15
+ # Schema:
16
+ # {
17
+ # "id": "default",
18
+ # "criteria": [ { "name": str, "weight": [0,1], "min_pass": [0,1] }, ... ],
19
+ # "score_threshold": [0,1],
20
+ # "max_iterations": int >= 1,
21
+ # "judge_types": ["standard" | "security" | ...],
22
+ # "gate_policy": "strict" | "majority" | "unanimous" | "meta_override",
23
+ # "aggregation_method": "mean" | "median" | "min" | "weighted_mean"
24
+ # }
25
+ #
26
+ # Requires tribunal-config.sh to be sourced and tribunal_config_load to have run.
27
+
28
+ _TRIBUNAL_RUBRICS="[]"
29
+
30
+ _tribunal_rubric_overlay_file() {
31
+ local file="$1"
32
+ local base="$2"
33
+ [[ -f "$file" ]] || { printf '%s' "$base"; return 0; }
34
+ local overlay
35
+ overlay=$(jq -c '.rubrics // []' "$file" 2>/dev/null) || { printf '%s' "$base"; return 0; }
36
+ [[ "$overlay" == "[]" || -z "$overlay" ]] && { printf '%s' "$base"; return 0; }
37
+
38
+ jq -c -n --argjson base "$base" --argjson overlay "$overlay" '
39
+ ($base + $overlay)
40
+ | reduce .[] as $r ({}; .[$r.id] = $r)
41
+ | [.[]]
42
+ '
43
+ }
44
+
45
+ tribunal_rubric_load() {
46
+ local repo_root="${1:-}"
47
+ local home_dir="${HOME:-}"
48
+
49
+ local builtins
50
+ builtins=$(printf '%s' "$_TRIBUNAL_CONFIG" | jq -c '.tribunal.rubric.builtins // []' 2>/dev/null)
51
+ [[ -z "$builtins" ]] && builtins="[]"
52
+
53
+ local merged="$builtins"
54
+ merged=$(_tribunal_rubric_overlay_file "${home_dir}/.onlooker/tribunal.json" "$merged")
55
+ merged=$(_tribunal_rubric_overlay_file "${repo_root}/.claude/tribunal.json" "$merged")
56
+
57
+ _TRIBUNAL_RUBRICS="$merged"
58
+ }
59
+
60
+ tribunal_rubric_default_id() {
61
+ local id
62
+ id=$(tribunal_config_get '.tribunal.rubric.default_id')
63
+ [[ -z "$id" ]] && id="default"
64
+ printf '%s' "$id"
65
+ }
66
+
67
+ tribunal_rubric_get() {
68
+ local id="$1"
69
+ [[ -z "$id" ]] && return 0
70
+ printf '%s' "$_TRIBUNAL_RUBRICS" | jq -c --arg id "$id" \
71
+ 'map(select(.id == $id)) | first // empty' 2>/dev/null
72
+ }
73
+
74
+ # Validate a single rubric JSON blob. Prints reasons to stderr; exits 1 on first
75
+ # problem so callers can pipe to a single failure path.
76
+ tribunal_rubric_validate() {
77
+ local rubric="${1:-}"
78
+ [[ -z "$rubric" || "$rubric" == "null" ]] && {
79
+ printf 'rubric is empty\n' >&2
80
+ return 1
81
+ }
82
+
83
+ local id
84
+ id=$(printf '%s' "$rubric" | jq -r '.id // empty' 2>/dev/null)
85
+ [[ -z "$id" ]] && { printf 'rubric missing .id\n' >&2; return 1; }
86
+
87
+ local criteria_count
88
+ criteria_count=$(printf '%s' "$rubric" | jq -r '(.criteria // []) | length' 2>/dev/null)
89
+ [[ "$criteria_count" -ge 1 ]] || {
90
+ printf 'rubric %s: criteria must be non-empty\n' "$id" >&2
91
+ return 1
92
+ }
93
+
94
+ # Each criterion: name non-empty, weight in [0,1], min_pass in [0,1].
95
+ local bad_crit
96
+ bad_crit=$(printf '%s' "$rubric" | jq -r '
97
+ [.criteria[]
98
+ | select(
99
+ (.name | type) != "string"
100
+ or (.name | length) == 0
101
+ or (.weight | type) != "number" or .weight < 0 or .weight > 1
102
+ or (.min_pass | type) != "number" or .min_pass < 0 or .min_pass > 1
103
+ )
104
+ | .name // "(unnamed)"]
105
+ | join(",")
106
+ ' 2>/dev/null)
107
+ [[ -n "$bad_crit" ]] && {
108
+ printf 'rubric %s: invalid criteria: %s\n' "$id" "$bad_crit" >&2
109
+ return 1
110
+ }
111
+
112
+ # Weights must sum to ~1.0.
113
+ local weight_sum
114
+ weight_sum=$(printf '%s' "$rubric" | jq -r '[.criteria[].weight] | add' 2>/dev/null)
115
+ awk -v s="$weight_sum" 'BEGIN { exit !(s > 0.99 && s < 1.01) }' || {
116
+ printf 'rubric %s: criterion weights sum to %s (expected ~1.0)\n' "$id" "$weight_sum" >&2
117
+ return 1
118
+ }
119
+
120
+ # score_threshold in [0,1].
121
+ local thr
122
+ thr=$(printf '%s' "$rubric" | jq -r '.score_threshold // 0.75' 2>/dev/null)
123
+ awk -v t="$thr" 'BEGIN { exit !(t >= 0 && t <= 1) }' || {
124
+ printf 'rubric %s: score_threshold %s out of [0,1]\n' "$id" "$thr" >&2
125
+ return 1
126
+ }
127
+
128
+ # gate_policy enum.
129
+ local gp
130
+ gp=$(printf '%s' "$rubric" | jq -r '.gate_policy // "majority"' 2>/dev/null)
131
+ case "$gp" in
132
+ strict|majority|unanimous|meta_override) : ;;
133
+ *) printf 'rubric %s: invalid gate_policy %s\n' "$id" "$gp" >&2; return 1 ;;
134
+ esac
135
+
136
+ # aggregation_method enum.
137
+ local am
138
+ am=$(printf '%s' "$rubric" | jq -r '.aggregation_method // "weighted_mean"' 2>/dev/null)
139
+ case "$am" in
140
+ mean|median|min|weighted_mean) : ;;
141
+ *) printf 'rubric %s: invalid aggregation_method %s\n' "$id" "$am" >&2; return 1 ;;
142
+ esac
143
+
144
+ # max_iterations integer >= 1.
145
+ local mi
146
+ mi=$(printf '%s' "$rubric" | jq -r '.max_iterations // 3' 2>/dev/null)
147
+ [[ "$mi" =~ ^[0-9]+$ && "$mi" -ge 1 ]] || {
148
+ printf 'rubric %s: max_iterations must be integer >= 1 (got %s)\n' "$id" "$mi" >&2
149
+ return 1
150
+ }
151
+
152
+ return 0
153
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env bash
2
+ # Minimal ULID generator for Tribunal task_id and iteration_id values.
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
+ _TRIBUNAL_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
10
+
11
+ _tribunal_ulid_encode() {
12
+ local n="$1"
13
+ local len="$2"
14
+ local out=""
15
+ local i
16
+ for ((i = 0; i < len; i++)); do
17
+ out="${_TRIBUNAL_ULID_ALPHABET:$((n % 32)):1}${out}"
18
+ n=$((n / 32))
19
+ done
20
+ printf '%s' "$out"
21
+ }
22
+
23
+ tribunal_ulid() {
24
+ local now_ms
25
+ if [[ "$(uname)" == "Darwin" ]]; then
26
+ now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
27
+ || now_ms=$(($(date +%s) * 1000))
28
+ else
29
+ now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
30
+ fi
31
+
32
+ local rand_hex rand_hi rand_lo
33
+ rand_hex=$(openssl rand -hex 10 2>/dev/null)
34
+ if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
35
+ rand_hi=$((16#${rand_hex:0:10}))
36
+ rand_lo=$((16#${rand_hex:10:10}))
37
+ else
38
+ rand_hi=$((RANDOM * 32768 + RANDOM))
39
+ rand_lo=$((RANDOM * 32768 + RANDOM))
40
+ rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
41
+ rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
42
+ fi
43
+
44
+ local ts_part hi_part lo_part
45
+ ts_part=$(_tribunal_ulid_encode "$now_ms" 10)
46
+ hi_part=$(_tribunal_ulid_encode "$rand_hi" 8)
47
+ lo_part=$(_tribunal_ulid_encode "$rand_lo" 8)
48
+
49
+ printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
50
+ }