@onlooker-community/ecosystem 0.10.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.claude-plugin/marketplace.json +39 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.github/copilot-instructions.md +46 -0
  4. package/.github/workflows/coverage.yml +78 -0
  5. package/.github/workflows/release.yml +24 -8
  6. package/.github/workflows/test.yml +3 -0
  7. package/.markdownlintignore +3 -0
  8. package/.release-please-manifest.json +5 -1
  9. package/CHANGELOG.md +44 -0
  10. package/README.md +58 -13
  11. package/config.json +6 -1
  12. package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
  13. package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
  14. package/docs/adr/003-ulid-over-uuid.md +40 -0
  15. package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
  16. package/docs/architecture.md +123 -0
  17. package/hooks/hooks.json +4 -0
  18. package/package.json +13 -7
  19. package/plugins/archivist/.claude-plugin/plugin.json +14 -0
  20. package/plugins/archivist/CHANGELOG.md +8 -0
  21. package/plugins/archivist/README.md +105 -0
  22. package/plugins/archivist/config.json +18 -0
  23. package/plugins/archivist/hooks/hooks.json +35 -0
  24. package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
  25. package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
  26. package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
  27. package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
  28. package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
  29. package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
  30. package/plugins/cartographer/.claude-plugin/plugin.json +14 -0
  31. package/plugins/cartographer/CHANGELOG.md +27 -0
  32. package/plugins/cartographer/README.md +113 -0
  33. package/plugins/cartographer/config.json +21 -0
  34. package/plugins/cartographer/docs/adr/001-background-audit-launch.md +28 -0
  35. package/plugins/cartographer/docs/adr/002-flock-pid-file-fallback.md +30 -0
  36. package/plugins/cartographer/docs/adr/003-at-least-once-event-delivery.md +32 -0
  37. package/plugins/cartographer/docs/adr/004-exclude-paths-replace-semantics.md +27 -0
  38. package/plugins/cartographer/hooks/hooks.json +44 -0
  39. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +87 -0
  40. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +89 -0
  41. package/plugins/cartographer/scripts/lib/cartographer-analyze.sh +286 -0
  42. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +59 -0
  43. package/plugins/cartographer/scripts/lib/cartographer-config.sh +105 -0
  44. package/plugins/cartographer/scripts/lib/cartographer-events.sh +82 -0
  45. package/plugins/cartographer/scripts/lib/cartographer-lock.sh +38 -0
  46. package/plugins/cartographer/scripts/lib/cartographer-project-key.sh +55 -0
  47. package/plugins/cartographer/scripts/lib/cartographer-ulid.sh +47 -0
  48. package/plugins/cartographer/scripts/run-audit.sh +309 -0
  49. package/plugins/cartographer/skills/cartographer/SKILL.md +154 -0
  50. package/plugins/echo/.claude-plugin/plugin.json +14 -0
  51. package/plugins/echo/CHANGELOG.md +24 -0
  52. package/plugins/echo/README.md +110 -0
  53. package/plugins/echo/config.json +15 -0
  54. package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
  55. package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
  56. package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
  57. package/plugins/echo/hooks/hooks.json +15 -0
  58. package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
  59. package/plugins/echo/scripts/lib/echo-config.sh +108 -0
  60. package/plugins/echo/scripts/lib/echo-events.sh +74 -0
  61. package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
  62. package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
  63. package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
  64. package/plugins/tribunal/CHANGELOG.md +10 -0
  65. package/plugins/tribunal/README.md +134 -0
  66. package/plugins/tribunal/agents/tribunal-actor.md +35 -0
  67. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
  68. package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
  69. package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
  70. package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
  71. package/plugins/tribunal/config.json +50 -0
  72. package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
  73. package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
  74. package/plugins/tribunal/hooks/hooks.json +15 -0
  75. package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
  76. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
  77. package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
  78. package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
  79. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
  80. package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
  81. package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
  82. package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
  83. package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
  84. package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
  85. package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
  86. package/release-please-config.json +59 -5
  87. package/scripts/coverage/bash-coverage.mjs +169 -0
  88. package/scripts/coverage/format-comment.mjs +120 -0
  89. package/scripts/coverage/run-coverage.mjs +151 -0
  90. package/scripts/hooks/agent-spawn-tracker.sh +4 -4
  91. package/scripts/hooks/prompt-rule-injector.sh +122 -0
  92. package/scripts/lib/portable-lock.sh +48 -0
  93. package/scripts/lib/prompt-rules.sh +207 -0
  94. package/scripts/lib/tool-history.sh +7 -8
  95. package/scripts/lib/validate-path.sh +4 -0
  96. package/scripts/lint/check-manifests.mjs +314 -0
  97. package/scripts/lint/check-references.mjs +311 -0
  98. package/skills/list-prompt-rules/SKILL.md +15 -0
  99. package/test/bats/archivist-config-files.bats +60 -0
  100. package/test/bats/archivist-config.bats +54 -0
  101. package/test/bats/archivist-inject.bats +73 -0
  102. package/test/bats/archivist-project-key.bats +75 -0
  103. package/test/bats/archivist-storage.bats +119 -0
  104. package/test/bats/archivist-ulid.bats +36 -0
  105. package/test/bats/cartographer-config.bats +107 -0
  106. package/test/bats/cartographer-lock.bats +77 -0
  107. package/test/bats/cartographer-ulid.bats +56 -0
  108. package/test/bats/config.bats +10 -10
  109. package/test/bats/echo-config.bats +90 -0
  110. package/test/bats/echo-events.bats +121 -0
  111. package/test/bats/echo-project-key.bats +115 -0
  112. package/test/bats/echo-stop-hook.bats +101 -0
  113. package/test/bats/echo-ulid.bats +38 -0
  114. package/test/bats/portable-lock.bats +62 -0
  115. package/test/bats/prompt-rules.bats +269 -0
  116. package/test/bats/tribunal-aggregate.bats +77 -0
  117. package/test/bats/tribunal-config.bats +86 -0
  118. package/test/bats/tribunal-events.bats +209 -0
  119. package/test/bats/tribunal-gate.bats +95 -0
  120. package/test/bats/tribunal-jury.bats +80 -0
  121. package/test/bats/tribunal-rubric.bats +119 -0
  122. package/test/bats/tribunal-stop-hook.bats +73 -0
  123. package/test/bats/tribunal-verdict.bats +71 -0
  124. package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
  125. package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
  126. package/test/helpers/setup.bash +9 -0
  127. package/test/node/check-manifests.test.mjs +173 -0
  128. package/test/node/check-references.test.mjs +279 -0
  129. package/test/node/coverage.test.mjs +143 -0
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bash
2
+ # Onlooker Prompt Rule Injector
3
+ # Invoked by UserPromptSubmit. Loads declarative prompt rules from
4
+ # ~/.onlooker/prompt-rules.json (global)
5
+ # <cwd>/.claude/prompt-rules.json (project, overrides global by id)
6
+ # and injects guidance for rules whose POSIX-ERE pattern matches the prompt.
7
+ # Each rule fires at most once per session per rule_id.
8
+ #
9
+ # Emits canonical-ish events to ~/.onlooker/logs/onlooker-events.jsonl:
10
+ # prompt_rule.matched — every match (including subsequent matches in-session)
11
+ # prompt_rule.applied — only when guidance is actually injected
12
+ #
13
+ # Usage:
14
+ # echo "$INPUT" | prompt-rule-injector.sh
15
+
16
+ set -uo pipefail # No -e: never block prompt submission
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ # shellcheck source=../lib/validate-path.sh
20
+ source "$SCRIPT_DIR/../lib/validate-path.sh"
21
+ # shellcheck source=../lib/prompt-rules.sh
22
+ source "$SCRIPT_DIR/../lib/prompt-rules.sh"
23
+
24
+ hook_register "prompt-rule-injector" "Prompt Rule Injector" "Injects declarative guidance when regex rules match prompts"
25
+
26
+ INPUT=$(cat)
27
+ hook_set_context "$INPUT" "UserPromptSubmit"
28
+
29
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
30
+ PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
31
+ CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
32
+ [[ -z "$CWD" ]] && CWD="$PWD"
33
+
34
+ turn_state_export "$SESSION_ID"
35
+
36
+ CONFIG_FILE="${CLAUDE_PLUGIN_ROOT:-}/config.json"
37
+ PER_TURN_MAX_CHARS=1200
38
+ ENABLED=true
39
+ if [[ -f "$CONFIG_FILE" ]]; then
40
+ # `// true` would coerce an explicit `false` to true; check the field explicitly.
41
+ ENABLED=$(jq -r 'if (.prompt_rules.enabled == false) then "false" else "true" end' "$CONFIG_FILE" 2>/dev/null) || ENABLED=true
42
+ PER_TURN_MAX_CHARS=$(jq -r '.prompt_rules.per_turn_max_chars // 1200' "$CONFIG_FILE" 2>/dev/null) || PER_TURN_MAX_CHARS=1200
43
+ fi
44
+
45
+ if [[ "$ENABLED" != "true" ]]; then
46
+ hook_success
47
+ exit 0
48
+ fi
49
+
50
+ RULES=$(prompt_rules_load_merged "$CWD")
51
+ RULE_COUNT=$(echo "$RULES" | jq 'length' 2>/dev/null || echo 0)
52
+ if [[ "$RULE_COUNT" -eq 0 ]]; then
53
+ hook_success
54
+ exit 0
55
+ fi
56
+
57
+ FIRED=$(prompt_rules_load_fired "$SESSION_ID")
58
+ COMBINED_GUIDANCE=""
59
+ COMBINED_LEN=0
60
+
61
+ while IFS= read -r rule; do
62
+ [[ -z "$rule" ]] && continue
63
+ RULE_ID=$(echo "$rule" | jq -r '.id // empty')
64
+ PATTERN=$(echo "$rule" | jq -r '.pattern // empty')
65
+ GUIDANCE=$(echo "$rule" | jq -r '.guidance // empty')
66
+ MAX_CHARS=$(echo "$rule" | jq -r '.max_chars // 400')
67
+ # `// true` would coerce an explicit `false` to true (jq's // treats `false`
68
+ # like `null`); check the field explicitly so rules can opt out of fire-once.
69
+ FIRE_ONCE=$(echo "$rule" | jq -r 'if (.fire_once_per_session == false) then "false" else "true" end')
70
+
71
+ [[ -z "$RULE_ID" || -z "$PATTERN" || -z "$GUIDANCE" ]] && continue
72
+
73
+ if ! prompt_rules_pattern_matches "$PROMPT" "$PATTERN"; then
74
+ continue
75
+ fi
76
+
77
+ prompt_rules_emit "$SESSION_ID" "prompt_rule.matched" \
78
+ "$(jq -cn --arg id "$RULE_ID" '{rule_id: $id}')" || true
79
+
80
+ ALREADY_FIRED=$(echo "$FIRED" | jq --arg id "$RULE_ID" 'index($id) != null' 2>/dev/null)
81
+ if [[ "$FIRE_ONCE" == "true" && "$ALREADY_FIRED" == "true" ]]; then
82
+ continue
83
+ fi
84
+
85
+ if (( ${#GUIDANCE} > MAX_CHARS )); then
86
+ GUIDANCE="${GUIDANCE:0:$MAX_CHARS}"
87
+ fi
88
+
89
+ ADD_LEN=${#GUIDANCE}
90
+ # +2 accounts for the blank-line separator between guidance entries.
91
+ if (( COMBINED_LEN + ADD_LEN + 2 > PER_TURN_MAX_CHARS )); then
92
+ continue
93
+ fi
94
+
95
+ if [[ -n "$COMBINED_GUIDANCE" ]]; then
96
+ COMBINED_GUIDANCE="$COMBINED_GUIDANCE"$'\n\n'"$GUIDANCE"
97
+ COMBINED_LEN=$(( COMBINED_LEN + ADD_LEN + 2 ))
98
+ else
99
+ COMBINED_GUIDANCE="$GUIDANCE"
100
+ COMBINED_LEN=$ADD_LEN
101
+ fi
102
+
103
+ prompt_rules_mark_fired "$SESSION_ID" "$RULE_ID" || hook_failure "Failed to mark rule fired: $RULE_ID"
104
+ FIRED=$(prompt_rules_load_fired "$SESSION_ID")
105
+
106
+ prompt_rules_emit "$SESSION_ID" "prompt_rule.applied" \
107
+ "$(jq -cn --arg id "$RULE_ID" --argjson chars "$ADD_LEN" \
108
+ '{rule_id: $id, guidance_chars: $chars}')" || true
109
+ done < <(echo "$RULES" | jq -c '.[]' 2>/dev/null)
110
+
111
+ if [[ -n "$COMBINED_GUIDANCE" ]]; then
112
+ jq -n --arg ctx "$COMBINED_GUIDANCE" \
113
+ '{
114
+ hookSpecificOutput: {
115
+ hookEventName: "UserPromptSubmit",
116
+ additionalContext: $ctx
117
+ }
118
+ }'
119
+ fi
120
+
121
+ hook_success
122
+ exit 0
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # Portable advisory file locking via mkdir() atomicity.
3
+ #
4
+ # Replaces flock(1), which ships with util-linux on Linux but is not present
5
+ # in stock macOS. This matters because the Onlooker hooks run on user
6
+ # machines, not just in CI: a macOS user without util-linux would otherwise
7
+ # see every PostToolUse history append silently fail.
8
+ #
9
+ # mkdir() is atomic on POSIX local filesystems, which is the only place
10
+ # $ONLOOKER_DIR ever lives. Network filesystems (NFS) do not guarantee
11
+ # atomicity, but Claude Code state is local-only.
12
+ #
13
+ # Usage:
14
+ # lock_acquire "/path/to/file.lock" [timeout_seconds=5]
15
+ # # ... critical section ...
16
+ # lock_release "/path/to/file.lock"
17
+ #
18
+ # Avoid associative arrays so bash 3.2 (macOS default) keeps working.
19
+
20
+ # Acquire an exclusive lock at LOCKPATH. Returns 0 on success, 1 on timeout.
21
+ lock_acquire() {
22
+ local lockpath="${1:-}"
23
+ local timeout="${2:-5}"
24
+ [[ -z "$lockpath" ]] && return 1
25
+
26
+ local lockdir="${lockpath}.d"
27
+ local waited=0
28
+ # Poll at 10 Hz so a 5s timeout = 50 attempts.
29
+ local max_iter=$((timeout * 10))
30
+ while ! mkdir "$lockdir" 2>/dev/null; do
31
+ if ((waited >= max_iter)); then
32
+ return 1
33
+ fi
34
+ # `sleep 0.1` works on Linux + macOS; the `|| sleep 1` is a paranoid
35
+ # fallback for embedded shells that only accept integer seconds.
36
+ sleep 0.1 2>/dev/null || sleep 1
37
+ waited=$((waited + 1))
38
+ done
39
+ return 0
40
+ }
41
+
42
+ # Release the lock previously acquired for LOCKPATH. Safe to call when the
43
+ # lock is not held (no-op in that case).
44
+ lock_release() {
45
+ local lockpath="${1:-}"
46
+ [[ -z "$lockpath" ]] && return 0
47
+ rmdir "${lockpath}.d" 2>/dev/null || true
48
+ }
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env bash
2
+ # Prompt-rule library — declarative regex-triggered guidance injection.
3
+ #
4
+ # Source after validate-path.sh:
5
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/validate-path.sh"
6
+ # source "$CLAUDE_PLUGIN_ROOT/scripts/lib/prompt-rules.sh"
7
+ #
8
+ # Rule schema (JSON file at ~/.onlooker/prompt-rules.json or <cwd>/.claude/prompt-rules.json):
9
+ # {
10
+ # "rules": [
11
+ # {
12
+ # "id": "rule-no-verify-warning",
13
+ # "pattern": "--no-verify",
14
+ # "guidance": "Skipping hooks usually masks the real issue.",
15
+ # "fire_once_per_session": true,
16
+ # "max_chars": 400,
17
+ # "enabled": true,
18
+ # "tags": ["safety"]
19
+ # }
20
+ # ]
21
+ # }
22
+ #
23
+ # Patterns are POSIX ERE (bash [[ =~ ]] semantics). `\b` is unsupported;
24
+ # use `(^|[^a-zA-Z0-9_])foo([^a-zA-Z0-9_]|$)` for word-boundary behavior.
25
+
26
+ export ONLOOKER_PROMPT_RULES_DIR="${ONLOOKER_PROMPT_RULES_DIR:-$ONLOOKER_DIR/prompt-rules}"
27
+ export ONLOOKER_PROMPT_RULES_SESSIONS_DIR="$ONLOOKER_PROMPT_RULES_DIR/sessions"
28
+
29
+ # Path to the global rules file.
30
+ # Usage: path=$(prompt_rules_global_path)
31
+ prompt_rules_global_path() {
32
+ printf '%s\n' "$ONLOOKER_DIR/prompt-rules.json"
33
+ }
34
+
35
+ # Path to the project-scoped rules file for a given cwd.
36
+ # Usage: path=$(prompt_rules_project_path "$cwd")
37
+ prompt_rules_project_path() {
38
+ local cwd="${1:-$PWD}"
39
+ printf '%s\n' "$cwd/.claude/prompt-rules.json"
40
+ }
41
+
42
+ # Path to the fired-marker file for a session.
43
+ # Usage: path=$(prompt_rules_fired_path "$session_id")
44
+ prompt_rules_fired_path() {
45
+ local session_id="${1:-unknown}"
46
+ printf '%s\n' "$ONLOOKER_PROMPT_RULES_SESSIONS_DIR/$session_id.json"
47
+ }
48
+
49
+ # Print the merged rules JSON array. Project entries override global by id.
50
+ # Disabled rules (enabled: false) are filtered out.
51
+ # Usage: rules=$(prompt_rules_load_merged "$cwd")
52
+ prompt_rules_load_merged() {
53
+ local cwd="${1:-$PWD}"
54
+ local global_path project_path
55
+ global_path=$(prompt_rules_global_path)
56
+ project_path=$(prompt_rules_project_path "$cwd")
57
+
58
+ local global_json='[]'
59
+ local project_json='[]'
60
+ if [[ -f "$global_path" ]]; then
61
+ global_json=$(jq -c '.rules // []' "$global_path" 2>/dev/null) || global_json='[]'
62
+ fi
63
+ if [[ -f "$project_path" ]]; then
64
+ project_json=$(jq -c '.rules // []' "$project_path" 2>/dev/null) || project_json='[]'
65
+ fi
66
+
67
+ jq -n \
68
+ --argjson g "$global_json" \
69
+ --argjson p "$project_json" \
70
+ '
71
+ # Coerce non-array inputs to []; drop entries without a string id so a
72
+ # single malformed rule cannot poison `map({(.id): .})` (which errors when
73
+ # `.id` is null or non-string).
74
+ def to_array(x): if (x | type) == "array" then x else [] end;
75
+ def sanitize(arr): to_array(arr) | map(select(type == "object" and (.id | type) == "string" and .id != ""));
76
+ def to_map(arr): (sanitize(arr) | map({(.id): .}) | add) // {};
77
+ (to_map($g) + to_map($p))
78
+ | to_entries
79
+ | map(.value)
80
+ | map(select(.enabled != false))
81
+ '
82
+ }
83
+
84
+ # Print the fired-id JSON array for a session.
85
+ # Usage: fired=$(prompt_rules_load_fired "$session_id")
86
+ prompt_rules_load_fired() {
87
+ local session_id="${1:-unknown}"
88
+ local path
89
+ path=$(prompt_rules_fired_path "$session_id")
90
+ if [[ -f "$path" ]]; then
91
+ jq -c '.fired_ids // []' "$path" 2>/dev/null || echo '[]'
92
+ else
93
+ echo '[]'
94
+ fi
95
+ }
96
+
97
+ # Mark a rule id as fired for a session. Idempotent.
98
+ # Read-modify-write is wrapped in a portable file lock so concurrent
99
+ # UserPromptSubmit hooks (or other writers) can't drop updates or corrupt
100
+ # the marker file — same pattern as tool-history.sh.
101
+ # Usage: prompt_rules_mark_fired "$session_id" "$rule_id"
102
+ prompt_rules_mark_fired() {
103
+ local session_id="${1:-unknown}"
104
+ local rule_id="${2:-}"
105
+ [[ -z "$rule_id" ]] && return 1
106
+ local path
107
+ path=$(prompt_rules_fired_path "$session_id")
108
+ ensure_dir_exists "$(dirname "$path")" || return 1
109
+
110
+ local lockfile="${path}.lock"
111
+ lock_acquire "$lockfile" 5 || return 1
112
+
113
+ local current='[]'
114
+ if [[ -f "$path" ]]; then
115
+ current=$(jq -c '.fired_ids // []' "$path" 2>/dev/null) || current='[]'
116
+ fi
117
+ local next rc=0
118
+ next=$(jq -cn --argjson cur "$current" --arg id "$rule_id" \
119
+ '{fired_ids: ($cur + [$id] | unique)}')
120
+ printf '%s\n' "$next" > "$path" || rc=$?
121
+ lock_release "$lockfile"
122
+ return "$rc"
123
+ }
124
+
125
+ # Test whether a POSIX ERE pattern matches the given prompt.
126
+ # Returns 0 on match, 1 otherwise (including empty or invalid pattern).
127
+ # Invalid ERE patterns from user-edited rule files would otherwise leak a
128
+ # "syntax error in regular expression" message to stderr and return status 2;
129
+ # we treat that as a non-match so the hook stays quiet on bad input.
130
+ # Usage: prompt_rules_pattern_matches "$prompt" "$pattern" && echo "hit"
131
+ prompt_rules_pattern_matches() {
132
+ local prompt="$1"
133
+ local pattern="$2"
134
+ [[ -z "$pattern" ]] && return 1
135
+ { [[ "$prompt" =~ $pattern ]]; } 2>/dev/null
136
+ local rc=$?
137
+ # Bash returns 2 for a malformed regex; collapse to "no match".
138
+ if (( rc == 0 )); then
139
+ return 0
140
+ fi
141
+ return 1
142
+ }
143
+
144
+ # Append a prompt-rule event to the global events log.
145
+ # These event types (prompt_rule.matched, prompt_rule.applied) are not yet
146
+ # in @onlooker-community/schema; once added, swap to onlooker_append_event.
147
+ # Usage: prompt_rules_emit "$session_id" "prompt_rule.matched" "$payload_json"
148
+ prompt_rules_emit() {
149
+ local session_id="${1:-unknown}"
150
+ local event_type="${2:-}"
151
+ local payload_json="${3:-{\}}"
152
+ [[ -z "$event_type" ]] && return 1
153
+ ensure_file_exists "$ONLOOKER_EVENTS_LOG" || return 1
154
+
155
+ local timestamp plugin
156
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
157
+ plugin="${ONLOOKER_PLUGIN_NAME:-onlooker}"
158
+
159
+ jq -cn \
160
+ --arg ts "$timestamp" \
161
+ --arg sid "$session_id" \
162
+ --arg plugin "$plugin" \
163
+ --arg type "$event_type" \
164
+ --argjson payload "$payload_json" \
165
+ --arg turn "${ONLOOKER_TURN_NUMBER:-}" \
166
+ '{timestamp: $ts, session_id: $sid, plugin: $plugin, event_type: $type, payload: $payload}
167
+ + (if $turn != "" then {turn: ($turn | tonumber)} else {} end)
168
+ ' >> "$ONLOOKER_EVENTS_LOG"
169
+ }
170
+
171
+ # Print a human-readable table of merged rules with their fired status.
172
+ # Usage: prompt_rules_list_table "$session_id" "$cwd"
173
+ prompt_rules_list_table() {
174
+ local session_id="${1:-unknown}"
175
+ local cwd="${2:-$PWD}"
176
+
177
+ local rules fired global_path project_path
178
+ rules=$(prompt_rules_load_merged "$cwd")
179
+ fired=$(prompt_rules_load_fired "$session_id")
180
+ global_path=$(prompt_rules_global_path)
181
+ project_path=$(prompt_rules_project_path "$cwd")
182
+
183
+ local rule_count
184
+ rule_count=$(echo "$rules" | jq 'length' 2>/dev/null || echo 0)
185
+
186
+ printf 'Prompt rules (session: %s)\n' "$session_id"
187
+ printf ' global file: %s%s\n' "$global_path" \
188
+ "$([[ -f "$global_path" ]] && printf '' || printf ' (missing)')"
189
+ printf ' project file: %s%s\n' "$project_path" \
190
+ "$([[ -f "$project_path" ]] && printf '' || printf ' (missing)')"
191
+ printf ' active rules: %s\n' "$rule_count"
192
+ printf '\n'
193
+
194
+ if [[ "$rule_count" -eq 0 ]]; then
195
+ printf ' (no rules)\n'
196
+ return 0
197
+ fi
198
+
199
+ echo "$rules" | jq -r --argjson fired "$fired" '
200
+ .[]
201
+ | . as $rule
202
+ | " - id: \(.id)\n"
203
+ + " pattern: \(.pattern)\n"
204
+ + " fired: \(if ($fired | any(. == $rule.id)) then "yes" else "no" end)\n"
205
+ + " guidance: \(.guidance)\n"
206
+ '
207
+ }
@@ -13,8 +13,9 @@ tool_history_build_record() {
13
13
  onlooker_event_from_hook "$input_json"
14
14
  }
15
15
 
16
- # Append a canonical event to the session JSONL history (flock-protected).
17
- # Usage: tool_history_append "$SESSION_ID" "$event_json"
16
+ # Append a canonical event to the session JSONL history (lock-protected).
17
+ # Uses the portable mkdir-based mutex so the hook works on macOS as well as
18
+ # Linux. Usage: tool_history_append "$SESSION_ID" "$event_json"
18
19
  tool_history_append() {
19
20
  local session_id="${1:-}"
20
21
  local record_json="${2:-}"
@@ -25,11 +26,9 @@ tool_history_append() {
25
26
  ensure_dir_exists "$ONLOOKER_SESSION_HISTORY_DIR" || return 1
26
27
 
27
28
  local lockfile="${history_file}.lock"
28
- exec 202>"$lockfile"
29
- if ! flock -w 5 202; then
30
- return 1
31
- fi
32
-
29
+ lock_acquire "$lockfile" 5 || return 1
33
30
  printf '%s\n' "$record_json" >>"$history_file" 2>/dev/null
34
- flock -u 202
31
+ local rc=$?
32
+ lock_release "$lockfile"
33
+ return "$rc"
35
34
  }
@@ -22,6 +22,10 @@ export ONLOOKER_EVENTS_LOG="$ONLOOKER_DIR/logs/onlooker-events.jsonl"
22
22
  export ONLOOKER_HOOK_HEALTH_LOG="$ONLOOKER_DIR/logs/hook-health.jsonl"
23
23
  _VALIDATE_PATH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
24
  export ONLOOKER_EMIT="$_VALIDATE_PATH_DIR/onlooker-emit.sh"
25
+ # Portable mutex (flock substitute) — every hook script that needs to write
26
+ # shared state can call lock_acquire/lock_release after sourcing this file.
27
+ # shellcheck source=portable-lock.sh
28
+ source "$_VALIDATE_PATH_DIR/portable-lock.sh"
25
29
  unset _VALIDATE_PATH_DIR
26
30
 
27
31
  # ==============================================================================