@onlooker-community/ecosystem 0.10.0 → 0.15.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 (129) 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 +5 -1
  9. package/CHANGELOG.md +44 -0
  10. package/README.md +58 -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 +123 -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/cartographer/.claude-plugin/plugin.json +14 -0
  31. package/plugins/cartographer/CHANGELOG.md +27 -0
  32. package/plugins/cartographer/README.md +113 -0
  33. package/plugins/cartographer/config.json +21 -0
  34. package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
  35. package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
  36. package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
  37. package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
  38. package/plugins/cartographer/hooks/hooks.json +44 -0
  39. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
  40. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
  41. package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
  42. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
  43. package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
  44. package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
  45. package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
  46. package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
  47. package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
  48. package/plugins/cartographer/scripts/run-audit.sh +309 -0
  49. package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
  50. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  51. package/plugins/echo/CHANGELOG.md +24 -0
  52. package/plugins/echo/README.md +110 -0
  53. package/plugins/echo/config.json +15 -0
  54. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  55. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  56. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  57. package/plugins/echo/hooks/hooks.json +15 -0
  58. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  59. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  60. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  61. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  62. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  63. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  64. package/plugins/tribunal/CHANGELOG.md +10 -0
  65. package/plugins/tribunal/README.md +134 -0
  66. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  67. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  68. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  69. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  70. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  71. package/plugins/tribunal/config.json +50 -0
  72. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  73. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  74. package/plugins/tribunal/hooks/hooks.json +15 -0
  75. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  76. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  77. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  78. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  79. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  80. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  81. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  82. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  83. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  84. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  85. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  86. package/release-please-config.json +59 -5
  87. package/scripts/coverage/bash-coverage.mjs +169 -0
  88. package/scripts/coverage/format-comment.mjs +120 -0
  89. package/scripts/coverage/run-coverage.mjs +151 -0
  90. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  91. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  92. package/scripts/lib/portable-lock.sh +48 -0
  93. package/scripts/lib/prompt-rules.sh +207 -0
  94. package/scripts/lib/tool-history.sh +7 -8
  95. package/scripts/lib/validate-path.sh +4 -0
  96. package/scripts/lint/check-manifests.mjs +314 -0
  97. package/scripts/lint/check-references.mjs +311 -0
  98. package/skills/list-prompt-rules/SKILL.md +15 -0
  99. package/test/bats/archivist-config-files.bats +60 -0
  100. package/test/bats/archivist-config.bats +54 -0
  101. package/test/bats/archivist-inject.bats +73 -0
  102. package/test/bats/archivist-project-key.bats +75 -0
  103. package/test/bats/archivist-storage.bats +119 -0
  104. package/test/bats/archivist-ulid.bats +36 -0
  105. package/test/bats/cartographer-config.bats +107 -0
  106. package/test/bats/cartographer-lock.bats +77 -0
  107. package/test/bats/cartographer-ulid.bats +56 -0
  108. package/test/bats/config.bats +10 -10
  109. package/test/bats/echo-config.bats +90 -0
  110. package/test/bats/echo-events.bats +121 -0
  111. package/test/bats/echo-project-key.bats +115 -0
  112. package/test/bats/echo-stop-hook.bats +101 -0
  113. package/test/bats/echo-ulid.bats +38 -0
  114. package/test/bats/portable-lock.bats +62 -0
  115. package/test/bats/prompt-rules.bats +269 -0
  116. package/test/bats/tribunal-aggregate.bats +77 -0
  117. package/test/bats/tribunal-config.bats +86 -0
  118. package/test/bats/tribunal-events.bats +209 -0
  119. package/test/bats/tribunal-gate.bats +95 -0
  120. package/test/bats/tribunal-jury.bats +80 -0
  121. package/test/bats/tribunal-rubric.bats +119 -0
  122. package/test/bats/tribunal-stop-hook.bats +73 -0
  123. package/test/bats/tribunal-verdict.bats +71 -0
  124. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  125. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  126. package/test/helpers/setup.bash +9 -0
  127. package/test/node/check-manifests.test.mjs +173 -0
  128. package/test/node/check-references.test.mjs +279 -0
  129. package/test/node/coverage.test.mjs +143 -0
