@onlooker-community/ecosystem 0.28.1 → 0.29.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 (52) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +2 -2
  4. package/CHANGELOG.md +7 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/plugin-catalog.md +125 -0
  7. package/package.json +3 -3
  8. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  9. package/plugins/compass/CHANGELOG.md +7 -0
  10. package/plugins/compass/README.md +1 -3
  11. package/plugins/compass/config.json +1 -2
  12. package/plugins/compass/docs/design.md +1 -2
  13. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
  14. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +8 -1
  15. package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
  16. package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
  17. package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
  18. package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
  19. package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
  20. package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
  21. package/plugins/inspector/.claude-plugin/plugin.json +14 -0
  22. package/plugins/inspector/README.md +155 -0
  23. package/plugins/inspector/config.json +25 -0
  24. package/plugins/inspector/docs/design.md +286 -0
  25. package/plugins/inspector/hooks/hooks.json +33 -0
  26. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
  27. package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
  28. package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
  29. package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
  30. package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
  31. package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
  32. package/test/bats/archivist-project-key.bats +79 -0
  33. package/test/bats/archivist-storage.bats +79 -0
  34. package/test/bats/compact-tracker.bats +125 -0
  35. package/test/bats/compass-config.bats +65 -0
  36. package/test/bats/compass-gate.bats +129 -0
  37. package/test/bats/compass-sanitizer.bats +69 -0
  38. package/test/bats/compass-symbolic-skip.bats +88 -0
  39. package/test/bats/compass-transcript.bats +80 -0
  40. package/test/bats/inspector-config.bats +118 -0
  41. package/test/bats/inspector-events.bats +156 -0
  42. package/test/bats/inspector-post-write-hook.bats +164 -0
  43. package/test/bats/inspector-project-key.bats +68 -0
  44. package/test/bats/inspector-ulid.bats +34 -0
  45. package/test/bats/onlooker-schema.bats +111 -0
  46. package/test/bats/prompt-rules.bats +98 -0
  47. package/test/bats/session-tracker.bats +260 -0
  48. package/test/bats/skill-usage-tracker.bats +63 -0
  49. package/test/bats/task-tracker.bats +102 -0
  50. package/test/bats/turn-tracker.bats +180 -0
  51. package/test/bats/validate-path.bats +125 -0
  52. package/test/bats/worktree-tracker.bats +167 -0
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Validates every emitted inspector.* event against @onlooker-community/schema.
4
+ #
5
+ # The inspector.* 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.
9
+
10
+ setup() {
11
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
12
+ setup_test_env
13
+
14
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/inspector"
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/inspector-events.sh"
24
+ }
25
+
26
+ _require_inspector_schema() {
27
+ if ! grep -q "inspector.check.passed" \
28
+ "${REPO_ROOT}/node_modules/@onlooker-community/schema/schemas/event.v1.json" 2>/dev/null; then
29
+ skip "installed @onlooker-community/schema has no inspector.* types yet"
30
+ fi
31
+ }
32
+
33
+ _validate_latest_event() {
34
+ local last
35
+ last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
36
+ [ -n "$last" ] || return 1
37
+ printf '%s' "$last" | ONLOOKER_DIR="$ONLOOKER_DIR" \
38
+ node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
39
+ }
40
+
41
+ @test "inspector.check.passed validates" {
42
+ _require_inspector_schema
43
+ local p
44
+ p=$(jq -n '{
45
+ file_path: "/repo/src/a.ts",
46
+ file_path_relative: "src/a.ts",
47
+ tool_name: "Edit",
48
+ check_name: "biome",
49
+ check_kind: "lint",
50
+ argv: ["biome", "check", "/repo/src/a.ts"],
51
+ duration_ms: 124,
52
+ project_key: "a1b2c3d4e5f6"
53
+ }')
54
+ inspector_emit_event "inspector.check.passed" "$p"
55
+ run _validate_latest_event
56
+ [ "$status" -eq 0 ]
57
+ }
58
+
59
+ @test "inspector.check.failed validates" {
60
+ _require_inspector_schema
61
+ local p
62
+ p=$(jq -n '{
63
+ file_path: "/repo/src/a.ts",
64
+ file_path_relative: "src/a.ts",
65
+ tool_name: "Edit",
66
+ check_name: "tsc",
67
+ check_kind: "typecheck",
68
+ argv: ["tsc", "--noEmit"],
69
+ duration_ms: 980,
70
+ exit_code: 2,
71
+ issue_count: 3,
72
+ output_excerpt: "src/a.ts:42:5 - Type error",
73
+ output_truncated: false,
74
+ project_key: "a1b2c3d4e5f6"
75
+ }')
76
+ inspector_emit_event "inspector.check.failed" "$p"
77
+ run _validate_latest_event
78
+ [ "$status" -eq 0 ]
79
+ }
80
+
81
+ @test "inspector.check.failed validates with null issue_count" {
82
+ _require_inspector_schema
83
+ local p
84
+ p=$(jq -n '{
85
+ file_path: "/repo/src/a.ts",
86
+ tool_name: "Edit",
87
+ check_name: "mystery-linter",
88
+ check_kind: "lint",
89
+ exit_code: 1,
90
+ issue_count: null
91
+ }')
92
+ inspector_emit_event "inspector.check.failed" "$p"
93
+ run _validate_latest_event
94
+ [ "$status" -eq 0 ]
95
+ }
96
+
97
+ @test "inspector.check.skipped (per-check) validates" {
98
+ _require_inspector_schema
99
+ local p
100
+ p=$(jq -n '{
101
+ file_path: "/repo/scripts/deploy.sh",
102
+ file_path_relative: "scripts/deploy.sh",
103
+ tool_name: "Edit",
104
+ check_name: "shellcheck",
105
+ check_kind: "lint",
106
+ reason: "tool_missing",
107
+ project_key: "a1b2c3d4e5f6"
108
+ }')
109
+ inspector_emit_event "inspector.check.skipped" "$p"
110
+ run _validate_latest_event
111
+ [ "$status" -eq 0 ]
112
+ }
113
+
114
+ @test "inspector.check.skipped (whole-file) validates" {
115
+ _require_inspector_schema
116
+ local p
117
+ p=$(jq -n '{
118
+ file_path: "/repo/node_modules/foo/index.js",
119
+ file_path_relative: "node_modules/foo/index.js",
120
+ tool_name: "Write",
121
+ reason: "excluded_path",
122
+ project_key: "a1b2c3d4e5f6"
123
+ }')
124
+ inspector_emit_event "inspector.check.skipped" "$p"
125
+ run _validate_latest_event
126
+ [ "$status" -eq 0 ]
127
+ }
128
+
129
+ @test "inspector.run.completed validates" {
130
+ _require_inspector_schema
131
+ local p
132
+ p=$(jq -n '{
133
+ file_path: "/repo/src/a.ts",
134
+ file_path_relative: "src/a.ts",
135
+ tool_name: "Edit",
136
+ checks_run: 2,
137
+ checks_passed: 1,
138
+ checks_failed: 1,
139
+ checks_skipped: 0,
140
+ duration_ms: 1080,
141
+ project_key: "a1b2c3d4e5f6"
142
+ }')
143
+ inspector_emit_event "inspector.run.completed" "$p"
144
+ run _validate_latest_event
145
+ [ "$status" -eq 0 ]
146
+ }
147
+
148
+ @test "emission rejects an unknown event type" {
149
+ run inspector_emit_event "inspector.no.such.event" '{"file_path":"x"}'
150
+ [ "$status" -ne 0 ]
151
+ }
152
+
153
+ @test "inspector_emit_event returns 1 when payload is empty" {
154
+ run inspector_emit_event "inspector.check.passed" ""
155
+ [ "$status" -ne 0 ]
156
+ }
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises the Inspector PostToolUse hook's end-to-end behavior.
4
+ # Uses fake check commands (sh -c) so the suite has no external lint deps.
5
+
6
+ setup() {
7
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
8
+ setup_test_env
9
+
10
+ # These tests assert on the event log, so they require a schema that
11
+ # accepts inspector.* events. Skip when the installed package is too old.
12
+ if ! grep -q "inspector.check.passed" \
13
+ "${BATS_TEST_DIRNAME}/../../node_modules/@onlooker-community/schema/schemas/event.v1.json" 2>/dev/null; then
14
+ skip "installed @onlooker-community/schema has no inspector.* types yet"
15
+ fi
16
+
17
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/inspector"
18
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/inspector-post-write.sh"
19
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/logs/onlooker-events.jsonl"
20
+ mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
21
+
22
+ REPO="${BATS_TEST_TMPDIR}/repo"
23
+ mkdir -p "${REPO}/src" "${REPO}/.claude" "${REPO}/node_modules/foo"
24
+ git -C "$REPO" init -q
25
+ git -C "$REPO" remote add origin "https://example.com/test-${BATS_TEST_NUMBER}.git"
26
+ printf 'sample\n' >"${REPO}/src/sample.ts"
27
+ printf 'sample\n' >"${REPO}/src/sample.py"
28
+ printf 'sample\n' >"${REPO}/node_modules/foo/index.ts"
29
+ }
30
+
31
+ _input() {
32
+ local cwd="${1:-$REPO}" tool="${2:-Edit}" path="${3:-${REPO}/src/sample.ts}"
33
+ jq -n --arg cwd "$cwd" --arg tool "$tool" --arg p "$path" --arg sid "test-${BATS_TEST_NUMBER}" \
34
+ '{cwd:$cwd, session_id:$sid, tool_name:$tool, tool_input:{file_path:$p}}'
35
+ }
36
+
37
+ _settings() {
38
+ cat >"${REPO}/.claude/settings.json"
39
+ }
40
+
41
+ _event_count() {
42
+ local et="$1"
43
+ if [[ -f "$ONLOOKER_EVENTS_LOG" ]]; then
44
+ jq -c "select(.event_type == \"$et\")" "$ONLOOKER_EVENTS_LOG" | wc -l | tr -d ' '
45
+ else
46
+ printf '0'
47
+ fi
48
+ }
49
+
50
+ _run_hook() {
51
+ local input="$1"
52
+ printf '%s' "$input" | ONLOOKER_DIR="$ONLOOKER_DIR" CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" bash "$HOOK"
53
+ }
54
+
55
+ @test "exits 0 silently when inspector.enabled is false (default)" {
56
+ run _run_hook "$(_input)"
57
+ [ "$status" -eq 0 ]
58
+ [ -z "$output" ]
59
+ [ ! -f "$ONLOOKER_EVENTS_LOG" ] || [ "$(_event_count inspector.run.completed)" = "0" ]
60
+ }
61
+
62
+ @test "exits 0 when tool_name is not Write/Edit/MultiEdit" {
63
+ echo '{"inspector":{"enabled":true,"checks":{".ts":[{"name":"t","kind":"lint","argv":["true"]}]}}}' | _settings
64
+ run _run_hook "$(_input "$REPO" "Bash")"
65
+ [ "$status" -eq 0 ]
66
+ [ -z "$output" ]
67
+ [ "$(_event_count inspector.run.completed)" = "0" ]
68
+ }
69
+
70
+ @test "recursion guard: INSPECTOR_NESTED=1 causes immediate exit 0" {
71
+ echo '{"inspector":{"enabled":true,"checks":{".ts":[{"name":"t","kind":"lint","argv":["true"]}]}}}' | _settings
72
+ run bash -c "printf '%s' '$(_input)' | INSPECTOR_NESTED=1 ONLOOKER_DIR='$ONLOOKER_DIR' CLAUDE_PLUGIN_ROOT='$PLUGIN_ROOT' '$HOOK'"
73
+ [ "$status" -eq 0 ]
74
+ [ -z "$output" ]
75
+ [ "$(_event_count inspector.run.completed)" = "0" ]
76
+ }
77
+
78
+ @test "files in excluded_paths emit a single .skipped event and produce no agent output" {
79
+ echo '{"inspector":{"enabled":true,"checks":{".ts":[{"name":"t","kind":"lint","argv":["true"]}]}}}' | _settings
80
+ run _run_hook "$(_input "$REPO" "Write" "${REPO}/node_modules/foo/index.ts")"
81
+ [ "$status" -eq 0 ]
82
+ [ -z "$output" ]
83
+ [ "$(_event_count inspector.check.skipped)" = "1" ]
84
+ [ "$(_event_count inspector.run.completed)" = "0" ]
85
+ # The skipped reason must be excluded_path.
86
+ [ "$(jq -r 'select(.event_type=="inspector.check.skipped").payload.reason' "$ONLOOKER_EVENTS_LOG")" = "excluded_path" ]
87
+ }
88
+
89
+ @test "extensions with no configured checks emit a single .skipped with no_extension_match" {
90
+ echo '{"inspector":{"enabled":true,"checks":{".ts":[{"name":"t","kind":"lint","argv":["true"]}]}}}' | _settings
91
+ run _run_hook "$(_input "$REPO" "Edit" "${REPO}/src/sample.py")"
92
+ [ "$status" -eq 0 ]
93
+ [ "$(_event_count inspector.check.skipped)" = "1" ]
94
+ [ "$(jq -r 'select(.event_type=="inspector.check.skipped").payload.reason' "$ONLOOKER_EVENTS_LOG")" = "no_extension_match" ]
95
+ }
96
+
97
+ @test "a passing check emits .passed and silences agent-facing stdout by default" {
98
+ echo '{"inspector":{"enabled":true,"checks":{".ts":[{"name":"clean","kind":"lint","argv":["true"]}]}}}' | _settings
99
+ run _run_hook "$(_input)"
100
+ [ "$status" -eq 0 ]
101
+ [ -z "$output" ]
102
+ [ "$(_event_count inspector.check.passed)" = "1" ]
103
+ [ "$(_event_count inspector.run.completed)" = "1" ]
104
+ }
105
+
106
+ @test "show_clean_runs surfaces the file header for passing checks" {
107
+ echo '{"inspector":{"enabled":true,"show_clean_runs":true,"checks":{".ts":[{"name":"clean","kind":"lint","argv":["true"]}]}}}' | _settings
108
+ run _run_hook "$(_input)"
109
+ [ "$status" -eq 0 ]
110
+ [[ "$output" == *"inspector: src/sample.ts"* ]]
111
+ [[ "$output" == *"✓ clean"* ]]
112
+ }
113
+
114
+ @test "a failing check emits .failed with exit_code and surfaces issue lines to the agent" {
115
+ cat <<'EOF' | _settings
116
+ {"inspector":{"enabled":true,"checks":{".ts":[
117
+ {"name":"broken","kind":"lint","argv":["sh","-c","echo 'src/sample.ts:1:1 - Bad'; exit 2"]}
118
+ ]}}}
119
+ EOF
120
+ run _run_hook "$(_input)"
121
+ [ "$status" -eq 0 ]
122
+ [[ "$output" == *"inspector: src/sample.ts"* ]]
123
+ [[ "$output" == *"✗ broken"* ]]
124
+ [[ "$output" == *"src/sample.ts:1:1 - Bad"* ]]
125
+ [ "$(_event_count inspector.check.failed)" = "1" ]
126
+ [ "$(jq -r 'select(.event_type=="inspector.check.failed").payload.exit_code' "$ONLOOKER_EVENTS_LOG")" = "2" ]
127
+ }
128
+
129
+ @test "a missing tool emits .skipped with tool_missing and produces no agent output" {
130
+ echo '{"inspector":{"enabled":true,"checks":{".ts":[{"name":"ghost","kind":"lint","argv":["this-tool-does-not-exist"]}]}}}' | _settings
131
+ run _run_hook "$(_input)"
132
+ [ "$status" -eq 0 ]
133
+ [ -z "$output" ]
134
+ [ "$(_event_count inspector.check.skipped)" = "1" ]
135
+ [ "$(jq -r 'select(.event_type=="inspector.check.skipped").payload.reason' "$ONLOOKER_EVENTS_LOG")" = "tool_missing" ]
136
+ }
137
+
138
+ @test "run.completed aggregates pass/fail/skip counts" {
139
+ cat <<'EOF' | _settings
140
+ {"inspector":{"enabled":true,"checks":{".ts":[
141
+ {"name":"clean", "kind":"lint", "argv":["true"]},
142
+ {"name":"broken","kind":"typecheck", "argv":["sh","-c","echo 'oops'; exit 1"]},
143
+ {"name":"ghost", "kind":"lint", "argv":["this-tool-does-not-exist"]}
144
+ ]}}}
145
+ EOF
146
+ run _run_hook "$(_input)"
147
+ [ "$status" -eq 0 ]
148
+ local completed
149
+ completed=$(jq -c 'select(.event_type=="inspector.run.completed").payload' "$ONLOOKER_EVENTS_LOG")
150
+ [ "$(echo "$completed" | jq -r '.checks_run')" = "2" ]
151
+ [ "$(echo "$completed" | jq -r '.checks_passed')" = "1" ]
152
+ [ "$(echo "$completed" | jq -r '.checks_failed')" = "1" ]
153
+ [ "$(echo "$completed" | jq -r '.checks_skipped')" = "1" ]
154
+ }
155
+
156
+ @test "argv is expanded with the touched file path" {
157
+ echo '{"inspector":{"enabled":true,"checks":{".ts":[{"name":"echo-file","kind":"lint","argv":["sh","-c","echo $1; test -n \"$1\"","--","${file}"]}]}}}' | _settings
158
+ run _run_hook "$(_input)"
159
+ [ "$status" -eq 0 ]
160
+ local argv
161
+ argv=$(jq -c 'select(.event_type=="inspector.check.passed").payload.argv' "$ONLOOKER_EVENTS_LOG")
162
+ # The last argv slot should now hold the resolved touched file path.
163
+ [[ "$(echo "$argv" | jq -r '.[-1]')" == *"src/sample.ts" ]]
164
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises Inspector's project-key derivation.
4
+
5
+ setup() {
6
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
7
+ setup_test_env
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/inspector"
9
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
10
+ # shellcheck disable=SC1091
11
+ source "${PLUGIN_ROOT}/scripts/lib/inspector-project-key.sh"
12
+ }
13
+
14
+ @test "produces a 12-char hex key from origin remote" {
15
+ REPO="${BATS_TEST_TMPDIR}/repo"
16
+ mkdir -p "$REPO"
17
+ git -C "$REPO" init -q
18
+ git -C "$REPO" remote add origin "https://example.com/widgets.git"
19
+
20
+ local key
21
+ key=$(inspector_project_key "$REPO")
22
+ [ "${#key}" = "12" ]
23
+ [[ "$key" =~ ^[0-9a-f]{12}$ ]]
24
+ }
25
+
26
+ @test "remote-derived key is stable across temp clones" {
27
+ REPO_A="${BATS_TEST_TMPDIR}/a"; mkdir -p "$REPO_A"
28
+ REPO_B="${BATS_TEST_TMPDIR}/b"; mkdir -p "$REPO_B"
29
+ git -C "$REPO_A" init -q
30
+ git -C "$REPO_B" init -q
31
+ git -C "$REPO_A" remote add origin "https://example.com/x.git"
32
+ git -C "$REPO_B" remote add origin "https://example.com/x.git"
33
+
34
+ local key_a key_b
35
+ key_a=$(inspector_project_key "$REPO_A")
36
+ key_b=$(inspector_project_key "$REPO_B")
37
+ [ "$key_a" = "$key_b" ]
38
+ }
39
+
40
+ @test "falls back to repo root when origin is missing" {
41
+ REPO="${BATS_TEST_TMPDIR}/no-remote"
42
+ mkdir -p "$REPO"
43
+ git -C "$REPO" init -q
44
+ local key
45
+ key=$(inspector_project_key "$REPO")
46
+ [ "${#key}" = "12" ]
47
+ }
48
+
49
+ @test "falls back to cwd when not a git repo" {
50
+ NOT_REPO="${BATS_TEST_TMPDIR}/not-git"
51
+ mkdir -p "$NOT_REPO"
52
+ local key
53
+ key=$(inspector_project_key "$NOT_REPO")
54
+ [ "${#key}" = "12" ]
55
+ }
56
+
57
+ @test "project_repo_root returns repo top-level for a git checkout" {
58
+ REPO="${BATS_TEST_TMPDIR}/repo2"
59
+ mkdir -p "$REPO/sub"
60
+ git -C "$REPO" init -q
61
+ local root expected
62
+ root=$(inspector_project_repo_root "$REPO/sub")
63
+ # Canonicalize both sides: macOS git resolves tmp paths through /private/,
64
+ # while $REPO retains the symlinked /var/folders/ prefix.
65
+ expected=$(cd "$REPO" && /bin/pwd -P 2>/dev/null || pwd)
66
+ root=$(cd "$root" && /bin/pwd -P 2>/dev/null || pwd)
67
+ [ "$root" = "$expected" ]
68
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises Inspector's ULID generator.
4
+
5
+ setup() {
6
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
7
+ setup_test_env
8
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/inspector"
9
+ # shellcheck disable=SC1091
10
+ source "${PLUGIN_ROOT}/scripts/lib/inspector-ulid.sh"
11
+ }
12
+
13
+ @test "produces a 26-char Crockford Base32 ULID" {
14
+ local id
15
+ id=$(inspector_ulid)
16
+ [ "${#id}" = "26" ]
17
+ [[ "$id" =~ ^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{26}$ ]]
18
+ }
19
+
20
+ @test "produces unique values on consecutive calls" {
21
+ local a b
22
+ a=$(inspector_ulid)
23
+ b=$(inspector_ulid)
24
+ [ "$a" != "$b" ]
25
+ }
26
+
27
+ @test "is lexicographically sortable across a short delay" {
28
+ local a b
29
+ a=$(inspector_ulid)
30
+ # Sleep is intentionally <1ms to avoid lengthening the suite, but
31
+ # even back-to-back invocations should not regress the timestamp prefix.
32
+ b=$(inspector_ulid)
33
+ [[ "$a" < "$b" || "$a" == "$b" ]] || [[ "${a:0:10}" == "${b:0:10}" ]]
34
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises scripts/lib/onlooker-schema.sh — the bash wrappers around the
4
+ # canonical event emitter (scripts/lib/onlooker-event.mjs). Events are
5
+ # validated against the real installed @onlooker-community/schema.
6
+
7
+ setup() {
8
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
9
+ load_validate_path
10
+ export _ONLOOKER_EVENT_JS="${REPO_ROOT}/scripts/lib/onlooker-event.mjs"
11
+ export ONLOOKER_PLUGIN_NAME="onlooker"
12
+ # shellcheck disable=SC1091
13
+ source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
14
+ }
15
+
16
+ _fixture() {
17
+ cat "${REPO_ROOT}/test/fixtures/hook-inputs/${1}"
18
+ }
19
+
20
+ # --- onlooker_append_event -------------------------------------------------
21
+
22
+ @test "onlooker_append_event appends a parseable line carrying the event_type" {
23
+ local line
24
+ line='{"event_type":"tool.file.read","payload":{"path":"/x"}}'
25
+ run onlooker_append_event "$line"
26
+ [ "$status" -eq 0 ]
27
+
28
+ local last
29
+ last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
30
+ # Round-trips through jq (valid JSON) and preserves the event_type.
31
+ [ "$(printf '%s' "$last" | jq -r '.event_type')" = "tool.file.read" ]
32
+ }
33
+
34
+ @test "onlooker_append_event with empty input is a no-op returning 0" {
35
+ run onlooker_append_event ""
36
+ [ "$status" -eq 0 ]
37
+ # No log line written.
38
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
39
+ }
40
+
41
+ @test "onlooker_append_event creates parent dirs when missing" {
42
+ # Point the log at a path whose parent does not yet exist.
43
+ export ONLOOKER_EVENTS_LOG="${ONLOOKER_DIR}/nested/deeper/onlooker-events.jsonl"
44
+ [ ! -d "$(dirname "$ONLOOKER_EVENTS_LOG")" ]
45
+
46
+ run onlooker_append_event '{"event_type":"tool.file.read"}'
47
+ [ "$status" -eq 0 ]
48
+ [ -d "$(dirname "$ONLOOKER_EVENTS_LOG")" ]
49
+ [ "$(tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -r '.event_type')" = "tool.file.read" ]
50
+ }
51
+
52
+ @test "onlooker_append_event appends rather than truncating" {
53
+ onlooker_append_event '{"event_type":"tool.file.read"}'
54
+ onlooker_append_event '{"event_type":"tool.shell.exec"}'
55
+ [ "$(wc -l <"$ONLOOKER_EVENTS_LOG" | tr -d ' ')" -eq 2 ]
56
+ [ "$(tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -r '.event_type')" = "tool.shell.exec" ]
57
+ }
58
+
59
+ # --- onlooker_event_from_hook ----------------------------------------------
60
+
61
+ @test "onlooker_event_from_hook maps a mappable fixture to tool.file.read" {
62
+ local event
63
+ event=$(onlooker_event_from_hook "$(_fixture post-tool-use-read.json)")
64
+ [ -n "$event" ]
65
+ [ "$(printf '%s' "$event" | jq -r '.event_type')" = "tool.file.read" ]
66
+ }
67
+
68
+ @test "onlooker_event_from_hook output validates against the schema" {
69
+ local event
70
+ event=$(onlooker_event_from_hook "$(_fixture post-tool-use-read.json)")
71
+ [ -n "$event" ]
72
+ printf '%s' "$event" | node "$_ONLOOKER_EVENT_JS" validate
73
+ }
74
+
75
+ @test "onlooker_event_from_hook prints empty for an unmappable fixture" {
76
+ local event
77
+ event=$(onlooker_event_from_hook "$(_fixture session-start-startup.json)")
78
+ [ -z "$event" ]
79
+ }
80
+
81
+ @test "onlooker_event_from_hook with empty input returns 0 and prints nothing" {
82
+ run onlooker_event_from_hook ""
83
+ [ "$status" -eq 0 ]
84
+ [ -z "$output" ]
85
+ }
86
+
87
+ # --- onlooker_emit_from_hook -----------------------------------------------
88
+
89
+ @test "onlooker_emit_from_hook writes exactly one tool.file.read line" {
90
+ run onlooker_emit_from_hook "$(_fixture post-tool-use-read.json)"
91
+ [ "$status" -eq 0 ]
92
+
93
+ [ "$(wc -l <"$ONLOOKER_EVENTS_LOG" | tr -d ' ')" -eq 1 ]
94
+ [ "$(tail -n 1 "$ONLOOKER_EVENTS_LOG" | jq -r '.event_type')" = "tool.file.read" ]
95
+ }
96
+
97
+ @test "onlooker_emit_from_hook leaves the log empty for an unmappable fixture" {
98
+ run onlooker_emit_from_hook "$(_fixture session-start-startup.json)"
99
+ [ "$status" -eq 0 ]
100
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
101
+ }
102
+
103
+ @test "onlooker_emit_from_hook records blocked=true for a failed Bash tool" {
104
+ run onlooker_emit_from_hook "$(_fixture post-tool-use-failure-bash.json)"
105
+ [ "$status" -eq 0 ]
106
+
107
+ local last
108
+ last=$(tail -n 1 "$ONLOOKER_EVENTS_LOG")
109
+ [ "$(printf '%s' "$last" | jq -r '.event_type')" = "tool.shell.exec" ]
110
+ [ "$(printf '%s' "$last" | jq -r '.payload.blocked')" = "true" ]
111
+ }
@@ -267,3 +267,101 @@ write_project_rules() {
267
267
  echo "$out" | grep -A2 "id: r1" | grep -q "fired: yes"
268
268
  echo "$out" | grep -A2 "id: r2" | grep -q "fired: no"
269
269
  }
270
+
271
+ @test "project_path: appends .claude/prompt-rules.json to the given cwd" {
272
+ local path
273
+ path=$(prompt_rules_project_path "/some/where")
274
+ [ "$path" = "/some/where/.claude/prompt-rules.json" ]
275
+ }
276
+
277
+ @test "project_path: defaults to \$PWD when no cwd is given" {
278
+ local path expected
279
+ path=$(prompt_rules_project_path)
280
+ expected="$PWD/.claude/prompt-rules.json"
281
+ [ "$path" = "$expected" ]
282
+ }
283
+
284
+ @test "fired_path: builds path under the sessions dir from the session id" {
285
+ local path expected
286
+ path=$(prompt_rules_fired_path "sess-XYZ")
287
+ expected="$ONLOOKER_PROMPT_RULES_SESSIONS_DIR/sess-XYZ.json"
288
+ [ "$path" = "$expected" ]
289
+ }
290
+
291
+ @test "fired_path: defaults session id to 'unknown' when none is given" {
292
+ local path expected
293
+ path=$(prompt_rules_fired_path)
294
+ expected="$ONLOOKER_PROMPT_RULES_SESSIONS_DIR/unknown.json"
295
+ [ "$path" = "$expected" ]
296
+ }
297
+
298
+ @test "emit: appends a JSON event line with type, session, payload, and plugin" {
299
+ : >"$ONLOOKER_EVENTS_LOG"
300
+ run prompt_rules_emit "sess-emit" "prompt_rule.matched" '{"rule_id":"rule-1"}'
301
+ [ "$status" -eq 0 ]
302
+
303
+ local line
304
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
305
+ [ "$(echo "$line" | jq -r '.event_type')" = "prompt_rule.matched" ]
306
+ [ "$(echo "$line" | jq -r '.session_id')" = "sess-emit" ]
307
+ [ "$(echo "$line" | jq -r '.payload.rule_id')" = "rule-1" ]
308
+ # No ONLOOKER_PLUGIN_NAME exported in this test → defaults to "onlooker".
309
+ [ "$(echo "$line" | jq -r '.plugin')" = "onlooker" ]
310
+ }
311
+
312
+ @test "emit: honors ONLOOKER_PLUGIN_NAME for the plugin field" {
313
+ : >"$ONLOOKER_EVENTS_LOG"
314
+ ONLOOKER_PLUGIN_NAME="prompt-rules" prompt_rules_emit "sess-plugin" "prompt_rule.applied" '{"rule_id":"r9"}'
315
+
316
+ local line
317
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
318
+ [ "$(echo "$line" | jq -r '.plugin')" = "prompt-rules" ]
319
+ }
320
+
321
+ @test "emit: defaults payload to an empty object when none is given" {
322
+ : >"$ONLOOKER_EVENTS_LOG"
323
+ run prompt_rules_emit "sess-nopayload" "prompt_rule.matched"
324
+ [ "$status" -eq 0 ]
325
+
326
+ local line
327
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
328
+ [ "$(echo "$line" | jq -c '.payload')" = "{}" ]
329
+ }
330
+
331
+ @test "emit: defaults session id to 'unknown' when none is given" {
332
+ : >"$ONLOOKER_EVENTS_LOG"
333
+ prompt_rules_emit "" "prompt_rule.matched" '{}'
334
+
335
+ local line
336
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
337
+ [ "$(echo "$line" | jq -r '.session_id')" = "unknown" ]
338
+ }
339
+
340
+ @test "emit: includes a numeric turn field when ONLOOKER_TURN_NUMBER is exported" {
341
+ : >"$ONLOOKER_EVENTS_LOG"
342
+ ONLOOKER_TURN_NUMBER=7 prompt_rules_emit "sess-turn" "prompt_rule.matched" '{}'
343
+
344
+ local line
345
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
346
+ [ "$(echo "$line" | jq -r '.turn')" = "7" ]
347
+ [ "$(echo "$line" | jq -r '.turn | type')" = "number" ]
348
+ }
349
+
350
+ @test "emit: omits the turn field when ONLOOKER_TURN_NUMBER is unset" {
351
+ : >"$ONLOOKER_EVENTS_LOG"
352
+ # Guard against any ambient value leaking in from the environment.
353
+ unset ONLOOKER_TURN_NUMBER
354
+ prompt_rules_emit "sess-noturn" "prompt_rule.matched" '{}'
355
+
356
+ local line
357
+ line=$(tail -1 "$ONLOOKER_EVENTS_LOG")
358
+ [ "$(echo "$line" | jq -e 'has("turn")')" = "false" ] || \
359
+ [ "$(echo "$line" | jq 'has("turn")')" = "false" ]
360
+ }
361
+
362
+ @test "emit: returns 1 and writes nothing when event_type is empty" {
363
+ : >"$ONLOOKER_EVENTS_LOG"
364
+ run prompt_rules_emit "sess-empty" ""
365
+ [ "$status" -eq 1 ]
366
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
367
+ }