@onlooker-community/ecosystem 0.10.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.
- package/.claude-plugin/marketplace.json +39 -1
- package/.claude-plugin/plugin.json +2 -2
- package/.github/copilot-instructions.md +46 -0
- package/.github/workflows/coverage.yml +78 -0
- package/.github/workflows/release.yml +24 -8
- package/.github/workflows/test.yml +3 -0
- package/.markdownlintignore +3 -0
- package/.release-please-manifest.json +4 -1
- package/CHANGELOG.md +37 -0
- package/README.md +57 -13
- package/config.json +6 -1
- package/docs/adr/001-claude-code-hooks-as-integration-surface.md +43 -0
- package/docs/adr/002-centralized-jsonl-event-log.md +39 -0
- package/docs/adr/003-ulid-over-uuid.md +40 -0
- package/docs/adr/004-plugin-config-with-settings-overlay.md +34 -0
- package/docs/architecture.md +117 -0
- package/hooks/hooks.json +4 -0
- package/package.json +13 -7
- package/plugins/archivist/.claude-plugin/plugin.json +14 -0
- package/plugins/archivist/CHANGELOG.md +8 -0
- package/plugins/archivist/README.md +105 -0
- package/plugins/archivist/config.json +18 -0
- package/plugins/archivist/hooks/hooks.json +35 -0
- package/plugins/archivist/scripts/hooks/archivist-extract.sh +238 -0
- package/plugins/archivist/scripts/hooks/archivist-inject.sh +159 -0
- package/plugins/archivist/scripts/lib/archivist-config.sh +66 -0
- package/plugins/archivist/scripts/lib/archivist-project-key.sh +91 -0
- package/plugins/archivist/scripts/lib/archivist-storage.sh +215 -0
- package/plugins/archivist/scripts/lib/archivist-ulid.sh +52 -0
- package/plugins/echo/.claude-plugin/plugin.json +14 -0
- package/plugins/echo/CHANGELOG.md +24 -0
- package/plugins/echo/README.md +110 -0
- package/plugins/echo/config.json +15 -0
- package/plugins/echo/docs/adr/001-echo-as-separate-plugin.md +33 -0
- package/plugins/echo/docs/adr/002-direct-evaluation-vs-tribunal-pipeline.md +35 -0
- package/plugins/echo/docs/adr/003-stop-hook-trigger.md +40 -0
- package/plugins/echo/hooks/hooks.json +15 -0
- package/plugins/echo/scripts/hooks/echo-stop-gate.sh +366 -0
- package/plugins/echo/scripts/lib/echo-config.sh +108 -0
- package/plugins/echo/scripts/lib/echo-events.sh +74 -0
- package/plugins/echo/scripts/lib/echo-project-key.sh +81 -0
- package/plugins/echo/scripts/lib/echo-ulid.sh +46 -0
- package/plugins/tribunal/.claude-plugin/plugin.json +20 -0
- package/plugins/tribunal/CHANGELOG.md +10 -0
- package/plugins/tribunal/README.md +134 -0
- package/plugins/tribunal/agents/tribunal-actor.md +35 -0
- package/plugins/tribunal/agents/tribunal-judge-adversarial.md +51 -0
- package/plugins/tribunal/agents/tribunal-judge-security.md +47 -0
- package/plugins/tribunal/agents/tribunal-judge-standard.md +47 -0
- package/plugins/tribunal/agents/tribunal-meta-judge.md +61 -0
- package/plugins/tribunal/config.json +50 -0
- package/plugins/tribunal/docs/adr/001-actor-jury-meta-gate-loop.md +40 -0
- package/plugins/tribunal/docs/adr/002-majority-gate-policy.md +48 -0
- package/plugins/tribunal/hooks/hooks.json +15 -0
- package/plugins/tribunal/scripts/hooks/tribunal-stop-gate.sh +267 -0
- package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +65 -0
- package/plugins/tribunal/scripts/lib/tribunal-config.sh +101 -0
- package/plugins/tribunal/scripts/lib/tribunal-events.sh +97 -0
- package/plugins/tribunal/scripts/lib/tribunal-gate.sh +111 -0
- package/plugins/tribunal/scripts/lib/tribunal-jury.sh +102 -0
- package/plugins/tribunal/scripts/lib/tribunal-project-key.sh +84 -0
- package/plugins/tribunal/scripts/lib/tribunal-rubric.sh +153 -0
- package/plugins/tribunal/scripts/lib/tribunal-ulid.sh +50 -0
- package/plugins/tribunal/scripts/lib/tribunal-verdict.sh +127 -0
- package/plugins/tribunal/skills/tribunal/SKILL.md +129 -0
- package/release-please-config.json +43 -5
- package/scripts/coverage/bash-coverage.mjs +169 -0
- package/scripts/coverage/format-comment.mjs +120 -0
- package/scripts/coverage/run-coverage.mjs +151 -0
- package/scripts/hooks/agent-spawn-tracker.sh +4 -4
- package/scripts/hooks/prompt-rule-injector.sh +122 -0
- package/scripts/lib/portable-lock.sh +48 -0
- package/scripts/lib/prompt-rules.sh +207 -0
- package/scripts/lib/tool-history.sh +7 -8
- package/scripts/lib/validate-path.sh +4 -0
- package/scripts/lint/check-manifests.mjs +314 -0
- package/scripts/lint/check-references.mjs +311 -0
- package/skills/list-prompt-rules/SKILL.md +15 -0
- package/test/bats/archivist-config-files.bats +60 -0
- package/test/bats/archivist-config.bats +54 -0
- package/test/bats/archivist-inject.bats +73 -0
- package/test/bats/archivist-project-key.bats +75 -0
- package/test/bats/archivist-storage.bats +119 -0
- package/test/bats/archivist-ulid.bats +36 -0
- package/test/bats/config.bats +10 -10
- package/test/bats/echo-config.bats +90 -0
- package/test/bats/echo-events.bats +121 -0
- package/test/bats/echo-project-key.bats +115 -0
- package/test/bats/echo-stop-hook.bats +101 -0
- package/test/bats/echo-ulid.bats +38 -0
- package/test/bats/portable-lock.bats +62 -0
- package/test/bats/prompt-rules.bats +269 -0
- package/test/bats/tribunal-aggregate.bats +77 -0
- package/test/bats/tribunal-config.bats +86 -0
- package/test/bats/tribunal-events.bats +209 -0
- package/test/bats/tribunal-gate.bats +95 -0
- package/test/bats/tribunal-jury.bats +80 -0
- package/test/bats/tribunal-rubric.bats +119 -0
- package/test/bats/tribunal-stop-hook.bats +73 -0
- package/test/bats/tribunal-verdict.bats +71 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-match.json +8 -0
- package/test/fixtures/hook-inputs/user-prompt-submit-rule-nomatch.json +8 -0
- package/test/helpers/setup.bash +9 -0
- package/test/node/check-manifests.test.mjs +173 -0
- package/test/node/check-references.test.mjs +279 -0
- package/test/node/coverage.test.mjs +143 -0
|
@@ -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
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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 (
|
|
17
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
# ==============================================================================
|