@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,151 @@
1
+ #!/usr/bin/env node
2
+ // Run the .mjs test suite with node's built-in --experimental-test-coverage,
3
+ // parse the emitted table into structured JSON, and either pretty-print it
4
+ // or hand it off as JSON for downstream tools (format-comment.mjs).
5
+ //
6
+ // The output table is fixed-format text; we parse it line-by-line rather
7
+ // than depending on V8 coverage dumps so we don't need to handle binary
8
+ // formats across node versions.
9
+ //
10
+ // Flags:
11
+ // --json emit structured JSON
12
+ // --root override repo root
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+ import { statSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ function findRepoRoot(start) {
20
+ let cur = resolve(start);
21
+ while (cur !== '/') {
22
+ try {
23
+ statSync(join(cur, '.claude-plugin', 'marketplace.json'));
24
+ return cur;
25
+ } catch {}
26
+ cur = dirname(cur);
27
+ }
28
+ throw new Error(`no repo root above ${start}`);
29
+ }
30
+
31
+ function parseArgs(argv) {
32
+ const out = { json: false, root: null };
33
+ for (let i = 2; i < argv.length; i++) {
34
+ if (argv[i] === '--json') out.json = true;
35
+ else if (argv[i] === '--root') out.root = argv[++i];
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Parse the human-readable coverage report node prints after a --test run.
41
+ // Layout:
42
+ // ℹ start of coverage report
43
+ // ℹ ---------- (separator)
44
+ // ℹ file | line % | branch % | funcs % | uncovered lines
45
+ // ℹ ---------- (separator)
46
+ // ℹ <directory>
47
+ // ℹ <subdir>
48
+ // ℹ <file.mjs> | 74.20 | 58.27 | 85.00 | 130-131 170 ...
49
+ // ℹ ---------- (separator)
50
+ // ℹ all files | 78.55 | 57.38 | 87.18 |
51
+ // ℹ ---------- (separator)
52
+ // ℹ end of coverage report
53
+ function parseCoverageOutput(text) {
54
+ const lines = text.split(/\r?\n/);
55
+ const files = [];
56
+ let overall = null;
57
+ let inReport = false;
58
+
59
+ for (const rawLine of lines) {
60
+ const line = rawLine.replace(/^ℹ\s?/, '').replace(/^[\sℹ]+/, '');
61
+ if (line.startsWith('start of coverage report')) {
62
+ inReport = true;
63
+ continue;
64
+ }
65
+ if (line.startsWith('end of coverage report')) {
66
+ inReport = false;
67
+ continue;
68
+ }
69
+ if (!inReport) continue;
70
+
71
+ // Skip separators and the header row.
72
+ if (line.startsWith('-')) continue;
73
+ if (line.includes('line %') && line.includes('branch %')) continue;
74
+
75
+ const cells = line.split('|').map((c) => c.trim());
76
+ // A real data row has 5 columns: file, line%, branch%, funcs%, uncovered.
77
+ if (cells.length < 5) continue;
78
+
79
+ const [file, linePct, branchPct, funcsPct, uncovered] = cells;
80
+ if (!file) continue;
81
+ // Directory rows have all-blank metric columns — skip them so we only
82
+ // surface per-file numbers + the all-files total.
83
+ if (!linePct || !branchPct || !funcsPct) continue;
84
+
85
+ const num = (s) => Number.parseFloat(s);
86
+ const entry = {
87
+ file,
88
+ line: num(linePct),
89
+ branch: num(branchPct),
90
+ funcs: num(funcsPct),
91
+ uncoveredLines: uncovered || '',
92
+ };
93
+ if (file === 'all files') {
94
+ overall = entry;
95
+ } else {
96
+ files.push(entry);
97
+ }
98
+ }
99
+
100
+ return { files, overall };
101
+ }
102
+
103
+ function main() {
104
+ const args = parseArgs(process.argv);
105
+ const here = dirname(fileURLToPath(import.meta.url));
106
+ const root = args.root ? resolve(args.root) : findRepoRoot(here);
107
+
108
+ const testGlob = ['test/node'].map((d) => join(root, d, '*.test.mjs'));
109
+ const r = spawnSync(
110
+ 'node',
111
+ [
112
+ '--experimental-test-coverage',
113
+ '--test-coverage-include=scripts/**/*.mjs',
114
+ '--test-coverage-exclude=test/**',
115
+ '--test',
116
+ ...testGlob,
117
+ ],
118
+ { encoding: 'utf8', cwd: root },
119
+ );
120
+
121
+ if (r.status !== 0) {
122
+ process.stderr.write(`tests failed (exit ${r.status})\n`);
123
+ process.stderr.write(r.stdout);
124
+ process.stderr.write(r.stderr);
125
+ process.exit(r.status ?? 1);
126
+ }
127
+
128
+ const report = parseCoverageOutput(r.stdout);
129
+
130
+ if (args.json) {
131
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
132
+ return;
133
+ }
134
+
135
+ if (!report.overall) {
136
+ process.stderr.write('could not parse coverage output\n');
137
+ process.stderr.write(r.stdout);
138
+ process.exit(1);
139
+ }
140
+
141
+ process.stdout.write(
142
+ `node coverage: line ${report.overall.line.toFixed(1)}% branch ${report.overall.branch.toFixed(1)}% funcs ${report.overall.funcs.toFixed(1)}%\n\n`,
143
+ );
144
+ for (const f of report.files) {
145
+ process.stdout.write(
146
+ ` line ${f.line.toFixed(0).padStart(3)}% branch ${f.branch.toFixed(0).padStart(3)}% ${f.file}\n`,
147
+ );
148
+ }
149
+ }
150
+
151
+ main();
@@ -64,9 +64,9 @@ BACKGROUND=$(jq -r '.tool_input.background // false' <<<"$INPUT")
64
64
  STATE_FILE="$ONLOOKER_DIR/agent-spawn-trackers.json"
65
65
  LOCKFILE="$STATE_FILE.lock"
66
66
 
67
- # Use flock for exclusive access
68
- exec 200>"$LOCKFILE"
69
- flock -w 5 200 || {
67
+ # Acquire exclusive access via the portable lock helper (mkdir-based mutex,
68
+ # works on macOS without util-linux).
69
+ lock_acquire "$LOCKFILE" 5 || {
70
70
  json_response "deny" "Failed to acquire lock"
71
71
  hook_failure
72
72
  exit 0
@@ -103,7 +103,7 @@ STATE=$(jq --arg sid "$SESSION_ID" '
103
103
  echo "$STATE" > "$STATE_FILE" 2>/dev/null || true
104
104
 
105
105
  # Release lock
106
- flock -u 200
106
+ lock_release "$LOCKFILE"
107
107
 
108
108
  # Get current session stats
109
109
  SPAWN_COUNT=$(jq -r --arg sid "$SESSION_ID" '.sessions[$sid].spawns' <<<"$STATE")
@@ -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
@@ -4,7 +4,7 @@
4
4
  * Uses @onlooker-community/schema for envelope shape and validation.
5
5
  */
6
6
  import { randomUUID } from 'node:crypto';
7
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import {
10
10
  createEvent,
@@ -81,6 +81,85 @@ function extractPath(toolInput, toolResponse) {
81
81
  return toolInput?.file_path ?? toolInput?.path ?? toolResponse?.filePath ?? toolResponse?.path ?? undefined;
82
82
  }
83
83
 
84
+ /** Bytes on disk above which a full read is flagged as large_file_full_read. */
85
+ export const LARGE_FILE_BYTES_ON_DISK = 100_000;
86
+
87
+ const MAX_FILE_LINES_STAT_BYTES = 512 * 1024;
88
+
89
+ function parseNonNegativeInt(value) {
90
+ if (value == null || value === '') return undefined;
91
+ const n = Number.parseInt(String(value), 10);
92
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
93
+ }
94
+
95
+ function parsePositiveInt(value, min = 1) {
96
+ if (value == null || value === '') return undefined;
97
+ const n = Number.parseInt(String(value), 10);
98
+ return Number.isFinite(n) && n >= min ? n : undefined;
99
+ }
100
+
101
+ /**
102
+ * Derive read_mode and line range from Read tool_input (supports common field aliases).
103
+ */
104
+ export function extractReadRange(toolInput) {
105
+ const input = toolInput ?? {};
106
+ const offset = parseNonNegativeInt(
107
+ input.offset ?? input.start_line ?? input.start_line_one_indexed ?? input.line_offset,
108
+ );
109
+ const limit = parsePositiveInt(input.limit ?? input.line_limit ?? input.num_lines ?? input.line_count);
110
+ const read_mode = offset != null || limit != null ? 'partial' : 'full';
111
+ return stripUndefined({ read_mode, offset, limit });
112
+ }
113
+
114
+ /**
115
+ * Stat file on disk for chunking analytics (line count omitted for very large files).
116
+ */
117
+ export function measureFileOnDisk(filePath) {
118
+ try {
119
+ if (!filePath || !existsSync(filePath)) return {};
120
+ const st = statSync(filePath);
121
+ if (!st.isFile()) return {};
122
+ const file_bytes_on_disk = st.size;
123
+ let file_lines_on_disk;
124
+ if (st.size <= MAX_FILE_LINES_STAT_BYTES) {
125
+ const text = readFileSync(filePath, 'utf8');
126
+ file_lines_on_disk = text.split('\n').length;
127
+ }
128
+ return stripUndefined({ file_bytes_on_disk, file_lines_on_disk });
129
+ } catch {
130
+ return {};
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Build tool.file.read payload from Read tool hook fields.
136
+ */
137
+ export function buildToolFileReadPayload(toolInput, toolResponse, options = {}) {
138
+ const path = extractPath(toolInput, toolResponse);
139
+ if (!path) return null;
140
+
141
+ const payload = { path, ...extractReadRange(toolInput) };
142
+ const content = toolResponse?.content;
143
+ if (typeof content === 'string') {
144
+ payload.lines_read = content.split('\n').length;
145
+ payload.file_size_bytes = content.length;
146
+ }
147
+
148
+ if (options.measureOnDisk !== false) {
149
+ Object.assign(payload, measureFileOnDisk(path));
150
+ }
151
+
152
+ if (
153
+ payload.read_mode === 'full' &&
154
+ payload.file_bytes_on_disk != null &&
155
+ payload.file_bytes_on_disk >= LARGE_FILE_BYTES_ON_DISK
156
+ ) {
157
+ payload.large_file_full_read = true;
158
+ }
159
+
160
+ return stripUndefined(payload);
161
+ }
162
+
84
163
  function stripUndefined(obj) {
85
164
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
86
165
  }
@@ -279,16 +358,9 @@ export function mapHookInputToCanonical(hookInput, options) {
279
358
 
280
359
  switch (toolName) {
281
360
  case 'Read': {
282
- const path = extractPath(toolInput, toolResponse);
283
- if (!path) return null;
361
+ payload = buildToolFileReadPayload(toolInput, toolResponse);
362
+ if (!payload) return null;
284
363
  eventType = TOOL_FILE_READ;
285
- payload = { path };
286
- const content = toolResponse?.content;
287
- if (typeof content === 'string') {
288
- const lines = content.split('\n').length;
289
- payload.lines_read = lines;
290
- payload.file_size_bytes = content.length;
291
- }
292
364
  break;
293
365
  }
294
366
  case 'Write': {
@@ -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
  }