@onlooker-community/ecosystem 0.9.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.claude-plugin/marketplace.json +39 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.github/copilot-instructions.md +46 -0
  4. package/.github/workflows/coverage.yml +78 -0
  5. package/.github/workflows/release.yml +24 -8
  6. package/.github/workflows/test.yml +3 -0
  7. package/.markdownlintignore +3 -0
  8. package/.release-please-manifest.json +4 -1
  9. package/CHANGELOG.md +44 -0
  10. package/README.md +57 -13
  11. package/config.json +6 -1
  12. package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
  13. package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
  14. package/docs/adr/003-ulid-over-uuid.md +40 -0
  15. package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
  16. package/docs/architecture.md +117 -0
  17. package/hooks/hooks.json +4 -0
  18. package/package.json +13 -7
  19. package/plugins/archivist/.claude-plugin/plugin.json +14 -0
  20. package/plugins/archivist/CHANGELOG.md +8 -0
  21. package/plugins/archivist/README.md +105 -0
  22. package/plugins/archivist/config.json +18 -0
  23. package/plugins/archivist/hooks/hooks.json +35 -0
  24. package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
  25. package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
  26. package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
  27. package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
  28. package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
  29. package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
  30. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  31. package/plugins/echo/CHANGELOG.md +24 -0
  32. package/plugins/echo/README.md +110 -0
  33. package/plugins/echo/config.json +15 -0
  34. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  35. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  36. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  37. package/plugins/echo/hooks/hooks.json +15 -0
  38. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  39. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  40. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  41. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  42. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  43. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  44. package/plugins/tribunal/CHANGELOG.md +10 -0
  45. package/plugins/tribunal/README.md +134 -0
  46. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  47. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  48. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  49. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  50. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  51. package/plugins/tribunal/config.json +50 -0
  52. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  53. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  54. package/plugins/tribunal/hooks/hooks.json +15 -0
  55. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  56. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  57. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  58. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  59. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  60. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  61. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  62. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  63. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  64. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  65. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  66. package/release-please-config.json +43 -5
  67. package/scripts/coverage/bash-coverage.mjs +169 -0
  68. package/scripts/coverage/format-comment.mjs +120 -0
  69. package/scripts/coverage/run-coverage.mjs +151 -0
  70. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  71. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  72. package/scripts/lib/onlooker-event.mjs +82 -10
  73. package/scripts/lib/portable-lock.sh +48 -0
  74. package/scripts/lib/prompt-rules.sh +207 -0
  75. package/scripts/lib/tool-history.sh +7 -8
  76. package/scripts/lib/validate-path.sh +4 -0
  77. package/scripts/lint/check-manifests.mjs +314 -0
  78. package/scripts/lint/check-references.mjs +311 -0
  79. package/skills/list-prompt-rules/SKILL.md +15 -0
  80. package/test/bats/archivist-config-files.bats +60 -0
  81. package/test/bats/archivist-config.bats +54 -0
  82. package/test/bats/archivist-inject.bats +73 -0
  83. package/test/bats/archivist-project-key.bats +75 -0
  84. package/test/bats/archivist-storage.bats +119 -0
  85. package/test/bats/archivist-ulid.bats +36 -0
  86. package/test/bats/config.bats +10 -10
  87. package/test/bats/echo-config.bats +90 -0
  88. package/test/bats/echo-events.bats +121 -0
  89. package/test/bats/echo-project-key.bats +115 -0
  90. package/test/bats/echo-stop-hook.bats +101 -0
  91. package/test/bats/echo-ulid.bats +38 -0
  92. package/test/bats/portable-lock.bats +62 -0
  93. package/test/bats/prompt-rules.bats +269 -0
  94. package/test/bats/read-chunk-tracking.bats +73 -0
  95. package/test/bats/tool-history-tracker.bats +1 -0
  96. package/test/bats/tribunal-aggregate.bats +77 -0
  97. package/test/bats/tribunal-config.bats +86 -0
  98. package/test/bats/tribunal-events.bats +209 -0
  99. package/test/bats/tribunal-gate.bats +95 -0
  100. package/test/bats/tribunal-jury.bats +80 -0
  101. package/test/bats/tribunal-rubric.bats +119 -0
  102. package/test/bats/tribunal-stop-hook.bats +73 -0
  103. package/test/bats/tribunal-verdict.bats +71 -0
  104. package/test/bats/validate-path.bats +1 -1
  105. package/test/fixtures/hook-inputs/post-tool-use-read-chunked.json +15 -0
  106. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  107. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  108. package/test/helpers/setup.bash +9 -0
  109. package/test/node/check-manifests.test.mjs +173 -0
  110. package/test/node/check-references.test.mjs +279 -0
  111. package/test/node/coverage.test.mjs +143 -0
  112. package/test/node/schema-events.test.mjs +41 -1
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/echo"
8
+ # shellcheck disable=SC1091
9
+ source "${PLUGIN_ROOT}/scripts/lib/echo-project-key.sh"
10
+ }
11
+
12
+ @test "non-git directory returns empty key" {
13
+ local d="${BATS_TEST_TMPDIR}/non-git"
14
+ mkdir -p "$d"
15
+ run echo_project_key "$d"
16
+ [ "$status" -eq 0 ]
17
+ [ -z "$output" ]
18
+ }
19
+
20
+ @test "git repo without remote falls back to repo-root hash" {
21
+ local d="${BATS_TEST_TMPDIR}/local-only-repo"
22
+ mkdir -p "$d"
23
+ git -C "$d" init -q
24
+ git -C "$d" config user.email t@example.com
25
+ git -C "$d" config user.name "Test"
26
+
27
+ local k1 k2
28
+ k1=$(echo_project_key "$d")
29
+ k2=$(echo_project_key "$d")
30
+ [ -n "$k1" ]
31
+ [ "${#k1}" -eq 12 ]
32
+ [ "$k1" = "$k2" ]
33
+ }
34
+
35
+ @test "git repo with remote uses remote hash" {
36
+ local a="${BATS_TEST_TMPDIR}/clone-a"
37
+ local b="${BATS_TEST_TMPDIR}/clone-b"
38
+ mkdir -p "$a" "$b"
39
+ for d in "$a" "$b"; do
40
+ git -C "$d" init -q
41
+ git -C "$d" config user.email t@example.com
42
+ git -C "$d" config user.name "Test"
43
+ git -C "$d" remote add origin git@github.com:org/proj.git
44
+ done
45
+
46
+ local ka kb
47
+ ka=$(echo_project_key "$a")
48
+ kb=$(echo_project_key "$b")
49
+ [ -n "$ka" ]
50
+ [ "$ka" = "$kb" ]
51
+ }
52
+
53
+ @test "different remotes yield different keys" {
54
+ local a="${BATS_TEST_TMPDIR}/proj-a"
55
+ local b="${BATS_TEST_TMPDIR}/proj-b"
56
+ mkdir -p "$a" "$b"
57
+ for d in "$a" "$b"; do
58
+ git -C "$d" init -q
59
+ git -C "$d" config user.email t@example.com
60
+ git -C "$d" config user.name "Test"
61
+ done
62
+ git -C "$a" remote add origin git@github.com:org/proj-a.git
63
+ git -C "$b" remote add origin git@github.com:org/proj-b.git
64
+
65
+ local ka kb
66
+ ka=$(echo_project_key "$a")
67
+ kb=$(echo_project_key "$b")
68
+ [ -n "$ka" ]
69
+ [ -n "$kb" ]
70
+ [ "$ka" != "$kb" ]
71
+ }
72
+
73
+ @test "echo_test_id_for_path returns 16 hex chars" {
74
+ local tid
75
+ tid=$(echo_test_id_for_path "plugins/tribunal/agents/tribunal-judge-standard.md")
76
+ [ -n "$tid" ]
77
+ [ "${#tid}" -eq 16 ]
78
+ [[ "$tid" =~ ^[0-9a-f]{16}$ ]]
79
+ }
80
+
81
+ @test "echo_test_id_for_path is stable across calls" {
82
+ local a b
83
+ a=$(echo_test_id_for_path "plugins/tribunal/agents/tribunal-judge-standard.md")
84
+ b=$(echo_test_id_for_path "plugins/tribunal/agents/tribunal-judge-standard.md")
85
+ [ "$a" = "$b" ]
86
+ }
87
+
88
+ @test "echo_test_id_for_path differs for different paths" {
89
+ local a b
90
+ a=$(echo_test_id_for_path "plugins/tribunal/agents/tribunal-judge-standard.md")
91
+ b=$(echo_test_id_for_path "plugins/tribunal/agents/tribunal-judge-adversarial.md")
92
+ [ "$a" != "$b" ]
93
+ }
94
+
95
+ @test "echo_project_repo_root returns empty for non-git dir" {
96
+ local d="${BATS_TEST_TMPDIR}/not-a-repo"
97
+ mkdir -p "$d"
98
+ local r
99
+ r=$(echo_project_repo_root "$d")
100
+ [ -z "$r" ]
101
+ }
102
+
103
+ @test "echo_project_repo_root returns repo root for subdir" {
104
+ local d="${BATS_TEST_TMPDIR}/myrepo"
105
+ mkdir -p "${d}/sub/dir"
106
+ git -C "$d" init -q
107
+ git -C "$d" config user.email t@example.com
108
+ git -C "$d" config user.name "Test"
109
+
110
+ local r expected
111
+ r=$(echo_project_repo_root "${d}/sub/dir")
112
+ # Resolve symlinks — on macOS BATS_TEST_TMPDIR may differ from git's toplevel.
113
+ expected=$(cd "$d" && pwd -P)
114
+ [ "$r" = "$expected" ]
115
+ }
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # Exercises the Echo Stop hook's gating behavior. Does not invoke claude -p
4
+ # (the hook bails before reaching the eval loop when preconditions fail).
5
+ # Tests verify: disabled-by-default, no-git, no-watched-changes, recursion
6
+ # guard, untracked file detection, and stdout silence.
7
+
8
+ setup() {
9
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
10
+ setup_test_env
11
+
12
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/echo"
13
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
14
+ HOOK="${PLUGIN_ROOT}/scripts/hooks/echo-stop-gate.sh"
15
+
16
+ REPO="${BATS_TEST_TMPDIR}/repo"
17
+ mkdir -p "$REPO"
18
+ git -C "$REPO" init -q
19
+ git -C "$REPO" config user.email test@example.com
20
+ git -C "$REPO" config user.name test
21
+ (cd "$REPO" && printf 'initial\n' > README.md && git add README.md && git commit -q -m init)
22
+ }
23
+
24
+ _make_input() {
25
+ local cwd="${1:-$REPO}" sid="${2:-test-session}"
26
+ jq -n --arg cwd "$cwd" --arg sid "$sid" '{cwd: $cwd, session_id: $sid}'
27
+ }
28
+
29
+ @test "hook exits 0 silently when echo.enabled is false (default)" {
30
+ local input
31
+ input=$(_make_input)
32
+ run bash -c "printf '%s' '$input' | ONLOOKER_DIR='$ONLOOKER_DIR' '$HOOK'"
33
+ [ "$status" -eq 0 ]
34
+ [ -z "$output" ]
35
+ }
36
+
37
+ @test "hook exits 0 when cwd is not a git repo" {
38
+ local non_repo="${BATS_TEST_TMPDIR}/not-a-repo"
39
+ mkdir -p "$non_repo"
40
+ local input
41
+ input=$(_make_input "$non_repo")
42
+ run bash -c "printf '%s' '$input' | ONLOOKER_DIR='$ONLOOKER_DIR' '$HOOK'"
43
+ [ "$status" -eq 0 ]
44
+ }
45
+
46
+ @test "hook exits 0 when enabled but no watched files changed" {
47
+ mkdir -p "${REPO}/.claude"
48
+ printf '%s\n' '{"echo":{"enabled":true}}' > "${REPO}/.claude/settings.json"
49
+ local input
50
+ input=$(_make_input)
51
+ run bash -c "printf '%s' '$input' | ONLOOKER_DIR='$ONLOOKER_DIR' '$HOOK'"
52
+ [ "$status" -eq 0 ]
53
+ [ -z "$output" ]
54
+ }
55
+
56
+ @test "recursion guard: ECHO_NESTED=1 causes immediate exit 0" {
57
+ mkdir -p "${REPO}/.claude"
58
+ printf '%s\n' '{"echo":{"enabled":true}}' > "${REPO}/.claude/settings.json"
59
+ local input
60
+ input=$(_make_input)
61
+ # Export ECHO_NESTED into the subshell that runs the hook.
62
+ run bash -c "printf '%s' '$input' | ECHO_NESTED=1 ONLOOKER_DIR='$ONLOOKER_DIR' '$HOOK'"
63
+ [ "$status" -eq 0 ]
64
+ [ -z "$output" ]
65
+ }
66
+
67
+ @test "hook never prints to stdout (Stop contract)" {
68
+ local input
69
+ input=$(_make_input)
70
+ run bash -c "printf '%s' '$input' | ONLOOKER_DIR='$ONLOOKER_DIR' '$HOOK'"
71
+ [ "$status" -eq 0 ]
72
+ [ -z "$output" ]
73
+ }
74
+
75
+ @test "untracked watched file is detected when enabled and claude missing" {
76
+ mkdir -p "${REPO}/.claude" "${REPO}/plugins/myplugin/agents"
77
+ printf '%s\n' '{"echo":{"enabled":true,"watch_paths":["plugins/*/agents/*.md"]}}' \
78
+ > "${REPO}/.claude/settings.json"
79
+ printf '%s\n' '# New agent' > "${REPO}/plugins/myplugin/agents/new-agent.md"
80
+ local input
81
+ input=$(_make_input)
82
+ # claude is not present in the test env PATH → hook reaches _done after the
83
+ # `command -v claude` guard. Exit 0 with no output confirms the file was
84
+ # at least detected (it passed the _done guards before the claude check).
85
+ run bash -c "PATH=/usr/bin:/bin printf '%s' '$input' | ONLOOKER_DIR='$ONLOOKER_DIR' '$HOOK'"
86
+ [ "$status" -eq 0 ]
87
+ }
88
+
89
+ @test "files under plugins/echo are excluded even if they match watch_paths" {
90
+ mkdir -p "${REPO}/.claude" "${REPO}/plugins/echo/agents"
91
+ printf '%s\n' '{"echo":{"enabled":true,"watch_paths":["plugins/*/agents/*.md"]}}' \
92
+ > "${REPO}/.claude/settings.json"
93
+ printf '%s\n' '# Echo self' > "${REPO}/plugins/echo/agents/self.md"
94
+ local input
95
+ input=$(_make_input)
96
+ # With echo's own file as the only change, no watched files remain after
97
+ # exclude filtering — hook exits before the claude guard (no output).
98
+ run bash -c "PATH=/usr/bin:/bin printf '%s' '$input' | ONLOOKER_DIR='$ONLOOKER_DIR' '$HOOK'"
99
+ [ "$status" -eq 0 ]
100
+ [ -z "$output" ]
101
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/echo"
8
+ # shellcheck disable=SC1091
9
+ source "${PLUGIN_ROOT}/scripts/lib/echo-ulid.sh"
10
+ }
11
+
12
+ @test "echo_ulid returns a 26-char crockford base32 string" {
13
+ local id
14
+ id=$(echo_ulid)
15
+ [ "${#id}" -eq 26 ]
16
+ [[ "$id" =~ ^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$ ]]
17
+ }
18
+
19
+ @test "two ULIDs minted apart are lexicographically ordered or equal" {
20
+ local a b
21
+ a=$(echo_ulid)
22
+ sleep 0.01
23
+ b=$(echo_ulid)
24
+ [[ "$a" < "$b" ]] || [ "$a" = "$b" ]
25
+ }
26
+
27
+ @test "many ULIDs are unique" {
28
+ local seen="${BATS_TEST_TMPDIR}/ulids.txt"
29
+ : > "$seen"
30
+ local i
31
+ for ((i = 0; i < 50; i++)); do
32
+ printf '%s\n' "$(echo_ulid)" >> "$seen"
33
+ done
34
+ local total unique
35
+ total=$(wc -l < "$seen" | tr -d ' ')
36
+ unique=$(sort -u "$seen" | wc -l | tr -d ' ')
37
+ [ "$total" = "$unique" ]
38
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ setup_test_env
7
+ # shellcheck disable=SC1091
8
+ source "${REPO_ROOT}/scripts/lib/portable-lock.sh"
9
+ LOCK="${BATS_TEST_TMPDIR}/test.lock"
10
+ }
11
+
12
+ @test "lock_acquire succeeds on an unlocked path" {
13
+ run lock_acquire "$LOCK" 1
14
+ [ "$status" -eq 0 ]
15
+ [ -d "${LOCK}.d" ]
16
+ lock_release "$LOCK"
17
+ }
18
+
19
+ @test "lock_acquire on a held lock blocks until released" {
20
+ lock_acquire "$LOCK" 1
21
+ # Start a background releaser after 200ms.
22
+ ( sleep 0.2; lock_release "$LOCK" ) &
23
+ local releaser=$!
24
+ # Second acquire should succeed once the releaser fires.
25
+ run lock_acquire "$LOCK" 2
26
+ wait $releaser
27
+ [ "$status" -eq 0 ]
28
+ lock_release "$LOCK"
29
+ }
30
+
31
+ @test "lock_acquire returns 1 when timeout elapses with the lock still held" {
32
+ mkdir "${LOCK}.d"
33
+ run lock_acquire "$LOCK" 1
34
+ [ "$status" -eq 1 ]
35
+ rmdir "${LOCK}.d"
36
+ }
37
+
38
+ @test "lock_release is a no-op when the lock is not held" {
39
+ run lock_release "$LOCK"
40
+ [ "$status" -eq 0 ]
41
+ }
42
+
43
+ @test "concurrent appenders do not interleave writes" {
44
+ local out="${BATS_TEST_TMPDIR}/concurrent.txt"
45
+ : >"$out"
46
+ local n=20
47
+ local i
48
+ for ((i = 0; i < n; i++)); do
49
+ (
50
+ lock_acquire "$LOCK" 5 || exit 1
51
+ # Write a 100-char marker so any byte-level interleave is obvious.
52
+ printf '%s\n' "$(printf 'x%.0s' {1..100})" >>"$out"
53
+ lock_release "$LOCK"
54
+ ) &
55
+ done
56
+ wait
57
+ # All lines should be exactly 100 bytes followed by newline.
58
+ local lines
59
+ lines=$(wc -l <"$out" | tr -d ' ')
60
+ [ "$lines" = "$n" ]
61
+ awk 'length($0) != 100 { bad++ } END { exit (bad > 0) }' "$out"
62
+ }
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ load_validate_path
7
+ # shellcheck source=../../scripts/lib/prompt-rules.sh
8
+ source "${REPO_ROOT}/scripts/lib/prompt-rules.sh"
9
+ export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
10
+
11
+ ensure_dir_exists "$ONLOOKER_PROMPT_RULES_SESSIONS_DIR"
12
+ }
13
+
14
+ write_global_rules() {
15
+ local body="$1"
16
+ printf '%s\n' "$body" > "$(prompt_rules_global_path)"
17
+ }
18
+
19
+ write_project_rules() {
20
+ local project_dir="$1"
21
+ local body="$2"
22
+ mkdir -p "${project_dir}/.claude"
23
+ printf '%s\n' "$body" > "${project_dir}/.claude/prompt-rules.json"
24
+ }
25
+
26
+ @test "load_merged returns empty array when no rule files exist" {
27
+ local rules
28
+ rules=$(prompt_rules_load_merged "$TEST_HOME")
29
+ [ "$(echo "$rules" | jq 'length')" = "0" ]
30
+ }
31
+
32
+ @test "load_merged surfaces global rules when only global exists" {
33
+ write_global_rules '{
34
+ "rules": [
35
+ {"id": "r1", "pattern": "foo", "guidance": "global rule", "enabled": true}
36
+ ]
37
+ }'
38
+ local rules
39
+ rules=$(prompt_rules_load_merged "$TEST_HOME")
40
+ [ "$(echo "$rules" | jq 'length')" = "1" ]
41
+ [ "$(echo "$rules" | jq -r '.[0].guidance')" = "global rule" ]
42
+ }
43
+
44
+ @test "load_merged: project overrides global by id" {
45
+ write_global_rules '{
46
+ "rules": [
47
+ {"id": "r1", "pattern": "foo", "guidance": "global version"},
48
+ {"id": "r2", "pattern": "bar", "guidance": "global only"}
49
+ ]
50
+ }'
51
+ write_project_rules "$TEST_HOME" '{
52
+ "rules": [
53
+ {"id": "r1", "pattern": "foo", "guidance": "project version"}
54
+ ]
55
+ }'
56
+ local rules
57
+ rules=$(prompt_rules_load_merged "$TEST_HOME")
58
+ [ "$(echo "$rules" | jq 'length')" = "2" ]
59
+ [ "$(echo "$rules" | jq -r '.[] | select(.id=="r1") | .guidance')" = "project version" ]
60
+ [ "$(echo "$rules" | jq -r '.[] | select(.id=="r2") | .guidance')" = "global only" ]
61
+ }
62
+
63
+ @test "load_merged filters out enabled: false rules" {
64
+ write_global_rules '{
65
+ "rules": [
66
+ {"id": "r1", "pattern": "foo", "guidance": "on"},
67
+ {"id": "r2", "pattern": "bar", "guidance": "off", "enabled": false}
68
+ ]
69
+ }'
70
+ local rules
71
+ rules=$(prompt_rules_load_merged "$TEST_HOME")
72
+ [ "$(echo "$rules" | jq 'length')" = "1" ]
73
+ [ "$(echo "$rules" | jq -r '.[0].id')" = "r1" ]
74
+ }
75
+
76
+ @test "pattern_matches: hit, miss, empty pattern" {
77
+ run prompt_rules_pattern_matches "hello world" "world"
78
+ [ "$status" -eq 0 ]
79
+ run prompt_rules_pattern_matches "hello world" "xyz"
80
+ [ "$status" -eq 1 ]
81
+ run prompt_rules_pattern_matches "hello world" ""
82
+ [ "$status" -eq 1 ]
83
+ }
84
+
85
+ @test "pattern_matches: invalid ERE returns non-match without leaking stderr" {
86
+ # Unbalanced bracket — bash would normally print "syntax error in regular
87
+ # expression" to stderr and return 2. The helper must swallow both.
88
+ run prompt_rules_pattern_matches "anything" "[unterminated"
89
+ [ "$status" -eq 1 ]
90
+ [ -z "$stderr" ] || [ -z "${stderr// /}" ]
91
+ }
92
+
93
+ @test "load_merged: tolerates non-array .rules and entries missing id" {
94
+ # Object instead of array, and rule entries with missing/non-string ids.
95
+ write_global_rules '{"rules": "not-an-array"}'
96
+ write_project_rules "$TEST_HOME" '{
97
+ "rules": [
98
+ {"pattern": "no-id"},
99
+ {"id": null, "pattern": "null-id"},
100
+ {"id": 42, "pattern": "non-string-id"},
101
+ {"id": "good", "pattern": "ok", "guidance": "ok"}
102
+ ]
103
+ }'
104
+ local rules
105
+ rules=$(prompt_rules_load_merged "$TEST_HOME")
106
+ [ "$(echo "$rules" | jq 'length')" = "1" ]
107
+ [ "$(echo "$rules" | jq -r '.[0].id')" = "good" ]
108
+ }
109
+
110
+ @test "mark_fired + load_fired round-trip is idempotent" {
111
+ prompt_rules_mark_fired "sess-A" "rule-1"
112
+ prompt_rules_mark_fired "sess-A" "rule-1"
113
+ prompt_rules_mark_fired "sess-A" "rule-2"
114
+ local fired
115
+ fired=$(prompt_rules_load_fired "sess-A")
116
+ [ "$(echo "$fired" | jq 'length')" = "2" ]
117
+ [ "$(echo "$fired" | jq -r '. | sort | join(",")')" = "rule-1,rule-2" ]
118
+ }
119
+
120
+ @test "hook injects additionalContext when prompt matches a rule" {
121
+ write_global_rules '{
122
+ "rules": [
123
+ {"id": "rule-no-verify", "pattern": "--no-verify", "guidance": "Skipping hooks usually masks the real issue."}
124
+ ]
125
+ }'
126
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json"
127
+
128
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh' 2>/dev/null"
129
+ [ "$status" -eq 0 ]
130
+ echo "$output" | jq -e \
131
+ '.hookSpecificOutput.hookEventName == "UserPromptSubmit"
132
+ and (.hookSpecificOutput.additionalContext | contains("Skipping hooks"))' >/dev/null
133
+ }
134
+
135
+ @test "hook outputs nothing when no rule matches" {
136
+ write_global_rules '{
137
+ "rules": [
138
+ {"id": "rule-no-verify", "pattern": "--no-verify", "guidance": "Skipping hooks usually masks the real issue."}
139
+ ]
140
+ }'
141
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json"
142
+
143
+ run bash -c "cat '${fixture}' | '${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh' 2>/dev/null"
144
+ [ "$status" -eq 0 ]
145
+ [ -z "$output" ]
146
+ }
147
+
148
+ @test "hook fires a rule once per session" {
149
+ write_global_rules '{
150
+ "rules": [
151
+ {"id": "rule-no-verify", "pattern": "--no-verify", "guidance": "Skipping hooks usually masks the real issue."}
152
+ ]
153
+ }'
154
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json"
155
+
156
+ # First invocation: injects
157
+ local first_output
158
+ first_output=$(cat "$fixture" | "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" 2>/dev/null)
159
+ echo "$first_output" | jq -e '.hookSpecificOutput.additionalContext | contains("Skipping hooks")' >/dev/null
160
+
161
+ # Second invocation with same session: no injection
162
+ local second_output
163
+ second_output=$(cat "$fixture" | "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" 2>/dev/null)
164
+ [ -z "$second_output" ]
165
+ }
166
+
167
+ @test "hook emits prompt_rule.matched and prompt_rule.applied to events log" {
168
+ write_global_rules '{
169
+ "rules": [
170
+ {"id": "rule-no-verify", "pattern": "--no-verify", "guidance": "Skipping hooks usually masks the real issue."}
171
+ ]
172
+ }'
173
+ : >"$ONLOOKER_EVENTS_LOG"
174
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json"
175
+
176
+ cat "$fixture" | "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" >/dev/null 2>&1
177
+
178
+ grep -q '"event_type":"prompt_rule.matched"' "$ONLOOKER_EVENTS_LOG"
179
+ grep -q '"event_type":"prompt_rule.applied"' "$ONLOOKER_EVENTS_LOG"
180
+ grep -q '"rule_id":"rule-no-verify"' "$ONLOOKER_EVENTS_LOG"
181
+ }
182
+
183
+ @test "hook fires repeatedly when fire_once_per_session is false" {
184
+ write_global_rules '{
185
+ "rules": [
186
+ {"id": "rule-no-verify", "pattern": "--no-verify", "guidance": "Skipping hooks usually masks the real issue.", "fire_once_per_session": false}
187
+ ]
188
+ }'
189
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json"
190
+
191
+ # Both invocations must inject — explicit false should not get coerced to true.
192
+ local first second
193
+ first=$(cat "$fixture" | "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" 2>/dev/null)
194
+ second=$(cat "$fixture" | "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" 2>/dev/null)
195
+
196
+ echo "$first" | jq -e '.hookSpecificOutput.additionalContext | contains("Skipping hooks")' >/dev/null
197
+ echo "$second" | jq -e '.hookSpecificOutput.additionalContext | contains("Skipping hooks")' >/dev/null
198
+ }
199
+
200
+ @test "hook still emits prompt_rule.matched on subsequent match but not prompt_rule.applied" {
201
+ write_global_rules '{
202
+ "rules": [
203
+ {"id": "rule-no-verify", "pattern": "--no-verify", "guidance": "msg"}
204
+ ]
205
+ }'
206
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json"
207
+
208
+ cat "$fixture" | "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" >/dev/null 2>&1
209
+ : >"$ONLOOKER_EVENTS_LOG"
210
+ cat "$fixture" | "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" >/dev/null 2>&1
211
+
212
+ grep -q '"event_type":"prompt_rule.matched"' "$ONLOOKER_EVENTS_LOG"
213
+ ! grep -q '"event_type":"prompt_rule.applied"' "$ONLOOKER_EVENTS_LOG"
214
+ }
215
+
216
+ @test "hook respects per_turn_max_chars: drops overflowing rules" {
217
+ write_global_rules '{
218
+ "rules": [
219
+ {"id": "r1", "pattern": "no-verify", "guidance": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
220
+ {"id": "r2", "pattern": "no-verify", "guidance": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}
221
+ ]
222
+ }'
223
+ # Use a temp plugin root with a tiny char budget
224
+ local tmp_root="${BATS_TEST_TMPDIR}/tiny-budget-root"
225
+ mkdir -p "$tmp_root"
226
+ printf '%s\n' '{"plugin_name":"onlooker","prompt_rules":{"enabled":true,"per_turn_max_chars":120}}' > "$tmp_root/config.json"
227
+
228
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json"
229
+ local output
230
+ output=$(CLAUDE_PLUGIN_ROOT="$tmp_root" cat "$fixture" | CLAUDE_PLUGIN_ROOT="$tmp_root" "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" 2>/dev/null)
231
+
232
+ # First rule fits (~98 chars); second pushes past 120, gets dropped.
233
+ echo "$output" | jq -e '.hookSpecificOutput.additionalContext | contains("AAAA") and (contains("BBBB") | not)' >/dev/null
234
+ }
235
+
236
+ @test "hook short-circuits when prompt_rules.enabled = false" {
237
+ write_global_rules '{
238
+ "rules": [
239
+ {"id": "r1", "pattern": "no-verify", "guidance": "should not fire"}
240
+ ]
241
+ }'
242
+ local tmp_root="${BATS_TEST_TMPDIR}/disabled-root"
243
+ mkdir -p "$tmp_root"
244
+ printf '%s\n' '{"plugin_name":"onlooker","prompt_rules":{"enabled":false}}' > "$tmp_root/config.json"
245
+
246
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json"
247
+ local output
248
+ output=$(CLAUDE_PLUGIN_ROOT="$tmp_root" "${REPO_ROOT}/scripts/hooks/prompt-rule-injector.sh" < "$fixture" 2>/dev/null)
249
+ [ -z "$output" ]
250
+ }
251
+
252
+ @test "list_table reports active rules and per-session fire status" {
253
+ write_global_rules '{
254
+ "rules": [
255
+ {"id": "r1", "pattern": "foo", "guidance": "global one"},
256
+ {"id": "r2", "pattern": "bar", "guidance": "global two"}
257
+ ]
258
+ }'
259
+ prompt_rules_mark_fired "session-list" "r1"
260
+
261
+ local out
262
+ out=$(prompt_rules_list_table "session-list" "$TEST_HOME")
263
+ echo "$out" | grep -q "active rules: 2"
264
+ echo "$out" | grep -q "id: r1"
265
+ echo "$out" | grep -q "id: r2"
266
+ # r1 is fired, r2 is not
267
+ echo "$out" | grep -A2 "id: r1" | grep -q "fired: yes"
268
+ echo "$out" | grep -A2 "id: r2" | grep -q "fired: no"
269
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ # shellcheck source=../helpers/setup.bash
5
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
6
+ load_validate_path
7
+ # shellcheck source=../../scripts/lib/onlooker-schema.sh
8
+ source "${REPO_ROOT}/scripts/lib/onlooker-schema.sh"
9
+ # shellcheck source=../../scripts/lib/tool-history.sh
10
+ source "${REPO_ROOT}/scripts/lib/tool-history.sh"
11
+ export CLAUDE_PLUGIN_ROOT="${REPO_ROOT}"
12
+
13
+ LARGE_FILE="${BATS_TEST_TMPDIR}/large-source.ts"
14
+ # > LARGE_FILE_BYTES_ON_DISK (100_000) for large_file_full_read
15
+ printf '%*s\n' 120000 "" >"$LARGE_FILE"
16
+ }
17
+
18
+ @test "tool_history maps full Read to read_mode full" {
19
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read.json"
20
+ local record
21
+ record=$(tool_history_build_record "$(cat "$fixture")")
22
+ echo "$record" | jq -e \
23
+ '.payload.read_mode == "full"
24
+ and .payload.path == "/project/src/main.ts"
25
+ and (.payload.large_file_full_read | not)' \
26
+ >/dev/null
27
+ echo "$record" | onlooker_validate_event
28
+ }
29
+
30
+ @test "tool_history maps chunked Read to read_mode partial with offset and limit" {
31
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read-chunked.json"
32
+ local record
33
+ record=$(tool_history_build_record "$(cat "$fixture")")
34
+ echo "$record" | jq -e \
35
+ '.payload.read_mode == "partial"
36
+ and .payload.offset == 400
37
+ and .payload.limit == 80
38
+ and .payload.lines_read == 3' \
39
+ >/dev/null
40
+ echo "$record" | onlooker_validate_event
41
+ }
42
+
43
+ @test "tool_history flags large_file_full_read for full read of large on-disk file" {
44
+ local input
45
+ input=$(jq -n \
46
+ --arg path "$LARGE_FILE" \
47
+ --arg content "peek\n" \
48
+ '{
49
+ session_id: "history-session-002",
50
+ hook_event_name: "PostToolUse",
51
+ tool_name: "Read",
52
+ tool_input: {file_path: $path},
53
+ tool_response: {content: $content}
54
+ }')
55
+ local record
56
+ record=$(tool_history_build_record "$input")
57
+ echo "$record" | jq -e \
58
+ '.payload.read_mode == "full"
59
+ and .payload.large_file_full_read == true
60
+ and .payload.file_bytes_on_disk > 100000' \
61
+ >/dev/null
62
+ echo "$record" | onlooker_validate_event
63
+ }
64
+
65
+ @test "tool-history-tracker appends chunked read to session JSONL" {
66
+ local fixture="${REPO_ROOT}/test/fixtures/hook-inputs/post-tool-use-read-chunked.json"
67
+ local history_file="${ONLOOKER_SESSION_HISTORY_DIR}/history-session-001.jsonl"
68
+ rm -f "$history_file" "${history_file}.lock"
69
+
70
+ cat "$fixture" | "${REPO_ROOT}/scripts/hooks/tool-history-tracker.sh" >/dev/null 2>&1
71
+
72
+ tail -n 1 "$history_file" | jq -e '.payload.read_mode == "partial"' >/dev/null
73
+ }
@@ -19,6 +19,7 @@ setup() {
19
19
  '.schema_version == "1.0"
20
20
  and .event_type == "tool.file.read"
21
21
  and .payload.path == "/project/src/main.ts"
22
+ and .payload.read_mode == "full"
22
23
  and .session_id == "history-session-001"' \
23
24
  >/dev/null
24
25
  echo "$record" | onlooker_validate_event