@@ -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
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ # Verdict persistence for Tribunal.
3
+ #
4
+ # Writes per-iteration artifacts under:
5
+ # $ONLOOKER_DIR/tribunal/<project-key>/<task_id>/iteration-<iteration_id>/
6
+ # actor.md
7
+ # jury.json
8
+ # verdicts/<judge_id>.json
9
+ # consensus.json
10
+ # dissent.json (optional)
11
+ # meta.json
12
+ # gate.json
13
+ #
14
+ # Plus task-level files at <task_id>/{manifest,session-start,session-complete}.json.
15
+ #
16
+ # Requires tribunal-project-key.sh to be sourced.
17
+
18
+ tribunal_storage_root() {
19
+ local base="${ONLOOKER_DIR:-$HOME/.onlooker}"
20
+ printf '%s/tribunal' "$base"
21
+ }
22
+
23
+ tribunal_project_dir() {
24
+ local key="$1"
25
+ printf '%s/%s' "$(tribunal_storage_root)" "$key"
26
+ }
27
+
28
+ tribunal_task_dir() {
29
+ local key="$1"
30
+ local task_id="$2"
31
+ printf '%s/%s' "$(tribunal_project_dir "$key")" "$task_id"
32
+ }
33
+
34
+ tribunal_iteration_dir() {
35
+ local key="$1"
36
+ local task_id="$2"
37
+ local iteration_id="$3"
38
+ printf '%s/iteration-%s' "$(tribunal_task_dir "$key" "$task_id")" "$iteration_id"
39
+ }
40
+
41
+ tribunal_init_task() {
42
+ local key="$1"
43
+ local task_id="$2"
44
+ [[ -z "$key" || -z "$task_id" ]] && return 1
45
+ mkdir -p "$(tribunal_task_dir "$key" "$task_id")" 2>/dev/null
46
+ }
47
+
48
+ tribunal_init_iteration() {
49
+ local key="$1"
50
+ local task_id="$2"
51
+ local iteration_id="$3"
52
+ [[ -z "$key" || -z "$task_id" || -z "$iteration_id" ]] && return 1
53
+ mkdir -p "$(tribunal_iteration_dir "$key" "$task_id" "$iteration_id")/verdicts" 2>/dev/null
54
+ }
55
+
56
+ # Write the project-level manifest (one per project key, refreshed each task).
57
+ tribunal_write_project_manifest() {
58
+ local key="$1"
59
+ local remote_url="$2"
60
+ local repo_root="$3"
61
+ [[ -z "$key" ]] && return 1
62
+ mkdir -p "$(tribunal_project_dir "$key")" 2>/dev/null
63
+
64
+ local now
65
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
66
+ jq -n \
67
+ --arg key "$key" \
68
+ --arg remote "$remote_url" \
69
+ --arg root "$repo_root" \
70
+ --arg now "$now" \
71
+ '{
72
+ project_key: $key,
73
+ remote_url: (if $remote == "" then null else $remote end),
74
+ repo_root: (if $root == "" then null else $root end),
75
+ last_task_at: $now,
76
+ source: "local"
77
+ }' > "$(tribunal_project_dir "$key")/manifest.json"
78
+ }
79
+
80
+ # Write the per-task manifest with the active rubric snapshot.
81
+ tribunal_write_task_manifest() {
82
+ local key="$1"
83
+ local task_id="$2"
84
+ local task_summary="$3"
85
+ local rubric_id="$4"
86
+ local rubric_json="$5"
87
+ [[ -z "$key" || -z "$task_id" ]] && return 1
88
+ tribunal_init_task "$key" "$task_id" || return 1
89
+
90
+ local now
91
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
92
+ jq -n \
93
+ --arg task_id "$task_id" \
94
+ --arg summary "$task_summary" \
95
+ --arg rubric_id "$rubric_id" \
96
+ --argjson rubric "$rubric_json" \
97
+ --arg now "$now" \
98
+ '{
99
+ task_id: $task_id,
100
+ task_summary: $summary,
101
+ rubric_id: $rubric_id,
102
+ rubric: $rubric,
103
+ started_at: $now
104
+ }' > "$(tribunal_task_dir "$key" "$task_id")/manifest.json"
105
+ }
106
+
107
+ # Append-time helpers for each per-iteration artifact. Each takes the full JSON
108
+ # blob the caller wants persisted (typically the same payload it just emitted as
109
+ # a canonical event).
110
+ tribunal_write_actor_output() {
111
+ local key="$1" task_id="$2" iteration_id="$3" body="$4"
112
+ tribunal_init_iteration "$key" "$task_id" "$iteration_id" || return 1
113
+ printf '%s\n' "$body" > "$(tribunal_iteration_dir "$key" "$task_id" "$iteration_id")/actor.md"
114
+ }
115
+
116
+ tribunal_write_iteration_artifact() {
117
+ local key="$1" task_id="$2" iteration_id="$3" name="$4" json="$5"
118
+ tribunal_init_iteration "$key" "$task_id" "$iteration_id" || return 1
119
+ printf '%s\n' "$json" > "$(tribunal_iteration_dir "$key" "$task_id" "$iteration_id")/${name}.json"
120
+ }
121
+
122
+ tribunal_write_judge_verdict() {
123
+ local key="$1" task_id="$2" iteration_id="$3" judge_id="$4" verdict_json="$5"
124
+ tribunal_init_iteration "$key" "$task_id" "$iteration_id" || return 1
125
+ printf '%s\n' "$verdict_json" \
126
+ > "$(tribunal_iteration_dir "$key" "$task_id" "$iteration_id")/verdicts/${judge_id}.json"
127
+ }