@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.
- package/.claude-plugin/marketplace.json +39 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.github/copilot-instructions.md +46 -0
- package/.github/workflows/coverage.yml +78 -0
- package/.github/workflows/release.yml +24 -8
- package/.github/workflows/test.yml +3 -0
- package/.markdownlintignore +3 -0
- package/.release-please-manifest.json +5 -1
- package/CHANGELOG.md +44 -0
- package/README.md +58 -13
- package/config.json +6 -1
- package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
- package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
- package/docs/adr/003-ulid-over-uuid.md +40 -0
- package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
- package/docs/architecture.md +123 -0
- package/hooks/hooks.json +4 -0
- package/package.json +13 -7
- package/plugins/archivist/.claude-plugin/plugin.json +14 -0
- package/plugins/archivist/CHANGELOG.md +8 -0
- package/plugins/archivist/README.md +105 -0
- package/plugins/archivist/config.json +18 -0
- package/plugins/archivist/hooks/hooks.json +35 -0
- package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
- package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
- package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
- package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
- package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
- package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +14 -0
- package/plugins/cartographer/CHANGELOG.md +27 -0
- package/plugins/cartographer/README.md +113 -0
- package/plugins/cartographer/config.json +21 -0
- package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
- package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
- package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
- package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
- package/plugins/cartographer/hooks/hooks.json +44 -0
- package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
- package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
- package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
- package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
- package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
- package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
- package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
- package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
- package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
- package/plugins/cartographer/scripts/run-audit.sh +309 -0
- package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
- package/plugins/echo/.claude-plugin/plugin.json +14 -0
- package/plugins/echo/CHANGELOG.md +24 -0
- package/plugins/echo/README.md +110 -0
- package/plugins/echo/config.json +15 -0
- package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
- package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
- package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
- package/plugins/echo/hooks/hooks.json +15 -0
- package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
- package/plugins/echo/scripts/lib/echo-config.sh +108 -0
- package/plugins/echo/scripts/lib/echo-events.sh +74 -0
- package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
- package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
- package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
- package/plugins/tribunal/CHANGELOG.md +10 -0
- package/plugins/tribunal/README.md +134 -0
- package/plugins/tribunal/agents/tribunal-actor.md +35 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
- package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
- package/plugins/tribunal/config.json +50 -0
- package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
- package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
- package/plugins/tribunal/hooks/hooks.json +15 -0
- package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
- package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
- package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
- package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
- package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
- package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
- package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
- package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
- package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
- package/release-please-config.json +59 -5
- package/scripts/coverage/bash-coverage.mjs +169 -0
- package/scripts/coverage/format-comment.mjs +120 -0
- package/scripts/coverage/run-coverage.mjs +151 -0
- package/scripts/hooks/agent-spawn-tracker.sh +4 -4
- package/scripts/hooks/prompt-rule-injector.sh +122 -0
- package/scripts/lib/portable-lock.sh +48 -0
- package/scripts/lib/prompt-rules.sh +207 -0
- package/scripts/lib/tool-history.sh +7 -8
- package/scripts/lib/validate-path.sh +4 -0
- package/scripts/lint/check-manifests.mjs +314 -0
- package/scripts/lint/check-references.mjs +311 -0
- package/skills/list-prompt-rules/SKILL.md +15 -0
- package/test/bats/archivist-config-files.bats +60 -0
- package/test/bats/archivist-config.bats +54 -0
- package/test/bats/archivist-inject.bats +73 -0
- package/test/bats/archivist-project-key.bats +75 -0
- package/test/bats/archivist-storage.bats +119 -0
- package/test/bats/archivist-ulid.bats +36 -0
- package/test/bats/cartographer-config.bats +107 -0
- package/test/bats/cartographer-lock.bats +77 -0
- package/test/bats/cartographer-ulid.bats +56 -0
- package/test/bats/config.bats +10 -10
- package/test/bats/echo-config.bats +90 -0
- package/test/bats/echo-events.bats +121 -0
- package/test/bats/echo-project-key.bats +115 -0
- package/test/bats/echo-stop-hook.bats +101 -0
- package/test/bats/echo-ulid.bats +38 -0
- package/test/bats/portable-lock.bats +62 -0
- package/test/bats/prompt-rules.bats +269 -0
- package/test/bats/tribunal-aggregate.bats +77 -0
- package/test/bats/tribunal-config.bats +86 -0
- package/test/bats/tribunal-events.bats +209 -0
- package/test/bats/tribunal-gate.bats +95 -0
- package/test/bats/tribunal-jury.bats +80 -0
- package/test/bats/tribunal-rubric.bats +119 -0
- package/test/bats/tribunal-stop-hook.bats +73 -0
- package/test/bats/tribunal-verdict.bats +71 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
- package/test/helpers/setup.bash +9 -0
- package/test/node/check-manifests.test.mjs +173 -0
- package/test/node/check-references.test.mjs +279 -0
- package/test/node/coverage.test.mjs +143 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ADR-002: Majority Gate Policy as Default
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted
|
|
4
|
+
**Date:** 2026-05-24
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
After the Jury and Meta-Judge tiers produce scores, the Gate must decide: accept the output, retry, or exhaust? Several policies were considered:
|
|
9
|
+
|
|
10
|
+
- **Score threshold only** — pass if `aggregated_score >= threshold` (e.g., 0.75).
|
|
11
|
+
- **Unanimous** — pass only if every judge voted passed.
|
|
12
|
+
- **Majority** — pass if strictly more than half of judges voted passed.
|
|
13
|
+
- **Meta-override** — the Meta-Judge's recommendation overrides the jury.
|
|
14
|
+
- **Hybrid** — any combination of the above.
|
|
15
|
+
|
|
16
|
+
The available policies in config are: `majority`, `strict` (alias for `unanimous`), `unanimous`, `meta_override`.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
The default gate policy is **`majority`**. The gate requires **both** the jury policy vote **and** `score_threshold` to clear — both conditions must be true for a pass. `score_threshold: 0.75` is a hard blocking condition, not just a reporting signal.
|
|
21
|
+
|
|
22
|
+
## Rationale
|
|
23
|
+
|
|
24
|
+
**Majority is the most intuitive policy for a multi-judge panel.** In any jury system, majority verdict is the natural baseline. It prevents a single outlier judge from blocking a good result indefinitely (the adversarial judge is *designed* to find fault and rarely gives a full pass).
|
|
25
|
+
|
|
26
|
+
**Unanimous is too strict for the default judge composition.** With `["standard", "adversarial"]`, the adversarial judge is built to be skeptical. A policy requiring it to pass alongside the standard judge effectively gives veto power to the judge whose job is to reject. In practice, unanimous with this composition would mean the gate almost never passes.
|
|
27
|
+
|
|
28
|
+
**Score threshold alone conflates jury agreement with quality.** A score of 0.8 from two judges who disagree strongly (e.g., 1.0 and 0.6) is a different signal than 0.8 from two judges who both scored 0.8. The majority policy captures agreement; the dissent threshold captures disagreement.
|
|
29
|
+
|
|
30
|
+
## The 2-judge edge case
|
|
31
|
+
|
|
32
|
+
The majority formula is `passed_count * 2 > total_count`. With two judges:
|
|
33
|
+
|
|
34
|
+
| Judges passed | Formula | Result |
|
|
35
|
+
|--------------|---------|--------|
|
|
36
|
+
| 2/2 | `2 * 2 > 2` → `4 > 2` | ✓ pass |
|
|
37
|
+
| 1/2 | `1 * 2 > 2` → `2 > 2` | ✗ block |
|
|
38
|
+
| 0/2 | `0 * 2 > 2` → `0 > 2` | ✗ block |
|
|
39
|
+
|
|
40
|
+
This means with the default two-judge panel, **both judges must pass** for the gate to open. This behaves like unanimous in the 2-judge case. This was observed during early Tribunal development (Echo's own Tribunal evaluation exhausted all 3 iterations because the adversarial judge never passed). It is technically correct — strictly more than half of 2 requires 2 — but surprises users expecting "majority" to mean "1 out of 2".
|
|
41
|
+
|
|
42
|
+
**The consequence is intentional:** two judges is already a lean panel. Requiring both to pass ensures quality signal from both perspectives before accepting. Users who want genuine 2/3 behavior should add a third judge type (e.g., `security`) to the panel — majority with three judges means two must pass, which is materially different from two-judge unanimous.
|
|
43
|
+
|
|
44
|
+
## Consequences
|
|
45
|
+
|
|
46
|
+
- The `majority` policy with 2 judges is effectively `unanimous`. This should be documented prominently for users configuring judge panels.
|
|
47
|
+
- Adding a third judge type (e.g., `security`) changes `majority` to mean 2/3, which is a meaningfully different bar. Users who want consistent behavior regardless of panel size should specify `gate_policy: "unanimous"` explicitly.
|
|
48
|
+
- The `meta_override` policy gives the Meta-Judge final say, bypassing jury vote counts entirely. This is available but not the default — it introduces a single point of failure (Meta-Judge bias or hallucination) that the default policy is specifically designed to avoid.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Tribunal Stop-gate hook.
|
|
3
|
+
#
|
|
4
|
+
# Triggered by Stop. Off by default — gated on tribunal.stop_hook.enabled in
|
|
5
|
+
# config. When enabled, runs a single-judge advisory pass on the just-finished
|
|
6
|
+
# session's last turn and writes a verdict for review on the next session.
|
|
7
|
+
#
|
|
8
|
+
# Why advisory only: by the time Stop fires the main agent loop has already
|
|
9
|
+
# ended. We cannot retry the Actor or re-run the work. The hook records what
|
|
10
|
+
# the Standard Judge would have said so a human (or a follow-up SessionStart
|
|
11
|
+
# hook in v0.2) can see whether the turn would have passed the gate.
|
|
12
|
+
#
|
|
13
|
+
# Hook contract:
|
|
14
|
+
# - Always exits 0. Never blocks Stop.
|
|
15
|
+
# - Skips silently if disabled, no git context, no transcript, or skip_if_no_file_changes
|
|
16
|
+
# is true and the last turn did not modify files.
|
|
17
|
+
# - Errors from `claude -p` are swallowed; worst case is "no verdict for this stop".
|
|
18
|
+
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
22
|
+
PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
23
|
+
|
|
24
|
+
# Ecosystem substrate lives in the sibling ecosystem plugin. Same lookup as
|
|
25
|
+
# archivist-extract.sh.
|
|
26
|
+
_ECOSYSTEM_ROOT="${ONLOOKER_ECOSYSTEM_ROOT:-}"
|
|
27
|
+
if [[ -z "$_ECOSYSTEM_ROOT" ]]; then
|
|
28
|
+
_candidate="$(cd "${PLUGIN_ROOT}/../.." 2>/dev/null && pwd)"
|
|
29
|
+
if [[ -f "${_candidate}/scripts/lib/validate-path.sh" ]]; then
|
|
30
|
+
_ECOSYSTEM_ROOT="$_candidate"
|
|
31
|
+
fi
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
35
|
+
# shellcheck disable=SC1091
|
|
36
|
+
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
37
|
+
# shellcheck disable=SC1091
|
|
38
|
+
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/onlooker-schema.sh"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# shellcheck source=../lib/tribunal-config.sh
|
|
42
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-config.sh"
|
|
43
|
+
# shellcheck source=../lib/tribunal-project-key.sh
|
|
44
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-project-key.sh"
|
|
45
|
+
# shellcheck source=../lib/tribunal-ulid.sh
|
|
46
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-ulid.sh"
|
|
47
|
+
# shellcheck source=../lib/tribunal-events.sh
|
|
48
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-events.sh"
|
|
49
|
+
# shellcheck source=../lib/tribunal-verdict.sh
|
|
50
|
+
source "${PLUGIN_ROOT}/scripts/lib/tribunal-verdict.sh"
|
|
51
|
+
|
|
52
|
+
INPUT=$(cat)
|
|
53
|
+
|
|
54
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
|
|
55
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
|
|
56
|
+
TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null) || TRANSCRIPT_PATH=""
|
|
57
|
+
|
|
58
|
+
# Stop hook MUST NOT emit any stdout besides the optional `{continue: ...}`
|
|
59
|
+
# acknowledgement. Exiting 0 with no output is the safe path.
|
|
60
|
+
_done() {
|
|
61
|
+
exit 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
REPO_ROOT=$(tribunal_project_repo_root "$CWD")
|
|
65
|
+
tribunal_config_load "$REPO_ROOT"
|
|
66
|
+
|
|
67
|
+
if ! tribunal_config_stop_hook_enabled; then
|
|
68
|
+
_done
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
PROJECT_KEY=$(tribunal_project_key "$CWD")
|
|
72
|
+
if [[ -z "$PROJECT_KEY" || -z "$REPO_ROOT" ]]; then
|
|
73
|
+
_done
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
if [[ -z "$TRANSCRIPT_PATH" || ! -f "$TRANSCRIPT_PATH" ]]; then
|
|
77
|
+
_done
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# Skip if no files were modified since the last commit AND the user enabled
|
|
81
|
+
# skip_if_no_file_changes (default true).
|
|
82
|
+
SKIP_IF_CLEAN=$(tribunal_config_get '.tribunal.stop_hook.skip_if_no_file_changes')
|
|
83
|
+
if [[ "$SKIP_IF_CLEAN" == "true" ]]; then
|
|
84
|
+
if git -C "$REPO_ROOT" diff --quiet 2>/dev/null && git -C "$REPO_ROOT" diff --cached --quiet 2>/dev/null; then
|
|
85
|
+
_done
|
|
86
|
+
fi
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
90
|
+
_done
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# ----------------------------------------------------------------------------
|
|
94
|
+
# Build the advisory-judge prompt.
|
|
95
|
+
# ----------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
JUDGE_MODEL=$(tribunal_config_judge_model "standard")
|
|
98
|
+
[[ -z "$JUDGE_MODEL" || "$JUDGE_MODEL" == "null" ]] && JUDGE_MODEL=""
|
|
99
|
+
|
|
100
|
+
SCORE_THRESHOLD=$(tribunal_config_get '.tribunal.session.score_threshold')
|
|
101
|
+
[[ -z "$SCORE_THRESHOLD" ]] && SCORE_THRESHOLD="0.75"
|
|
102
|
+
|
|
103
|
+
TRANSCRIPT_TAIL=$(tail -c 30000 "$TRANSCRIPT_PATH" 2>/dev/null) || TRANSCRIPT_TAIL=""
|
|
104
|
+
[[ -z "$TRANSCRIPT_TAIL" ]] && _done
|
|
105
|
+
|
|
106
|
+
DIFF_SUMMARY=$(git -C "$REPO_ROOT" diff --stat 2>/dev/null | tail -c 4000) || DIFF_SUMMARY=""
|
|
107
|
+
|
|
108
|
+
PROMPT_FILE=$(mktemp -t tribunal-stop-prompt.XXXXXX 2>/dev/null) || PROMPT_FILE="/tmp/tribunal-stop-prompt.$$"
|
|
109
|
+
trap 'rm -f "$PROMPT_FILE"' EXIT
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
printf '%s\n' 'You are a Tribunal Standard Judge performing an advisory pass on a just-finished Claude Code turn. Return JSON only — no prose, no markdown fences.'
|
|
113
|
+
printf '\n'
|
|
114
|
+
printf '%s\n' 'Output schema (TribunalVerdictPayload, exactly these keys):'
|
|
115
|
+
printf '%s\n' '{'
|
|
116
|
+
printf '%s\n' ' "score": 0.0..1.0,'
|
|
117
|
+
printf '%s\n' ' "passed": true|false,'
|
|
118
|
+
printf '%s\n' ' "judge_type": "standard",'
|
|
119
|
+
printf '%s\n' ' "feedback_summary": "1-3 sentences naming the highest-leverage concern, if any.",'
|
|
120
|
+
printf '%s\n' ' "confidence": 0.0..1.0'
|
|
121
|
+
printf '%s\n' '}'
|
|
122
|
+
printf '\n'
|
|
123
|
+
printf '%s\n' "Score the work the assistant performed in this turn against general correctness, completeness, and clarity. A score >= ${SCORE_THRESHOLD} is \"passed\"."
|
|
124
|
+
printf '%s\n' 'This is advisory — the main session has already ended, no retry will happen. Be concise.'
|
|
125
|
+
printf '\n'
|
|
126
|
+
if [[ -n "$DIFF_SUMMARY" ]]; then
|
|
127
|
+
printf '%s\n' '---WORKING-TREE DIFF STAT---'
|
|
128
|
+
printf '%s\n' "$DIFF_SUMMARY"
|
|
129
|
+
printf '%s\n' '---END DIFF STAT---'
|
|
130
|
+
printf '\n'
|
|
131
|
+
fi
|
|
132
|
+
printf '%s\n' '---TRANSCRIPT TAIL---'
|
|
133
|
+
printf '%s\n' "$TRANSCRIPT_TAIL"
|
|
134
|
+
printf '%s\n' '---END TRANSCRIPT TAIL---'
|
|
135
|
+
} > "$PROMPT_FILE"
|
|
136
|
+
|
|
137
|
+
CLAUDE_ARGS=(-p --max-turns 1)
|
|
138
|
+
[[ -n "$JUDGE_MODEL" ]] && CLAUDE_ARGS+=(--model "$JUDGE_MODEL")
|
|
139
|
+
|
|
140
|
+
RESPONSE=""
|
|
141
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
142
|
+
RESPONSE=$(timeout 60 claude "${CLAUDE_ARGS[@]}" < "$PROMPT_FILE" 2>/dev/null) || RESPONSE=""
|
|
143
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
144
|
+
RESPONSE=$(gtimeout 60 claude "${CLAUDE_ARGS[@]}" < "$PROMPT_FILE" 2>/dev/null) || RESPONSE=""
|
|
145
|
+
else
|
|
146
|
+
RESPONSE=$(claude "${CLAUDE_ARGS[@]}" < "$PROMPT_FILE" 2>/dev/null) || RESPONSE=""
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
[[ -z "$RESPONSE" ]] && _done
|
|
150
|
+
|
|
151
|
+
CLEAN_RESPONSE=$(printf '%s' "$RESPONSE" | sed -e 's/^```json//' -e 's/^```//' -e 's/```$//')
|
|
152
|
+
if ! printf '%s' "$CLEAN_RESPONSE" | jq -e '.score and (.passed // false | type == "boolean") and .judge_type' >/dev/null 2>&1; then
|
|
153
|
+
_done
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# ----------------------------------------------------------------------------
|
|
157
|
+
# Emit the canonical event chain + persist the advisory verdict.
|
|
158
|
+
# ----------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
TASK_ID=$(tribunal_ulid)
|
|
161
|
+
ITERATION_ID=$(tribunal_ulid)
|
|
162
|
+
JUDGE_ID=$(tribunal_ulid)
|
|
163
|
+
|
|
164
|
+
START_PAYLOAD=$(jq -n \
|
|
165
|
+
--arg task_id "$TASK_ID" \
|
|
166
|
+
--arg model "$JUDGE_MODEL" \
|
|
167
|
+
--argjson thr "$SCORE_THRESHOLD" \
|
|
168
|
+
'{
|
|
169
|
+
task_id: $task_id,
|
|
170
|
+
judge_types: ["standard"],
|
|
171
|
+
gate_policy: "strict",
|
|
172
|
+
score_threshold: $thr,
|
|
173
|
+
max_iterations: 1
|
|
174
|
+
} + (if $model != "" then {judge_model_ids: [$model]} else {} end)')
|
|
175
|
+
|
|
176
|
+
ITER_PAYLOAD=$(jq -n \
|
|
177
|
+
--arg task_id "$TASK_ID" \
|
|
178
|
+
--arg iter_id "$ITERATION_ID" \
|
|
179
|
+
'{task_id: $task_id, iteration_id: $iter_id, iteration_number: 0, trigger: "initial"}')
|
|
180
|
+
|
|
181
|
+
JUDGE_START_PAYLOAD=$(jq -n \
|
|
182
|
+
--arg task_id "$TASK_ID" \
|
|
183
|
+
--arg iter_id "$ITERATION_ID" \
|
|
184
|
+
--arg judge_id "$JUDGE_ID" \
|
|
185
|
+
--arg model "$JUDGE_MODEL" \
|
|
186
|
+
'{
|
|
187
|
+
task_id: $task_id,
|
|
188
|
+
iteration_id: $iter_id,
|
|
189
|
+
judge_id: $judge_id,
|
|
190
|
+
judge_type: "standard",
|
|
191
|
+
judge_model_id: (if $model == "" then null else $model end)
|
|
192
|
+
} | with_entries(select(.value != null))')
|
|
193
|
+
|
|
194
|
+
VERDICT_PAYLOAD=$(printf '%s' "$CLEAN_RESPONSE" | jq -c \
|
|
195
|
+
--arg task_id "$TASK_ID" \
|
|
196
|
+
--arg iter_id "$ITERATION_ID" \
|
|
197
|
+
--arg judge_id "$JUDGE_ID" \
|
|
198
|
+
--arg model "$JUDGE_MODEL" \
|
|
199
|
+
--argjson thr "$SCORE_THRESHOLD" \
|
|
200
|
+
'{
|
|
201
|
+
task_id: $task_id,
|
|
202
|
+
score: .score,
|
|
203
|
+
passed: (.passed // (.score >= $thr)),
|
|
204
|
+
judge_type: "standard",
|
|
205
|
+
iteration_id: $iter_id,
|
|
206
|
+
judge_id: $judge_id,
|
|
207
|
+
feedback_summary: (.feedback_summary // ""),
|
|
208
|
+
confidence: (.confidence // 0.6),
|
|
209
|
+
judge_model_id: (if $model == "" then null else $model end)
|
|
210
|
+
} | with_entries(select(.value != null and .value != ""))')
|
|
211
|
+
|
|
212
|
+
SCORE=$(printf '%s' "$VERDICT_PAYLOAD" | jq -r '.score')
|
|
213
|
+
PASSED=$(printf '%s' "$VERDICT_PAYLOAD" | jq -r '.passed')
|
|
214
|
+
|
|
215
|
+
if [[ "$PASSED" == "true" ]]; then
|
|
216
|
+
GATE_PAYLOAD=$(jq -n \
|
|
217
|
+
--arg task_id "$TASK_ID" \
|
|
218
|
+
--arg iter_id "$ITERATION_ID" \
|
|
219
|
+
--argjson score "$SCORE" \
|
|
220
|
+
'{task_id: $task_id, iteration_id: $iter_id, final_score: $score, iteration_number: 0, judges_consulted: 1}')
|
|
221
|
+
GATE_EVENT="tribunal.gate.passed"
|
|
222
|
+
OUTCOME="accepted"
|
|
223
|
+
else
|
|
224
|
+
GATE_PAYLOAD=$(jq -n \
|
|
225
|
+
--arg task_id "$TASK_ID" \
|
|
226
|
+
--arg iter_id "$ITERATION_ID" \
|
|
227
|
+
--argjson score "$SCORE" \
|
|
228
|
+
'{task_id: $task_id, iteration_id: $iter_id, reason: "low_score", final_score: $score, iteration_number: 0, will_retry: false}')
|
|
229
|
+
GATE_EVENT="tribunal.gate.blocked"
|
|
230
|
+
OUTCOME="rejected"
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
COMPLETE_PAYLOAD=$(jq -n \
|
|
234
|
+
--arg task_id "$TASK_ID" \
|
|
235
|
+
--arg outcome "$OUTCOME" \
|
|
236
|
+
--argjson score "$SCORE" \
|
|
237
|
+
'{task_id: $task_id, outcome: $outcome, final_score: $score, iterations_used: 1}')
|
|
238
|
+
|
|
239
|
+
# Emit in canonical order. Each call is best-effort — a single schema failure
|
|
240
|
+
# should not break the user's Stop.
|
|
241
|
+
tribunal_emit_event "tribunal.session.start" "$START_PAYLOAD" || true
|
|
242
|
+
tribunal_emit_event "tribunal.iteration.start" "$ITER_PAYLOAD" || true
|
|
243
|
+
tribunal_emit_event "tribunal.judge.start" "$JUDGE_START_PAYLOAD" || true
|
|
244
|
+
tribunal_emit_event "tribunal.verdict" "$VERDICT_PAYLOAD" || true
|
|
245
|
+
tribunal_emit_event "$GATE_EVENT" "$GATE_PAYLOAD" || true
|
|
246
|
+
tribunal_emit_event "tribunal.session.complete" "$COMPLETE_PAYLOAD" || true
|
|
247
|
+
|
|
248
|
+
# Persist a single advisory file for the next session to surface.
|
|
249
|
+
STOP_DIR="$(tribunal_project_dir "$PROJECT_KEY")"
|
|
250
|
+
mkdir -p "$STOP_DIR" 2>/dev/null || _done
|
|
251
|
+
SAFE_SESSION_ID=$(printf '%s' "$SESSION_ID" | tr -c 'a-zA-Z0-9-' '_')
|
|
252
|
+
[[ -z "$SAFE_SESSION_ID" ]] && SAFE_SESSION_ID="unknown"
|
|
253
|
+
|
|
254
|
+
jq -n \
|
|
255
|
+
--arg task_id "$TASK_ID" \
|
|
256
|
+
--arg session_id "$SESSION_ID" \
|
|
257
|
+
--arg outcome "$OUTCOME" \
|
|
258
|
+
--argjson verdict "$VERDICT_PAYLOAD" \
|
|
259
|
+
'{
|
|
260
|
+
task_id: $task_id,
|
|
261
|
+
session_id: $session_id,
|
|
262
|
+
outcome: $outcome,
|
|
263
|
+
verdict: $verdict,
|
|
264
|
+
mode: "stop-advisory"
|
|
265
|
+
}' > "${STOP_DIR}/stop-${SAFE_SESSION_ID}.json" 2>/dev/null || true
|
|
266
|
+
|
|
267
|
+
_done
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Score aggregation for Tribunal.
|
|
3
|
+
#
|
|
4
|
+
# Aggregates per-judge verdicts into a single jury-level score per the chosen
|
|
5
|
+
# aggregation_method. Also computes the dissent metric (max - min) so callers
|
|
6
|
+
# can decide whether to emit tribunal.dissent.recorded.
|
|
7
|
+
#
|
|
8
|
+
# Verdicts input is a JSON array of TribunalVerdictPayload objects (or a subset
|
|
9
|
+
# containing at least { judge_id, score }). Rubric is the active rubric (for
|
|
10
|
+
# weighted_mean only).
|
|
11
|
+
#
|
|
12
|
+
# Exposes:
|
|
13
|
+
# tribunal_aggregate <method> <verdicts_json> [<rubric_json>]
|
|
14
|
+
# echoes the aggregated score (0..1) as a JSON number
|
|
15
|
+
# tribunal_disagreement <verdicts_json>
|
|
16
|
+
# echoes max(score) - min(score), or 0 if 0/1 verdicts
|
|
17
|
+
#
|
|
18
|
+
# weighted_mean uses *rubric criterion weights*, not per-judge weights — the
|
|
19
|
+
# semantics are "weight each criterion's contribution, then average judges'
|
|
20
|
+
# scores on each criterion." For v0.1 the per-criterion breakdown is not yet
|
|
21
|
+
# threaded through verdicts, so weighted_mean degrades to mean when the rubric
|
|
22
|
+
# weights cannot be applied. The schema still emits aggregation_method =
|
|
23
|
+
# "weighted_mean" so dashboards see the intent.
|
|
24
|
+
|
|
25
|
+
tribunal_aggregate() {
|
|
26
|
+
local method="${1:-mean}"
|
|
27
|
+
local verdicts="${2:-[]}"
|
|
28
|
+
local _rubric="${3:-{}}" # reserved for true weighted_mean once per-criterion scores are threaded
|
|
29
|
+
: "$_rubric"
|
|
30
|
+
|
|
31
|
+
local count
|
|
32
|
+
count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
|
|
33
|
+
[[ "$count" -eq 0 ]] && { printf '0'; return 0; }
|
|
34
|
+
|
|
35
|
+
case "$method" in
|
|
36
|
+
mean|weighted_mean)
|
|
37
|
+
printf '%s' "$verdicts" | jq -r '[.[].score] | add / length'
|
|
38
|
+
;;
|
|
39
|
+
median)
|
|
40
|
+
printf '%s' "$verdicts" | jq -r '
|
|
41
|
+
[.[].score] | sort as $s
|
|
42
|
+
| ($s | length) as $n
|
|
43
|
+
| if ($n % 2) == 1 then $s[($n - 1) / 2]
|
|
44
|
+
else (($s[$n / 2 - 1] + $s[$n / 2]) / 2)
|
|
45
|
+
end
|
|
46
|
+
'
|
|
47
|
+
;;
|
|
48
|
+
min)
|
|
49
|
+
printf '%s' "$verdicts" | jq -r '[.[].score] | min'
|
|
50
|
+
;;
|
|
51
|
+
*)
|
|
52
|
+
printf 'tribunal-aggregate: unknown method %s, falling back to mean\n' \
|
|
53
|
+
"$method" >&2
|
|
54
|
+
printf '%s' "$verdicts" | jq -r '[.[].score] | add / length'
|
|
55
|
+
;;
|
|
56
|
+
esac
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
tribunal_disagreement() {
|
|
60
|
+
local verdicts="${1:-[]}"
|
|
61
|
+
local count
|
|
62
|
+
count=$(printf '%s' "$verdicts" | jq 'length' 2>/dev/null) || count=0
|
|
63
|
+
[[ "$count" -lt 2 ]] && { printf '0'; return 0; }
|
|
64
|
+
printf '%s' "$verdicts" | jq -r '[.[].score] | (max - min)'
|
|
65
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Config resolution for Tribunal.
|
|
3
|
+
#
|
|
4
|
+
# Reads three layers, latest wins:
|
|
5
|
+
# 1. plugins/tribunal/config.json (defaults shipped with the plugin)
|
|
6
|
+
# 2. ~/.claude/settings.json
|
|
7
|
+
# 3. <repo>/.claude/settings.json
|
|
8
|
+
#
|
|
9
|
+
# Exposes:
|
|
10
|
+
# tribunal_config_load <repo_root> # populates _TRIBUNAL_CONFIG (JSON)
|
|
11
|
+
# tribunal_config_get <jq-path> # echoes string value (empty if unset)
|
|
12
|
+
# tribunal_config_get_json <jq-path> # echoes JSON value (null if unset)
|
|
13
|
+
# tribunal_config_enabled # 0 if tribunal.enabled is true
|
|
14
|
+
# tribunal_config_stop_hook_enabled # 0 if tribunal.stop_hook.enabled is true
|
|
15
|
+
# tribunal_config_judge_model <judge_type>
|
|
16
|
+
# # echoes per-judge-type model override,
|
|
17
|
+
# # falling back to tribunal.judges.model
|
|
18
|
+
#
|
|
19
|
+
# Settings overlay only touches the `tribunal.*` subtree so this plugin coexists
|
|
20
|
+
# with other plugins' configuration.
|
|
21
|
+
|
|
22
|
+
_TRIBUNAL_CONFIG="{}"
|
|
23
|
+
|
|
24
|
+
tribunal_config_load() {
|
|
25
|
+
local repo_root="${1:-}"
|
|
26
|
+
local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
|
|
27
|
+
local home_dir="${HOME:-}"
|
|
28
|
+
|
|
29
|
+
local merged="{}"
|
|
30
|
+
local file
|
|
31
|
+
|
|
32
|
+
file="${plugin_root}/config.json"
|
|
33
|
+
if [[ -f "$file" ]]; then
|
|
34
|
+
local defaults
|
|
35
|
+
defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
|
|
36
|
+
merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
|
|
37
|
+
|| merged="$defaults"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
for file in "${home_dir}/.claude/settings.json" "${repo_root}/.claude/settings.json"; do
|
|
41
|
+
[[ -n "$file" && -f "$file" ]] || continue
|
|
42
|
+
local overlay
|
|
43
|
+
overlay=$(jq '{ tribunal: (.tribunal // {}) }' "$file" 2>/dev/null) || continue
|
|
44
|
+
[[ -z "$overlay" ]] && continue
|
|
45
|
+
local attempt
|
|
46
|
+
if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
|
|
47
|
+
def deepmerge($a; $b):
|
|
48
|
+
if ($a|type) == "object" and ($b|type) == "object" then
|
|
49
|
+
reduce (($a|keys) + ($b|keys) | unique)[] as $k
|
|
50
|
+
({}; .[$k] = deepmerge($a[$k]; $b[$k]))
|
|
51
|
+
elif $b == null then $a
|
|
52
|
+
else $b end;
|
|
53
|
+
deepmerge($a; $b)
|
|
54
|
+
' 2>/dev/null) && [[ -n "$attempt" ]]; then
|
|
55
|
+
merged="$attempt"
|
|
56
|
+
fi
|
|
57
|
+
done
|
|
58
|
+
|
|
59
|
+
_TRIBUNAL_CONFIG="$merged"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Read a string value from the loaded config.
|
|
63
|
+
# Usage: tribunal_config_get '.tribunal.session.gate_policy'
|
|
64
|
+
tribunal_config_get() {
|
|
65
|
+
local path="$1"
|
|
66
|
+
printf '%s' "$_TRIBUNAL_CONFIG" | jq -r "${path} // empty" 2>/dev/null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Read a JSON value (arrays, objects, numbers) from the loaded config.
|
|
70
|
+
# Usage: tribunal_config_get_json '.tribunal.session.judge_types'
|
|
71
|
+
tribunal_config_get_json() {
|
|
72
|
+
local path="$1"
|
|
73
|
+
printf '%s' "$_TRIBUNAL_CONFIG" | jq -c "${path}" 2>/dev/null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Returns 0 if tribunal.enabled is true.
|
|
77
|
+
tribunal_config_enabled() {
|
|
78
|
+
local v
|
|
79
|
+
v=$(tribunal_config_get '.tribunal.enabled')
|
|
80
|
+
[[ "$v" == "true" ]]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Returns 0 if tribunal.stop_hook.enabled is true. Default is false.
|
|
84
|
+
tribunal_config_stop_hook_enabled() {
|
|
85
|
+
local v
|
|
86
|
+
v=$(tribunal_config_get '.tribunal.stop_hook.enabled')
|
|
87
|
+
[[ "$v" == "true" ]]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Resolve the model id for a given judge_type.
|
|
91
|
+
# Precedence: tribunal.judges.<type>.model > tribunal.judges.model
|
|
92
|
+
tribunal_config_judge_model() {
|
|
93
|
+
local judge_type="$1"
|
|
94
|
+
local override
|
|
95
|
+
override=$(tribunal_config_get ".tribunal.judges.\"${judge_type}\".model")
|
|
96
|
+
if [[ -n "$override" ]]; then
|
|
97
|
+
printf '%s' "$override"
|
|
98
|
+
return 0
|
|
99
|
+
fi
|
|
100
|
+
tribunal_config_get '.tribunal.judges.model'
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|