@onlooker-community/ecosystem 0.24.0 → 0.25.1
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 -13
- package/.claude-plugin/plugin.json +2 -2
- package/.release-please-manifest.json +5 -4
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +1 -0
- package/package.json +3 -3
- package/plugins/assayer/.claude-plugin/plugin.json +14 -0
- package/plugins/assayer/CHANGELOG.md +10 -0
- package/plugins/assayer/README.md +114 -0
- package/plugins/assayer/config.json +14 -0
- package/plugins/assayer/docs/adr/001-verify-claims-against-transcript-evidence.md +57 -0
- package/plugins/assayer/docs/design.md +72 -0
- package/plugins/assayer/hooks/hooks.json +15 -0
- package/plugins/assayer/scripts/hooks/assayer-stop.sh +249 -0
- package/plugins/assayer/scripts/lib/assayer-config.sh +88 -0
- package/plugins/assayer/scripts/lib/assayer-events.sh +85 -0
- package/plugins/assayer/scripts/lib/assayer-extract.sh +87 -0
- package/plugins/assayer/scripts/lib/assayer-project-key.sh +69 -0
- package/plugins/assayer/scripts/lib/assayer-transcript.sh +99 -0
- package/plugins/assayer/scripts/lib/assayer-ulid.sh +46 -0
- package/plugins/assayer/scripts/lib/assayer-verify.sh +95 -0
- package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
- package/plugins/cartographer/CHANGELOG.md +7 -0
- package/plugins/cartographer/scripts/lib/cartographer-lock.sh +17 -7
- package/plugins/cartographer/scripts/lib/portable-lock.sh +57 -0
- package/plugins/governor/.claude-plugin/plugin.json +1 -1
- package/plugins/governor/CHANGELOG.md +7 -0
- package/plugins/governor/scripts/hooks/governor-post-tool-use.sh +6 -2
- package/plugins/governor/scripts/hooks/governor-pre-tool-use.sh +6 -2
- package/plugins/governor/scripts/hooks/governor-session-start.sh +6 -2
- package/plugins/governor/scripts/hooks/governor-stop.sh +6 -2
- package/plugins/governor/scripts/lib/portable-lock.sh +59 -0
- package/release-please-config.json +16 -0
- package/scripts/lib/portable-lock.sh +1 -1
- package/test/bats/assayer-config.bats +60 -0
- package/test/bats/assayer-events.bats +99 -0
- package/test/bats/assayer-extract.bats +76 -0
- package/test/bats/assayer-project-key.bats +58 -0
- package/test/bats/assayer-stop-hook.bats +81 -0
- package/test/bats/assayer-transcript.bats +72 -0
- package/test/bats/assayer-ulid.bats +31 -0
- package/test/bats/assayer-verify.bats +89 -0
- package/test/bats/cartographer-lock.bats +19 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Minimal ULID generator for Assayer audit_id values.
|
|
3
|
+
# Crockford Base32, lexicographically sortable, time-ordered.
|
|
4
|
+
|
|
5
|
+
_ASSAYER_ULID_ALPHABET="0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
6
|
+
|
|
7
|
+
_assayer_ulid_encode() {
|
|
8
|
+
local n="$1"
|
|
9
|
+
local len="$2"
|
|
10
|
+
local out=""
|
|
11
|
+
local i
|
|
12
|
+
for ((i = 0; i < len; i++)); do
|
|
13
|
+
out="${_ASSAYER_ULID_ALPHABET:$((n % 32)):1}${out}"
|
|
14
|
+
n=$((n / 32))
|
|
15
|
+
done
|
|
16
|
+
printf '%s' "$out"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assayer_ulid() {
|
|
20
|
+
local now_ms
|
|
21
|
+
if [[ "$(uname)" == "Darwin" ]]; then
|
|
22
|
+
now_ms=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null) \
|
|
23
|
+
|| now_ms=$(($(date +%s) * 1000))
|
|
24
|
+
else
|
|
25
|
+
now_ms=$(date +%s%3N 2>/dev/null) || now_ms=$(($(date +%s) * 1000))
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
local rand_hex rand_hi rand_lo
|
|
29
|
+
rand_hex=$(openssl rand -hex 10 2>/dev/null)
|
|
30
|
+
if [[ -n "$rand_hex" && ${#rand_hex} -eq 20 ]]; then
|
|
31
|
+
rand_hi=$((16#${rand_hex:0:10}))
|
|
32
|
+
rand_lo=$((16#${rand_hex:10:10}))
|
|
33
|
+
else
|
|
34
|
+
rand_hi=$((RANDOM * 32768 + RANDOM))
|
|
35
|
+
rand_lo=$((RANDOM * 32768 + RANDOM))
|
|
36
|
+
rand_hi=$(((rand_hi * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
37
|
+
rand_lo=$(((rand_lo * 256 + RANDOM % 256) & ((1 << 40) - 1)))
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
local ts_part hi_part lo_part
|
|
41
|
+
ts_part=$(_assayer_ulid_encode "$now_ms" 10)
|
|
42
|
+
hi_part=$(_assayer_ulid_encode "$rand_hi" 8)
|
|
43
|
+
lo_part=$(_assayer_ulid_encode "$rand_lo" 8)
|
|
44
|
+
|
|
45
|
+
printf '%s%s%s' "$ts_part" "$hi_part" "$lo_part"
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Claim verification for Assayer.
|
|
3
|
+
#
|
|
4
|
+
# The deterministic half: given a claim (with a type and command_keyword) and
|
|
5
|
+
# the session's Bash commands paired with their is_error status, locate the
|
|
6
|
+
# command that would settle the claim and classify it. No LLM, no randomness —
|
|
7
|
+
# the same inputs always produce the same verdict.
|
|
8
|
+
#
|
|
9
|
+
# Matching: a claim type implies keywords (tests_pass -> "test", build_succeeds
|
|
10
|
+
# -> "build", ...); the LLM-supplied command_keyword is added. The MOST RECENT
|
|
11
|
+
# command containing any keyword wins, because an agent may fix and re-run, and
|
|
12
|
+
# the last run reflects the final state the claim describes.
|
|
13
|
+
#
|
|
14
|
+
# Verdicts:
|
|
15
|
+
# corroborated — matching command succeeded (is_error false)
|
|
16
|
+
# contradicted — matching command failed (is_error true)
|
|
17
|
+
# unverified — no matching command (reason no_matching_command), or the
|
|
18
|
+
# claim implies no checkable command (reason ambiguous)
|
|
19
|
+
|
|
20
|
+
# Classify a single claim against the collected commands.
|
|
21
|
+
# Echoes a JSON object: { verdict, evidence_command?, is_error?, excerpt?, reason? }
|
|
22
|
+
# $1 — claim JSON object
|
|
23
|
+
# $2 — commands JSON array (from assayer_collect_commands)
|
|
24
|
+
assayer_classify_claim() {
|
|
25
|
+
local claim="${1:-}"
|
|
26
|
+
local commands="${2:-[]}"
|
|
27
|
+
|
|
28
|
+
[[ -z "$claim" ]] && {
|
|
29
|
+
printf '{"verdict":"unverified","reason":"ambiguous"}'
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
[[ -z "$commands" || "$commands" == "null" ]] && commands="[]"
|
|
33
|
+
|
|
34
|
+
local result
|
|
35
|
+
result=$(jq -n \
|
|
36
|
+
--argjson claim "$claim" \
|
|
37
|
+
--argjson commands "$commands" '
|
|
38
|
+
def keywords:
|
|
39
|
+
($claim.type // "generic") as $t
|
|
40
|
+
| ( if $t == "tests_pass" then ["test"]
|
|
41
|
+
elif $t == "build_succeeds" then ["build"]
|
|
42
|
+
elif $t == "lint_clean" then ["lint"]
|
|
43
|
+
elif $t == "types_check" then ["tsc", "typecheck", "type-check", "types"]
|
|
44
|
+
else [] end ) as $base
|
|
45
|
+
| ($base + (if (($claim.command_keyword // "") | length) > 0 then [$claim.command_keyword] else [] end))
|
|
46
|
+
| map(ascii_downcase) | map(select(. != "")) | unique;
|
|
47
|
+
|
|
48
|
+
keywords as $kw
|
|
49
|
+
| if ($kw | length) == 0 then
|
|
50
|
+
{ verdict: "unverified", reason: "ambiguous" }
|
|
51
|
+
else
|
|
52
|
+
[ $commands[]
|
|
53
|
+
| . as $c
|
|
54
|
+
| select(($c.command | ascii_downcase) as $cmd | any($kw[]; . as $k | $cmd | contains($k)))
|
|
55
|
+
] as $matches
|
|
56
|
+
| if ($matches | length) == 0 then
|
|
57
|
+
{ verdict: "unverified", reason: "no_matching_command" }
|
|
58
|
+
else
|
|
59
|
+
($matches | last) as $m
|
|
60
|
+
| {
|
|
61
|
+
verdict: (if $m.is_error then "contradicted" else "corroborated" end),
|
|
62
|
+
evidence_command: $m.command,
|
|
63
|
+
is_error: $m.is_error,
|
|
64
|
+
excerpt: ($m.excerpt // "")
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
' 2>/dev/null) || result=""
|
|
69
|
+
|
|
70
|
+
[[ -z "$result" || "$result" == "null" ]] && result='{"verdict":"unverified","reason":"ambiguous"}'
|
|
71
|
+
printf '%s' "$result"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Derive the overall audit verdict from the three counts.
|
|
75
|
+
# $1 — contradicted count
|
|
76
|
+
# $2 — corroborated count
|
|
77
|
+
# $3 — unverified count
|
|
78
|
+
assayer_audit_verdict() {
|
|
79
|
+
local contradicted="${1:-0}"
|
|
80
|
+
local corroborated="${2:-0}"
|
|
81
|
+
local unverified="${3:-0}"
|
|
82
|
+
|
|
83
|
+
if [[ "$contradicted" -gt 0 ]]; then
|
|
84
|
+
printf 'contradictions_found'
|
|
85
|
+
elif [[ "$corroborated" -gt 0 ]]; then
|
|
86
|
+
printf 'clean'
|
|
87
|
+
else
|
|
88
|
+
# No contradictions and nothing corroborated — only unverified (or none).
|
|
89
|
+
if [[ "$unverified" -gt 0 ]]; then
|
|
90
|
+
printf 'clean'
|
|
91
|
+
else
|
|
92
|
+
printf 'nothing_to_verify'
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
95
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cartographer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Proactive periodic auditor of the persistent instruction layer (CLAUDE.md, AGENTS.md, .claude/rules/). Discovers all instruction files in the repo, extracts semantic maps, and surfaces contradictions, shadowing, gaps, and drift before they cause expensive agent misbehavior. Builds on the Onlooker ecosystem plugin.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Cartographer plugin are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.2.1](https://github.com/onlooker-community/ecosystem/compare/cartographer-v0.2.0...cartographer-v0.2.1) (2026-06-10)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* vendor portable-lock.sh into cartographer and governor ([#73](https://github.com/onlooker-community/ecosystem/issues/73)) ([ab2c354](https://github.com/onlooker-community/ecosystem/commit/ab2c354b131c26cc642ebb51e84a043dc43cbaa1))
|
|
11
|
+
|
|
5
12
|
## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/cartographer-v0.1.0...cartographer-v0.2.0) (2026-05-25)
|
|
6
13
|
|
|
7
14
|
|
|
@@ -9,17 +9,27 @@
|
|
|
9
9
|
# cartographer_lock_acquire <lock_file> # returns 0=acquired, 1=timeout
|
|
10
10
|
# cartographer_lock_release <lock_file>
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# portable-lock.sh is vendored into this plugin's lib dir (a sibling of this
|
|
13
|
+
# file) so cartographer stays self-contained when installed standalone from
|
|
14
|
+
# the marketplace, where the ecosystem repo's top-level scripts/lib/ is absent.
|
|
15
|
+
_CARTOGRAPHER_LOCK_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/portable-lock.sh"
|
|
13
16
|
|
|
14
|
-
if [[
|
|
15
|
-
|
|
17
|
+
if [[ -f "$_CARTOGRAPHER_LOCK_LIB" ]]; then
|
|
18
|
+
# shellcheck source=./portable-lock.sh
|
|
19
|
+
source "$_CARTOGRAPHER_LOCK_LIB"
|
|
20
|
+
else
|
|
21
|
+
# The vendored lock should always be present, but if an unexpected
|
|
22
|
+
# packaging or path issue removes it we must degrade gracefully: the
|
|
23
|
+
# cartographer hooks are fail-soft and contractually exit 0, so a hard
|
|
24
|
+
# exit here would crash a session this plugin was only meant to observe.
|
|
25
|
+
# Define a primitive that always fails to acquire, so the hooks'
|
|
26
|
+
# `cartographer_lock_acquire ... || exit 0` skips the audit instead.
|
|
27
|
+
printf '[cartographer-lock] WARN: portable-lock.sh not found at %s; locking disabled, skipping audit\n' \
|
|
16
28
|
"$_CARTOGRAPHER_LOCK_LIB" >&2
|
|
17
|
-
|
|
29
|
+
lock_acquire() { return 1; }
|
|
30
|
+
lock_release() { return 0; }
|
|
18
31
|
fi
|
|
19
32
|
|
|
20
|
-
# shellcheck source=../../../../scripts/lib/portable-lock.sh
|
|
21
|
-
source "$_CARTOGRAPHER_LOCK_LIB"
|
|
22
|
-
|
|
23
33
|
cartographer_lock_acquire() {
|
|
24
34
|
local lock_file="${1:?lock_file required}"
|
|
25
35
|
mkdir -p "$(dirname "$lock_file")" 2>/dev/null || true
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# portable-lock.sh — vendored copy of the ecosystem substrate's portable lock.
|
|
3
|
+
#
|
|
4
|
+
# Vendored into the cartographer plugin so the plugin is self-contained when
|
|
5
|
+
# installed standalone from the marketplace: the cache layout
|
|
6
|
+
# (~/.claude/plugins/cache/<owner>/cartographer/<version>/) does not include
|
|
7
|
+
# the ecosystem repo's top-level scripts/lib/, so reaching up to it breaks.
|
|
8
|
+
# This mirrors the per-plugin vendoring of cartographer-ulid.sh and friends.
|
|
9
|
+
# Keep in sync with scripts/lib/portable-lock.sh at the repo root.
|
|
10
|
+
#
|
|
11
|
+
# Portable advisory file locking via mkdir() atomicity.
|
|
12
|
+
#
|
|
13
|
+
# Replaces flock(1), which ships with util-linux on Linux but is not present
|
|
14
|
+
# in stock macOS. This matters because the Onlooker hooks run on user
|
|
15
|
+
# machines, not just in CI: a macOS user without util-linux would otherwise
|
|
16
|
+
# see concurrent writes to $ONLOOKER_DIR silently clobber each other.
|
|
17
|
+
#
|
|
18
|
+
# mkdir() is atomic on POSIX local filesystems, which is the only place
|
|
19
|
+
# $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
|
|
20
|
+
# atomicity, but Claude Code state is local-only.
|
|
21
|
+
#
|
|
22
|
+
# Usage:
|
|
23
|
+
# lock_acquire "/path/to/file.lock" [timeout_seconds=5]
|
|
24
|
+
# # ... critical section ...
|
|
25
|
+
# lock_release "/path/to/file.lock"
|
|
26
|
+
#
|
|
27
|
+
# Avoid associative arrays so bash 3.2 (macOS default) keeps working.
|
|
28
|
+
|
|
29
|
+
# Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
|
|
30
|
+
lock_acquire() {
|
|
31
|
+
local lockpath="${1:-}"
|
|
32
|
+
local timeout="${2:-5}"
|
|
33
|
+
[[ -z "$lockpath" ]] && return 1
|
|
34
|
+
|
|
35
|
+
local lockdir="${lockpath}.d"
|
|
36
|
+
local waited=0
|
|
37
|
+
# Poll at 10 Hz so a 5s timeout = 50 attempts.
|
|
38
|
+
local max_iter=$((timeout * 10))
|
|
39
|
+
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
40
|
+
if ((waited >= max_iter)); then
|
|
41
|
+
return 1
|
|
42
|
+
fi
|
|
43
|
+
# `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
|
|
44
|
+
# fallback for embedded shells that only accept integer seconds.
|
|
45
|
+
sleep 0.1 2>/dev/null || sleep 1
|
|
46
|
+
waited=$((waited + 1))
|
|
47
|
+
done
|
|
48
|
+
return 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Release the lock previously acquired for LOCKPATH. Safe to call when the
|
|
52
|
+
# lock is not held (no-op in that case).
|
|
53
|
+
lock_release() {
|
|
54
|
+
local lockpath="${1:-}"
|
|
55
|
+
[[ -z "$lockpath" ]] && return 0
|
|
56
|
+
rmdir "${lockpath}.d" 2>/dev/null || true
|
|
57
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "governor",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Resource governance and budget enforcement for the Onlooker ecosystem. Tracks per-session token and cost spend, gates Task spawns before they exceed a configurable budget ceiling, and emits governor.* events for audit. Named for the steam-engine governor — a device that regulates output. Builds on the Onlooker ecosystem plugin.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Onlooker Community",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.1](https://github.com/onlooker-community/ecosystem/compare/governor-v0.2.0...governor-v0.2.1) (2026-06-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* vendor portable-lock.sh into cartographer and governor ([#73](https://github.com/onlooker-community/ecosystem/issues/73)) ([ab2c354](https://github.com/onlooker-community/ecosystem/commit/ab2c354b131c26cc642ebb51e84a043dc43cbaa1))
|
|
9
|
+
|
|
3
10
|
## [0.2.0](https://github.com/onlooker-community/ecosystem/compare/governor-v0.1.0...governor-v0.2.0) (2026-05-26)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -24,12 +24,16 @@ fi
|
|
|
24
24
|
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
25
25
|
# shellcheck disable=SC1091
|
|
26
26
|
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
27
|
-
# shellcheck disable=SC1091
|
|
28
|
-
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
|
|
29
27
|
fi
|
|
30
28
|
|
|
31
29
|
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
32
30
|
|
|
31
|
+
# portable-lock.sh is vendored into this plugin's lib dir so the ledger's
|
|
32
|
+
# atomic appends keep working when governor is installed standalone, where the
|
|
33
|
+
# ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
|
|
34
|
+
# shellcheck source=../lib/portable-lock.sh
|
|
35
|
+
source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
|
|
36
|
+
|
|
33
37
|
# shellcheck source=../lib/governor-config.sh
|
|
34
38
|
source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
|
|
35
39
|
# shellcheck source=../lib/governor-events.sh
|
|
@@ -36,12 +36,16 @@ fi
|
|
|
36
36
|
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
37
37
|
# shellcheck disable=SC1091
|
|
38
38
|
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
39
|
-
# shellcheck disable=SC1091
|
|
40
|
-
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
|
|
41
39
|
fi
|
|
42
40
|
|
|
43
41
|
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
44
42
|
|
|
43
|
+
# portable-lock.sh is vendored into this plugin's lib dir so the ledger's
|
|
44
|
+
# atomic appends keep working when governor is installed standalone, where the
|
|
45
|
+
# ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
|
|
46
|
+
# shellcheck source=../lib/portable-lock.sh
|
|
47
|
+
source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
|
|
48
|
+
|
|
45
49
|
# shellcheck source=../lib/governor-config.sh
|
|
46
50
|
source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
|
|
47
51
|
# shellcheck source=../lib/governor-events.sh
|
|
@@ -28,12 +28,16 @@ fi
|
|
|
28
28
|
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
29
29
|
# shellcheck disable=SC1091
|
|
30
30
|
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
31
|
-
# shellcheck disable=SC1091
|
|
32
|
-
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
|
|
33
31
|
fi
|
|
34
32
|
|
|
35
33
|
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
36
34
|
|
|
35
|
+
# portable-lock.sh is vendored into this plugin's lib dir so the ledger's
|
|
36
|
+
# atomic appends keep working when governor is installed standalone, where the
|
|
37
|
+
# ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
|
|
38
|
+
# shellcheck source=../lib/portable-lock.sh
|
|
39
|
+
source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
|
|
40
|
+
|
|
37
41
|
# shellcheck source=../lib/governor-config.sh
|
|
38
42
|
source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
|
|
39
43
|
# shellcheck source=../lib/governor-events.sh
|
|
@@ -25,12 +25,16 @@ fi
|
|
|
25
25
|
if [[ -n "$_ECOSYSTEM_ROOT" && -f "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh" ]]; then
|
|
26
26
|
# shellcheck disable=SC1091
|
|
27
27
|
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/validate-path.sh"
|
|
28
|
-
# shellcheck disable=SC1091
|
|
29
|
-
CLAUDE_PLUGIN_ROOT="$_ECOSYSTEM_ROOT" source "${_ECOSYSTEM_ROOT}/scripts/lib/portable-lock.sh"
|
|
30
28
|
fi
|
|
31
29
|
|
|
32
30
|
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
33
31
|
|
|
32
|
+
# portable-lock.sh is vendored into this plugin's lib dir so the ledger's
|
|
33
|
+
# atomic appends keep working when governor is installed standalone, where the
|
|
34
|
+
# ecosystem repo's top-level scripts/lib/ is absent from the plugin cache.
|
|
35
|
+
# shellcheck source=../lib/portable-lock.sh
|
|
36
|
+
source "${PLUGIN_ROOT}/scripts/lib/portable-lock.sh"
|
|
37
|
+
|
|
34
38
|
# shellcheck source=../lib/governor-config.sh
|
|
35
39
|
source "${PLUGIN_ROOT}/scripts/lib/governor-config.sh"
|
|
36
40
|
# shellcheck source=../lib/governor-events.sh
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# portable-lock.sh — vendored copy of the ecosystem substrate's portable lock.
|
|
3
|
+
#
|
|
4
|
+
# Vendored into the governor plugin so the ledger's atomic appends keep
|
|
5
|
+
# working when governor is installed standalone from the marketplace: the
|
|
6
|
+
# cache layout (~/.claude/plugins/cache/<owner>/governor/<version>/) does not
|
|
7
|
+
# include the ecosystem repo's top-level scripts/lib/. Without a local copy,
|
|
8
|
+
# lock_acquire would be undefined and governor_ledger_append would poison the
|
|
9
|
+
# ledger after exhausting its retries. This mirrors the per-plugin vendoring
|
|
10
|
+
# of governor-ulid.sh and friends.
|
|
11
|
+
# Keep in sync with scripts/lib/portable-lock.sh at the repo root.
|
|
12
|
+
#
|
|
13
|
+
# Portable advisory file locking via mkdir() atomicity.
|
|
14
|
+
#
|
|
15
|
+
# Replaces flock(1), which ships with util-linux on Linux but is not present
|
|
16
|
+
# in stock macOS. This matters because the Onlooker hooks run on user
|
|
17
|
+
# machines, not just in CI: a macOS user without util-linux would otherwise
|
|
18
|
+
# see concurrent writes to $ONLOOKER_DIR silently clobber each other.
|
|
19
|
+
#
|
|
20
|
+
# mkdir() is atomic on POSIX local filesystems, which is the only place
|
|
21
|
+
# $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
|
|
22
|
+
# atomicity, but Claude Code state is local-only.
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
# lock_acquire "/path/to/file.lock" [timeout_seconds=5]
|
|
26
|
+
# # ... critical section ...
|
|
27
|
+
# lock_release "/path/to/file.lock"
|
|
28
|
+
#
|
|
29
|
+
# Avoid associative arrays so bash 3.2 (macOS default) keeps working.
|
|
30
|
+
|
|
31
|
+
# Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
|
|
32
|
+
lock_acquire() {
|
|
33
|
+
local lockpath="${1:-}"
|
|
34
|
+
local timeout="${2:-5}"
|
|
35
|
+
[[ -z "$lockpath" ]] && return 1
|
|
36
|
+
|
|
37
|
+
local lockdir="${lockpath}.d"
|
|
38
|
+
local waited=0
|
|
39
|
+
# Poll at 10 Hz so a 5s timeout = 50 attempts.
|
|
40
|
+
local max_iter=$((timeout * 10))
|
|
41
|
+
while ! mkdir "$lockdir" 2>/dev/null; do
|
|
42
|
+
if ((waited >= max_iter)); then
|
|
43
|
+
return 1
|
|
44
|
+
fi
|
|
45
|
+
# `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
|
|
46
|
+
# fallback for embedded shells that only accept integer seconds.
|
|
47
|
+
sleep 0.1 2>/dev/null || sleep 1
|
|
48
|
+
waited=$((waited + 1))
|
|
49
|
+
done
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Release the lock previously acquired for LOCKPATH. Safe to call when the
|
|
54
|
+
# lock is not held (no-op in that case).
|
|
55
|
+
lock_release() {
|
|
56
|
+
local lockpath="${1:-}"
|
|
57
|
+
[[ -z "$lockpath" ]] && return 0
|
|
58
|
+
rmdir "${lockpath}.d" 2>/dev/null || true
|
|
59
|
+
}
|
|
@@ -206,6 +206,22 @@
|
|
|
206
206
|
"jsonpath": "$.version"
|
|
207
207
|
}
|
|
208
208
|
]
|
|
209
|
+
},
|
|
210
|
+
"plugins/assayer": {
|
|
211
|
+
"changelog-path": "CHANGELOG.md",
|
|
212
|
+
"release-type": "simple",
|
|
213
|
+
"bump-minor-pre-major": true,
|
|
214
|
+
"bump-patch-for-minor-pre-major": false,
|
|
215
|
+
"component": "assayer",
|
|
216
|
+
"draft": false,
|
|
217
|
+
"prerelease": false,
|
|
218
|
+
"extra-files": [
|
|
219
|
+
{
|
|
220
|
+
"type": "json",
|
|
221
|
+
"path": ".claude-plugin/plugin.json",
|
|
222
|
+
"jsonpath": "$.version"
|
|
223
|
+
}
|
|
224
|
+
]
|
|
209
225
|
}
|
|
210
226
|
},
|
|
211
227
|
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# Replaces flock(1), which ships with util-linux on Linux but is not present
|
|
5
5
|
# in stock macOS. This matters because the Onlooker hooks run on user
|
|
6
6
|
# machines, not just in CI: a macOS user without util-linux would otherwise
|
|
7
|
-
# see
|
|
7
|
+
# see concurrent writes to $ONLOOKER_DIR silently clobber each other.
|
|
8
8
|
#
|
|
9
9
|
# mkdir() is atomic on POSIX local filesystems, which is the only place
|
|
10
10
|
# $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Exercises Assayer config loading: defaults and per-project overrides.
|
|
4
|
+
|
|
5
|
+
setup() {
|
|
6
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
7
|
+
setup_test_env
|
|
8
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/assayer"
|
|
9
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
10
|
+
# shellcheck disable=SC1091
|
|
11
|
+
source "${PLUGIN_ROOT}/scripts/lib/assayer-config.sh"
|
|
12
|
+
|
|
13
|
+
REPO="${BATS_TEST_TMPDIR}/repo"
|
|
14
|
+
mkdir -p "${REPO}/.claude"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@test "disabled by default (no settings)" {
|
|
18
|
+
assayer_config_load "$REPO"
|
|
19
|
+
run assayer_config_enabled
|
|
20
|
+
[ "$status" -ne 0 ]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@test "enabled when settings opt in" {
|
|
24
|
+
printf '%s\n' '{"assayer":{"enabled":true}}' >"${REPO}/.claude/settings.json"
|
|
25
|
+
assayer_config_load "$REPO"
|
|
26
|
+
run assayer_config_enabled
|
|
27
|
+
[ "$status" -eq 0 ]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@test "default model is haiku" {
|
|
31
|
+
assayer_config_load "$REPO"
|
|
32
|
+
[ "$(assayer_config_model)" = "claude-haiku-4-5-20251001" ]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@test "model override is honored" {
|
|
36
|
+
printf '%s\n' '{"assayer":{"evaluation":{"model":"claude-opus-4-8"}}}' >"${REPO}/.claude/settings.json"
|
|
37
|
+
assayer_config_load "$REPO"
|
|
38
|
+
[ "$(assayer_config_model)" = "claude-opus-4-8" ]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@test "default max_claims is 12" {
|
|
42
|
+
assayer_config_load "$REPO"
|
|
43
|
+
[ "$(assayer_config_max_claims)" = "12" ]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@test "default min_confidence is 0.5" {
|
|
47
|
+
assayer_config_load "$REPO"
|
|
48
|
+
[ "$(assayer_config_min_confidence)" = "0.5" ]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@test "min_confidence override is honored" {
|
|
52
|
+
printf '%s\n' '{"assayer":{"min_confidence":0.8}}' >"${REPO}/.claude/settings.json"
|
|
53
|
+
assayer_config_load "$REPO"
|
|
54
|
+
[ "$(assayer_config_min_confidence)" = "0.8" ]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@test "default timeout is 60" {
|
|
58
|
+
assayer_config_load "$REPO"
|
|
59
|
+
[ "$(assayer_config_timeout)" = "60" ]
|
|
60
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bats
|
|
2
|
+
|
|
3
|
+
# Validates every emitted assayer.* event against @onlooker-community/schema.
|
|
4
|
+
#
|
|
5
|
+
# The assayer.* event types ship in @onlooker-community/schema; until the
|
|
6
|
+
# installed version includes them, these tests skip rather than fail. Once the
|
|
7
|
+
# ecosystem's schema dependency is bumped to a release that carries them, they
|
|
8
|
+
# run for real. See plugins/assayer/README.md (Requirements).
|
|
9
|
+
|
|
10
|
+
setup() {
|
|
11
|
+
source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
|
|
12
|
+
setup_test_env
|
|
13
|
+
|
|
14
|
+
PLUGIN_ROOT="${REPO_ROOT}/plugins/assayer"
|
|
15
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
16
|
+
export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
|
|
17
|
+
mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
|
|
18
|
+
|
|
19
|
+
export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
|
|
20
|
+
export CLAUDE_SESSION_ID="bats-session-$$"
|
|
21
|
+
|
|
22
|
+
# shellcheck disable=SC1091
|
|
23
|
+
source "${PLUGIN_ROOT}/scripts/lib/assayer-events.sh"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Skip when the installed schema predates the assayer.* event types.
|
|
27
|
+
_require_assayer_schema() {
|
|
28
|
+
if ! grep -q "assayer.audit.started" \
|
|
29
|
+
"${REPO_ROOT}/node_modules/@onlooker-community/schema/schemas/event.v1.json" 2>/dev/null; then
|
|
30
|
+
skip "installed @onlooker-community/schema has no assayer.* types yet"
|
|
31
|
+
fi
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_validate_latest_event() {
|
|
35
|
+
local last
|
|
36
|
+
last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
|
|
37
|
+
[ -n "$last" ] || return 1
|
|
38
|
+
printf '%s' "$last" | ONLOOKER_DIR="$ONLOOKER_DIR" \
|
|
39
|
+
node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Valid 26-char Crockford Base32 ULID (no I, L, O, or U).
|
|
43
|
+
AUDIT_ID="01J0000000000000000000AB34"
|
|
44
|
+
|
|
45
|
+
@test "assayer.audit.started validates" {
|
|
46
|
+
_require_assayer_schema
|
|
47
|
+
local p
|
|
48
|
+
p=$(jq -n --arg a "$AUDIT_ID" '{audit_id: $a, claim_count: 3, trigger: "stop", command_count: 5}')
|
|
49
|
+
assayer_emit_event "assayer.audit.started" "$p"
|
|
50
|
+
run _validate_latest_event
|
|
51
|
+
[ "$status" -eq 0 ]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@test "assayer.claim.contradicted validates" {
|
|
55
|
+
_require_assayer_schema
|
|
56
|
+
local p
|
|
57
|
+
p=$(jq -n --arg a "$AUDIT_ID" '{
|
|
58
|
+
audit_id: $a,
|
|
59
|
+
claim: "I ran the tests and they all pass.",
|
|
60
|
+
claim_type: "tests_pass",
|
|
61
|
+
evidence_command: "npm test",
|
|
62
|
+
result_excerpt: "1 failed, 32 passed",
|
|
63
|
+
confidence: 0.9
|
|
64
|
+
}')
|
|
65
|
+
assayer_emit_event "assayer.claim.contradicted" "$p"
|
|
66
|
+
run _validate_latest_event
|
|
67
|
+
[ "$status" -eq 0 ]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@test "assayer.claim.unverified validates" {
|
|
71
|
+
_require_assayer_schema
|
|
72
|
+
local p
|
|
73
|
+
p=$(jq -n --arg a "$AUDIT_ID" '{audit_id: $a, claim: "The deploy is healthy.", claim_type: "generic", reason: "no_matching_command"}')
|
|
74
|
+
assayer_emit_event "assayer.claim.unverified" "$p"
|
|
75
|
+
run _validate_latest_event
|
|
76
|
+
[ "$status" -eq 0 ]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@test "assayer.audit.complete validates" {
|
|
80
|
+
_require_assayer_schema
|
|
81
|
+
local p
|
|
82
|
+
p=$(jq -n --arg a "$AUDIT_ID" '{
|
|
83
|
+
audit_id: $a, claim_count: 3, corroborated: 1, contradicted: 1,
|
|
84
|
+
unverified: 1, verdict: "contradictions_found", duration_ms: 4200
|
|
85
|
+
}')
|
|
86
|
+
assayer_emit_event "assayer.audit.complete" "$p"
|
|
87
|
+
run _validate_latest_event
|
|
88
|
+
[ "$status" -eq 0 ]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@test "emission fails on unknown event type" {
|
|
92
|
+
run assayer_emit_event "assayer.no.such.event" '{"audit_id":"x"}'
|
|
93
|
+
[ "$status" -ne 0 ]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@test "assayer_emit_event returns 1 when payload is empty" {
|
|
97
|
+
run assayer_emit_event "assayer.audit.started" ""
|
|
98
|
+
[ "$status" -ne 0 ]
|
|
99
|
+
}
|