@onlooker-community/ecosystem 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.release-please-manifest.json +2 -2
- package/CHANGELOG.md +7 -0
- package/CLAUDE.md +2 -0
- package/docs/plugin-catalog.md +125 -0
- package/package.json +3 -3
- package/plugins/compass/.claude-plugin/plugin.json +1 -1
- package/plugins/compass/CHANGELOG.md +7 -0
- package/plugins/compass/README.md +1 -3
- package/plugins/compass/config.json +1 -2
- package/plugins/compass/docs/design.md +1 -2
- package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
- package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +8 -1
- package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
- package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
- package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
- package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
- package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
- package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
- package/plugins/inspector/.claude-plugin/plugin.json +14 -0
- package/plugins/inspector/README.md +155 -0
- package/plugins/inspector/config.json +25 -0
- package/plugins/inspector/docs/design.md +286 -0
- package/plugins/inspector/hooks/hooks.json +33 -0
- package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
- package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
- package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
- package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
- package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
- package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
- package/test/bats/archivist-project-key.bats +79 -0
- package/test/bats/archivist-storage.bats +79 -0
- package/test/bats/compact-tracker.bats +125 -0
- package/test/bats/compass-config.bats +65 -0
- package/test/bats/compass-gate.bats +129 -0
- package/test/bats/compass-sanitizer.bats +69 -0
- package/test/bats/compass-symbolic-skip.bats +88 -0
- package/test/bats/compass-transcript.bats +80 -0
- package/test/bats/inspector-config.bats +118 -0
- package/test/bats/inspector-events.bats +156 -0
- package/test/bats/inspector-post-write-hook.bats +164 -0
- package/test/bats/inspector-project-key.bats +68 -0
- package/test/bats/inspector-ulid.bats +34 -0
- package/test/bats/onlooker-schema.bats +111 -0
- package/test/bats/prompt-rules.bats +98 -0
- package/test/bats/session-tracker.bats +260 -0
- package/test/bats/skill-usage-tracker.bats +63 -0
- package/test/bats/task-tracker.bats +102 -0
- package/test/bats/turn-tracker.bats +180 -0
- package/test/bats/validate-path.bats +125 -0
- package/test/bats/worktree-tracker.bats +167 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Inspector — Design
|
|
2
|
+
|
|
3
|
+
**Layer:** verification / execution
|
|
4
|
+
**Hook surface:** `PostToolUse` for `Write`, `Edit`, `MultiEdit`
|
|
5
|
+
**Status:** initial design — first implementation under this doc
|
|
6
|
+
|
|
7
|
+
Inspector is the per-edit lint and typecheck gate. Every time the agent writes
|
|
8
|
+
to a file, inspector runs the project's configured checks on **just that file**
|
|
9
|
+
and surfaces the result back to the agent before its next turn. The agent sees
|
|
10
|
+
its own type errors immediately and self-corrects, instead of claiming success
|
|
11
|
+
on broken code and getting caught later by [assayer] or a human reviewer.
|
|
12
|
+
|
|
13
|
+
## What inspector is — and isn't
|
|
14
|
+
|
|
15
|
+
- **Inspector is not proctor.** Proctor (planned) runs the project's full
|
|
16
|
+
verification command (`npm test`, `mise run check`, …) at `Stop`. Inspector
|
|
17
|
+
runs only on touched files, only on `PostToolUse`. Cheaper, fires far more
|
|
18
|
+
often, narrower scope.
|
|
19
|
+
- **Inspector is not assayer.** Assayer (shipped) parses the agent's final
|
|
20
|
+
message for testable claims ("the build passes", "I ran the tests") and
|
|
21
|
+
cross-checks them against actual exit codes in the transcript. Assayer catches
|
|
22
|
+
the agent *lying* about claims. Inspector ensures the agent has *accurate
|
|
23
|
+
ground truth* to make claims from. They compose: inspector emits real
|
|
24
|
+
pass/fail signals; assayer can later confirm the agent's claims line up with
|
|
25
|
+
those signals.
|
|
26
|
+
- **Inspector is not a build system.** It does not chain dependencies, cache
|
|
27
|
+
intermediate results, or share state across invocations beyond timeouts. It
|
|
28
|
+
runs the configured check, captures the result, emits an event, exits. If
|
|
29
|
+
configuration says "run tsc on a single file," it does that — even though
|
|
30
|
+
tsc's per-file mode loses project context. Choosing the right command for a
|
|
31
|
+
given language is the user's job; inspector executes what it is told.
|
|
32
|
+
|
|
33
|
+
## Hook flow
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
PostToolUse(Write|Edit|MultiEdit)
|
|
37
|
+
→ inspector-post-write.sh
|
|
38
|
+
→ resolve touched file from tool_input.file_path
|
|
39
|
+
→ load merged config (plugin defaults < home < repo)
|
|
40
|
+
→ if disabled OR excluded path OR no extension match → emit .skipped, exit 0
|
|
41
|
+
→ for each configured check matching the extension:
|
|
42
|
+
→ expand ${file}, ${file_relative}, ${repo_root} in the command
|
|
43
|
+
→ run with per-check timeout
|
|
44
|
+
→ emit inspector.check.passed / .failed / .skipped
|
|
45
|
+
→ exit 0 (never blocks the tool call)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The hook always exits 0. Inspector is advisory — it never blocks the agent's
|
|
49
|
+
write. It surfaces what it found in the additional-context channel of the
|
|
50
|
+
PostToolUse hook reply so the agent sees the result on its next turn.
|
|
51
|
+
|
|
52
|
+
## Configuration
|
|
53
|
+
|
|
54
|
+
The minimum useful configuration is a map from file extension to a list of
|
|
55
|
+
commands to run. Each command is an argv array; `${file}` substitutes the
|
|
56
|
+
canonical absolute path to the touched file.
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
"inspector": {
|
|
61
|
+
"enabled": false,
|
|
62
|
+
"timeout_seconds_per_check": 10,
|
|
63
|
+
"total_timeout_seconds": 30,
|
|
64
|
+
"exclude_paths": ["node_modules", ".git", "vendor", "dist", "build",
|
|
65
|
+
".next", "__pycache__", ".venv"],
|
|
66
|
+
"checks": {
|
|
67
|
+
".ts": [{ "name": "biome", "argv": ["biome", "check", "${file}"] },
|
|
68
|
+
{ "name": "tsc", "argv": ["tsc", "--noEmit"] }],
|
|
69
|
+
".tsx": [{ "name": "biome", "argv": ["biome", "check", "${file}"] },
|
|
70
|
+
{ "name": "tsc", "argv": ["tsc", "--noEmit"] }],
|
|
71
|
+
".py": [{ "name": "ruff", "argv": ["ruff", "check", "${file}"] }],
|
|
72
|
+
".sh": [{ "name": "shellcheck", "argv": ["shellcheck", "${file}"] }]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Merging follows the standard ecosystem precedence (plugin defaults < home <
|
|
79
|
+
repo). Each layer can fully replace `checks` for a given extension by setting
|
|
80
|
+
the array; deep-merge of individual entries within an extension is intentionally
|
|
81
|
+
not supported — it makes the override behavior unpredictable.
|
|
82
|
+
|
|
83
|
+
`config.json` ships with `enabled: false` to match the rest of the ecosystem.
|
|
84
|
+
The first PR that ships inspector also adds an opt-in path in the README.
|
|
85
|
+
|
|
86
|
+
## Path handling
|
|
87
|
+
|
|
88
|
+
- `file_path` is resolved via `realpath` where available, falling back to
|
|
89
|
+
`readlink -f`, falling back to the raw input.
|
|
90
|
+
- The file must be inside the current repo root (`git rev-parse --show-toplevel`
|
|
91
|
+
from `cwd`). Out-of-tree writes are skipped — inspector is per-project.
|
|
92
|
+
- `exclude_paths` is matched against the file's path relative to the repo root.
|
|
93
|
+
Match semantics are *containment* (`vendor/foo.ts` matches `vendor`), not
|
|
94
|
+
glob. Compass uses globs; inspector uses containment because the use case is
|
|
95
|
+
"skip this whole directory tree."
|
|
96
|
+
|
|
97
|
+
## Per-check execution
|
|
98
|
+
|
|
99
|
+
Each check runs in the repo root (`cwd`) with the following environment:
|
|
100
|
+
|
|
101
|
+
- inherited PATH plus any `mise`-shimmed bins (already set up at session start)
|
|
102
|
+
- `INSPECTOR_FILE` — absolute path to the touched file
|
|
103
|
+
- `INSPECTOR_FILE_RELATIVE` — relative to repo root
|
|
104
|
+
- `INSPECTOR_REPO_ROOT` — the repo root
|
|
105
|
+
- `INSPECTOR_PROJECT_KEY` — the project key
|
|
106
|
+
|
|
107
|
+
`timeout_seconds_per_check` is enforced via the `timeout` command (or a
|
|
108
|
+
fallback bash trap on systems that lack it). On timeout, inspector emits
|
|
109
|
+
`inspector.check.skipped` with `reason: "timeout"`.
|
|
110
|
+
|
|
111
|
+
If the command's first argv entry is not on PATH, inspector emits
|
|
112
|
+
`inspector.check.skipped` with `reason: "tool_missing"`. This is the dominant
|
|
113
|
+
"new project, lint not installed yet" case and must be quiet — no error in the
|
|
114
|
+
hook output, just the skipped event in the log.
|
|
115
|
+
|
|
116
|
+
## What the agent sees
|
|
117
|
+
|
|
118
|
+
The hook's stdout (the additional-context channel for PostToolUse hooks)
|
|
119
|
+
contains a compact, one-line-per-finding summary:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
inspector: src/cart.ts
|
|
123
|
+
✗ biome (3 issues)
|
|
124
|
+
src/cart.ts:42:5 — Unused variable 'subtotal'
|
|
125
|
+
src/cart.ts:51:3 — Missing return type annotation
|
|
126
|
+
src/cart.ts:64:9 — Unreachable code
|
|
127
|
+
✗ tsc (1 error)
|
|
128
|
+
src/cart.ts:42:5 — Type 'string | undefined' is not assignable to 'string'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
On clean runs, inspector either emits nothing to stdout (the silent case) or a
|
|
132
|
+
single confirmation line, controlled by `inspector.show_clean_runs`
|
|
133
|
+
(default `false`). Default silence avoids token spam on every edit.
|
|
134
|
+
|
|
135
|
+
Output capture per check is bounded by `inspector.output_excerpt_max_bytes`
|
|
136
|
+
(default 4096) — beyond that, inspector truncates with a `…[truncated]` marker
|
|
137
|
+
both in the agent-facing output and in the event payload.
|
|
138
|
+
|
|
139
|
+
## tsc and other whole-project checks
|
|
140
|
+
|
|
141
|
+
TypeScript's typecheck is project-scoped: `tsc --noEmit` checks every file in
|
|
142
|
+
the project, not just the touched one. Inspector cannot meaningfully run "tsc
|
|
143
|
+
on a single file" — `tsc --noEmit src/foo.ts` runs in a degraded mode that
|
|
144
|
+
loses project context (imports, references, lib types).
|
|
145
|
+
|
|
146
|
+
The right behavior is to run `tsc --noEmit -p tsconfig.json` (full project)
|
|
147
|
+
and filter the output to errors that mention the touched file. The first
|
|
148
|
+
implementation runs the full project tsc and post-filters. This is more
|
|
149
|
+
expensive than the lint case but cached by tsc's incremental compilation
|
|
150
|
+
(`tsBuildInfoFile`). Users who care about latency can disable the tsc check
|
|
151
|
+
per-project.
|
|
152
|
+
|
|
153
|
+
Other languages with similar whole-project semantics (mypy, cargo check,
|
|
154
|
+
golangci-lint) follow the same pattern: run the project-wide command, filter
|
|
155
|
+
results to the touched file. Users opt into these in `config.json` because the
|
|
156
|
+
cost is higher than per-file lint.
|
|
157
|
+
|
|
158
|
+
## Events
|
|
159
|
+
|
|
160
|
+
All four events are registered in `@onlooker-community/schema` under
|
|
161
|
+
`plugins-verification.json`.
|
|
162
|
+
|
|
163
|
+
### `inspector.check.passed`
|
|
164
|
+
|
|
165
|
+
Emitted once per check that passed cleanly.
|
|
166
|
+
|
|
167
|
+
```jsonc
|
|
168
|
+
{
|
|
169
|
+
"file_path": "/abs/path/to/src/cart.ts",
|
|
170
|
+
"file_path_relative": "src/cart.ts",
|
|
171
|
+
"tool_name": "Edit", // Write | Edit | MultiEdit
|
|
172
|
+
"check_name": "biome",
|
|
173
|
+
"check_kind": "lint", // lint | typecheck
|
|
174
|
+
"argv": ["biome", "check", "/abs/path/to/src/cart.ts"],
|
|
175
|
+
"duration_ms": 124
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### `inspector.check.failed`
|
|
180
|
+
|
|
181
|
+
Emitted once per check that returned a non-zero exit code.
|
|
182
|
+
|
|
183
|
+
```jsonc
|
|
184
|
+
{
|
|
185
|
+
"file_path": "/abs/path/to/src/cart.ts",
|
|
186
|
+
"file_path_relative": "src/cart.ts",
|
|
187
|
+
"tool_name": "Edit",
|
|
188
|
+
"check_name": "tsc",
|
|
189
|
+
"check_kind": "typecheck",
|
|
190
|
+
"argv": ["tsc", "--noEmit"],
|
|
191
|
+
"duration_ms": 980,
|
|
192
|
+
"exit_code": 2,
|
|
193
|
+
"issue_count": 3,
|
|
194
|
+
"output_excerpt": "src/cart.ts:42:5 — Type 'string | undefined' is not assignable to 'string'\n…"
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`issue_count` is best-effort: inspector parses common output formats (one
|
|
199
|
+
issue per non-empty line, ignoring obvious headers/footers) where possible
|
|
200
|
+
and falls back to `null` when the format is unknown.
|
|
201
|
+
|
|
202
|
+
### `inspector.check.skipped`
|
|
203
|
+
|
|
204
|
+
Emitted when a check did not run.
|
|
205
|
+
|
|
206
|
+
```jsonc
|
|
207
|
+
{
|
|
208
|
+
"file_path": "/abs/path/to/src/cart.ts",
|
|
209
|
+
"file_path_relative": "src/cart.ts",
|
|
210
|
+
"tool_name": "Edit",
|
|
211
|
+
"check_name": "tsc", // optional — absent for whole-file skips
|
|
212
|
+
"reason": "tool_missing" // tool_missing | disabled | excluded_path
|
|
213
|
+
// | no_extension_match | timeout
|
|
214
|
+
// | not_in_repo | total_budget_exhausted
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### `inspector.run.completed`
|
|
219
|
+
|
|
220
|
+
Emitted once per hook invocation, after all per-check events for that file.
|
|
221
|
+
|
|
222
|
+
```jsonc
|
|
223
|
+
{
|
|
224
|
+
"file_path": "/abs/path/to/src/cart.ts",
|
|
225
|
+
"file_path_relative": "src/cart.ts",
|
|
226
|
+
"tool_name": "Edit",
|
|
227
|
+
"checks_run": 2,
|
|
228
|
+
"checks_passed": 0,
|
|
229
|
+
"checks_failed": 2,
|
|
230
|
+
"checks_skipped": 0,
|
|
231
|
+
"duration_ms": 1104
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Downstream consumers that just want "did this edit produce broken code?" read
|
|
236
|
+
`inspector.run.completed` and check `checks_failed > 0`. Consumers that want
|
|
237
|
+
per-tool detail subscribe to `inspector.check.*`.
|
|
238
|
+
|
|
239
|
+
## Project-key derivation
|
|
240
|
+
|
|
241
|
+
Same algorithm as the rest of the ecosystem:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
SHA256("remote:" + git remote get-url origin) → first 12 hex chars
|
|
245
|
+
→ fall back to SHA256("root:" + git rev-parse --show-toplevel)[:12]
|
|
246
|
+
→ fall back to SHA256("cwd:" + pwd)[:12]
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Implemented in `plugins/inspector/scripts/lib/inspector-project-key.sh`,
|
|
250
|
+
mirroring the equivalent helper in cartographer.
|
|
251
|
+
|
|
252
|
+
## Failure modes and fail-soft behavior
|
|
253
|
+
|
|
254
|
+
Inspector is advisory. It never blocks the tool call. Specifically:
|
|
255
|
+
|
|
256
|
+
- Missing tool on PATH → `.skipped` event, no agent-facing output
|
|
257
|
+
- Timeout → `.skipped` event, agent sees `inspector: src/foo.ts — biome timed out`
|
|
258
|
+
- Hook script error → exit 0 with no event (last-resort; logged to
|
|
259
|
+
`~/.onlooker/inspector/<project>/hook.log`)
|
|
260
|
+
- Schema validation error → logged to stderr, hook continues
|
|
261
|
+
- Concurrent invocation → no lock; each check runs independently. tsc's own
|
|
262
|
+
incremental cache handles concurrent invocations safely
|
|
263
|
+
|
|
264
|
+
The "exit 0 with no event" path is the only case where inspector goes silent.
|
|
265
|
+
This is deliberate: inspector is a noticeably new behavior surface; bugs in the
|
|
266
|
+
hook script must not block writes.
|
|
267
|
+
|
|
268
|
+
## Open questions
|
|
269
|
+
|
|
270
|
+
These are deferred to a follow-up ADR after first real-world use.
|
|
271
|
+
|
|
272
|
+
1. **Output filtering for whole-project checks.** Filtering tsc/mypy output to
|
|
273
|
+
the touched file is necessary for "only what you broke just now" UX. But
|
|
274
|
+
compile errors in unrelated files often *are* caused by this edit (a removed
|
|
275
|
+
export breaks five importers). Showing only the touched-file errors hides
|
|
276
|
+
real damage; showing all errors floods the channel. Tentative: show only
|
|
277
|
+
touched-file errors by default; add `inspector.show_collateral_errors`
|
|
278
|
+
config knob if requests come in.
|
|
279
|
+
2. **Caching.** Repeated edits to the same file within seconds will re-run all
|
|
280
|
+
checks. Worth caching at the level of "skip identical file content"? Not
|
|
281
|
+
for v1.
|
|
282
|
+
3. **Parallel checks.** Checks for the same file run sequentially today. Worth
|
|
283
|
+
parallelizing? Not for v1 — most lints finish in <500ms and bash-level
|
|
284
|
+
parallelism with timeouts is fiddly.
|
|
285
|
+
|
|
286
|
+
[assayer]: ../../assayer/docs/design.md
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PostToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Write",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/inspector-post-write.sh"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"matcher": "Edit",
|
|
15
|
+
"hooks": [
|
|
16
|
+
{
|
|
17
|
+
"type": "command",
|
|
18
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/inspector-post-write.sh"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"matcher": "MultiEdit",
|
|
24
|
+
"hooks": [
|
|
25
|
+
{
|
|
26
|
+
"type": "command",
|
|
27
|
+
"command": "\"$CLAUDE_PLUGIN_ROOT\"/scripts/hooks/inspector-post-write.sh"
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# inspector-post-write.sh — PostToolUse hook for Write / Edit / MultiEdit.
|
|
3
|
+
#
|
|
4
|
+
# Runs the project's configured lint and typecheck commands on just the
|
|
5
|
+
# touched file. Emits inspector.check.* and inspector.run.completed events.
|
|
6
|
+
# Surfaces a compact summary on stdout for the agent's next turn.
|
|
7
|
+
# Always exits 0 — inspector is advisory.
|
|
8
|
+
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
# Recursion guard — prevents inspector from re-triggering itself if a check
|
|
12
|
+
# command happens to write to a watched file via its own tooling.
|
|
13
|
+
[[ "${INSPECTOR_NESTED:-0}" == "1" ]] && exit 0
|
|
14
|
+
export INSPECTOR_NESTED=1
|
|
15
|
+
|
|
16
|
+
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
|
|
17
|
+
export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
|
|
18
|
+
|
|
19
|
+
source "$PLUGIN_ROOT/scripts/lib/inspector-config.sh"
|
|
20
|
+
source "$PLUGIN_ROOT/scripts/lib/inspector-project-key.sh"
|
|
21
|
+
source "$PLUGIN_ROOT/scripts/lib/inspector-events.sh"
|
|
22
|
+
source "$PLUGIN_ROOT/scripts/lib/inspector-run.sh"
|
|
23
|
+
|
|
24
|
+
HOOK_INPUT=$(cat)
|
|
25
|
+
CWD=$(printf '%s' "$HOOK_INPUT" | jq -r '.cwd // empty' 2>/dev/null)
|
|
26
|
+
_HOOK_SESSION_ID=$(printf '%s' "$HOOK_INPUT" | jq -r '.session_id // empty' 2>/dev/null)
|
|
27
|
+
TOOL_NAME=$(printf '%s' "$HOOK_INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
28
|
+
export _HOOK_SESSION_ID
|
|
29
|
+
|
|
30
|
+
# Bail on missing input — never block the tool call.
|
|
31
|
+
[[ -z "$CWD" ]] && exit 0
|
|
32
|
+
case "$TOOL_NAME" in
|
|
33
|
+
Write|Edit|MultiEdit) ;;
|
|
34
|
+
*) exit 0 ;;
|
|
35
|
+
esac
|
|
36
|
+
export INSPECTOR_TOOL_NAME="$TOOL_NAME"
|
|
37
|
+
|
|
38
|
+
# Resolve touched file from tool input.
|
|
39
|
+
TOOL_TARGET=$(printf '%s' "$HOOK_INPUT" \
|
|
40
|
+
| jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
|
|
41
|
+
[[ -z "$TOOL_TARGET" ]] && exit 0
|
|
42
|
+
|
|
43
|
+
# Canonicalize.
|
|
44
|
+
if command -v realpath &>/dev/null; then
|
|
45
|
+
CANONICAL=$(realpath "$TOOL_TARGET" 2>/dev/null) || CANONICAL="$TOOL_TARGET"
|
|
46
|
+
elif command -v readlink &>/dev/null; then
|
|
47
|
+
CANONICAL=$(readlink -f "$TOOL_TARGET" 2>/dev/null) || CANONICAL="$TOOL_TARGET"
|
|
48
|
+
else
|
|
49
|
+
CANONICAL="$TOOL_TARGET"
|
|
50
|
+
fi
|
|
51
|
+
export INSPECTOR_FILE="$CANONICAL"
|
|
52
|
+
|
|
53
|
+
REPO_ROOT=$(inspector_project_repo_root "$CWD")
|
|
54
|
+
export INSPECTOR_REPO_ROOT="$REPO_ROOT"
|
|
55
|
+
|
|
56
|
+
# Project-key derivation — always succeeds (falls back to cwd hash).
|
|
57
|
+
PROJECT_KEY=$(inspector_project_key "$CWD")
|
|
58
|
+
export INSPECTOR_PROJECT_KEY="$PROJECT_KEY"
|
|
59
|
+
|
|
60
|
+
# File must live under repo root.
|
|
61
|
+
if [[ "$CANONICAL" != "$REPO_ROOT"/* && "$CANONICAL" != "$REPO_ROOT" ]]; then
|
|
62
|
+
export INSPECTOR_FILE_RELATIVE="$CANONICAL"
|
|
63
|
+
inspector_config_load "$REPO_ROOT"
|
|
64
|
+
inspector_config_enabled || exit 0
|
|
65
|
+
inspector_emit_whole_file_skipped "not_in_repo"
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
export INSPECTOR_FILE_RELATIVE="${CANONICAL#"$REPO_ROOT"/}"
|
|
69
|
+
|
|
70
|
+
inspector_config_load "$REPO_ROOT"
|
|
71
|
+
|
|
72
|
+
if ! inspector_config_enabled; then
|
|
73
|
+
# Silent skip — do not even emit an event when disabled. Disabled means
|
|
74
|
+
# "this plugin is dormant," not "this file was uninteresting."
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# Excluded path containment check.
|
|
79
|
+
EXCLUDES=$(inspector_config_exclude_paths)
|
|
80
|
+
if [[ -n "$EXCLUDES" && "$EXCLUDES" != "null" && "$EXCLUDES" != "[]" ]]; then
|
|
81
|
+
if jq -e --arg rel "$INSPECTOR_FILE_RELATIVE" \
|
|
82
|
+
'any(.[]; . as $p | $rel | startswith($p + "/") or . == $p or (("/" + $rel) | contains("/" + $p + "/")))' \
|
|
83
|
+
<<<"$EXCLUDES" >/dev/null 2>&1; then
|
|
84
|
+
inspector_emit_whole_file_skipped "excluded_path"
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
# Look up checks for this file's extension. Use the *longest* matching suffix
|
|
90
|
+
# so `.test.ts` matches before `.ts`. For now this is a simple two-step:
|
|
91
|
+
# first the multi-dot suffix, then the simple extension.
|
|
92
|
+
FILE_BASE=$(basename "$CANONICAL")
|
|
93
|
+
EXT_LONG=""
|
|
94
|
+
EXT_SHORT=""
|
|
95
|
+
if [[ "$FILE_BASE" == *.*.* ]]; then
|
|
96
|
+
EXT_LONG=".${FILE_BASE#*.}"
|
|
97
|
+
fi
|
|
98
|
+
if [[ "$FILE_BASE" == *.* ]]; then
|
|
99
|
+
EXT_SHORT=".${FILE_BASE##*.}"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
CHECKS="[]"
|
|
103
|
+
if [[ -n "$EXT_LONG" ]]; then
|
|
104
|
+
CANDIDATE=$(inspector_config_checks_for_extension "$EXT_LONG")
|
|
105
|
+
if [[ -n "$CANDIDATE" && "$CANDIDATE" != "[]" ]]; then
|
|
106
|
+
CHECKS="$CANDIDATE"
|
|
107
|
+
fi
|
|
108
|
+
fi
|
|
109
|
+
if [[ "$CHECKS" == "[]" && -n "$EXT_SHORT" ]]; then
|
|
110
|
+
CANDIDATE=$(inspector_config_checks_for_extension "$EXT_SHORT")
|
|
111
|
+
if [[ -n "$CANDIDATE" && "$CANDIDATE" != "[]" ]]; then
|
|
112
|
+
CHECKS="$CANDIDATE"
|
|
113
|
+
fi
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
if [[ "$CHECKS" == "[]" ]]; then
|
|
117
|
+
inspector_emit_whole_file_skipped "no_extension_match"
|
|
118
|
+
exit 0
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# Execute. Always exit 0 regardless of check outcomes.
|
|
122
|
+
inspector_run "$CHECKS" || true
|
|
123
|
+
|
|
124
|
+
exit 0
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# inspector-config.sh — load and query Inspector configuration.
|
|
3
|
+
#
|
|
4
|
+
# Merges three layers in precedence order (later wins):
|
|
5
|
+
# 1. plugins/inspector/config.json (plugin defaults)
|
|
6
|
+
# 2. ~/.claude/settings.json (.inspector subtree)
|
|
7
|
+
# 3. <repo>/.claude/settings.json (.inspector subtree)
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# inspector_config_load <repo_root>
|
|
11
|
+
# inspector_config_enabled
|
|
12
|
+
# inspector_config_get ".inspector.timeout_seconds_per_check"
|
|
13
|
+
# inspector_config_get_json ".inspector.exclude_paths"
|
|
14
|
+
# inspector_config_checks_for_extension ".ts"
|
|
15
|
+
|
|
16
|
+
_INSPECTOR_CONFIG=""
|
|
17
|
+
_INSPECTOR_PLUGIN_CONFIG=""
|
|
18
|
+
|
|
19
|
+
inspector_config_load() {
|
|
20
|
+
local repo_root="${1:-}"
|
|
21
|
+
local plugin_dir
|
|
22
|
+
plugin_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
23
|
+
local plugin_config="$plugin_dir/config.json"
|
|
24
|
+
|
|
25
|
+
_INSPECTOR_PLUGIN_CONFIG="{}"
|
|
26
|
+
if [[ -f "$plugin_config" ]]; then
|
|
27
|
+
_INSPECTOR_PLUGIN_CONFIG=$(cat "$plugin_config")
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
local home_settings="{}"
|
|
31
|
+
if [[ -f "$HOME/.claude/settings.json" ]]; then
|
|
32
|
+
home_settings=$(cat "$HOME/.claude/settings.json")
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
local repo_settings="{}"
|
|
36
|
+
if [[ -n "$repo_root" && -f "$repo_root/.claude/settings.json" ]]; then
|
|
37
|
+
repo_settings=$(cat "$repo_root/.claude/settings.json")
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
_INSPECTOR_CONFIG=$(jq -n \
|
|
41
|
+
--argjson plugin "$_INSPECTOR_PLUGIN_CONFIG" \
|
|
42
|
+
--argjson home "$home_settings" \
|
|
43
|
+
--argjson repo "$repo_settings" \
|
|
44
|
+
'$plugin * {"inspector": (($plugin.inspector // {}) * ($home.inspector // {}) * ($repo.inspector // {}))}')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
inspector_config_get() {
|
|
48
|
+
local path="${1:-}"
|
|
49
|
+
printf '%s' "$_INSPECTOR_CONFIG" | jq -r "$path // empty" 2>/dev/null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
inspector_config_get_json() {
|
|
53
|
+
local path="${1:-}"
|
|
54
|
+
printf '%s' "$_INSPECTOR_CONFIG" | jq -c "$path // empty" 2>/dev/null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
inspector_config_enabled() {
|
|
58
|
+
local v
|
|
59
|
+
v=$(inspector_config_get '.inspector.enabled')
|
|
60
|
+
[[ "$v" == "true" ]]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
inspector_config_show_clean_runs() {
|
|
64
|
+
local v
|
|
65
|
+
v=$(inspector_config_get '.inspector.show_clean_runs')
|
|
66
|
+
[[ "$v" == "true" ]]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
inspector_config_timeout_per_check() {
|
|
70
|
+
local v
|
|
71
|
+
v=$(inspector_config_get '.inspector.timeout_seconds_per_check')
|
|
72
|
+
printf '%s' "${v:-10}"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
inspector_config_total_timeout() {
|
|
76
|
+
local v
|
|
77
|
+
v=$(inspector_config_get '.inspector.total_timeout_seconds')
|
|
78
|
+
printf '%s' "${v:-30}"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
inspector_config_output_excerpt_max_bytes() {
|
|
82
|
+
local v
|
|
83
|
+
v=$(inspector_config_get '.inspector.output_excerpt_max_bytes')
|
|
84
|
+
printf '%s' "${v:-4096}"
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
inspector_config_exclude_paths() {
|
|
88
|
+
inspector_config_get_json '.inspector.exclude_paths // []'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Emits a JSON array of {name, argv, kind} objects for the given file extension
|
|
92
|
+
# (including the leading dot). Returns an empty array when no checks are
|
|
93
|
+
# configured for the extension.
|
|
94
|
+
inspector_config_checks_for_extension() {
|
|
95
|
+
local ext="${1:-}"
|
|
96
|
+
[[ -z "$ext" ]] && { printf '[]'; return; }
|
|
97
|
+
printf '%s' "$_INSPECTOR_CONFIG" | jq -c --arg ext "$ext" '
|
|
98
|
+
(.inspector.checks // {}) as $checks
|
|
99
|
+
| ($checks[$ext] // [])
|
|
100
|
+
| map(
|
|
101
|
+
if type == "array" then
|
|
102
|
+
{ name: (.[0] // "check"), argv: ., kind: "lint" }
|
|
103
|
+
else
|
|
104
|
+
{ name: (.name // "check"), argv: (.argv // []), kind: (.kind // "lint") }
|
|
105
|
+
end
|
|
106
|
+
)
|
|
107
|
+
' 2>/dev/null || printf '[]'
|
|
108
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# inspector-events.sh — emit inspector.* events to the canonical event log.
|
|
3
|
+
#
|
|
4
|
+
# Thin wrapper around onlooker-event.mjs. Validation failures are logged to
|
|
5
|
+
# stderr and do not abort the caller — Inspector is advisory.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# inspector_emit_event "inspector.check.passed" '{"file_path":"...","tool_name":"Edit",...}'
|
|
9
|
+
|
|
10
|
+
_INSPECTOR_PLUGIN_NAME="inspector"
|
|
11
|
+
|
|
12
|
+
_inspector_event_js_path() {
|
|
13
|
+
if [[ -n "${_ONLOOKER_EVENT_JS:-}" && -f "$_ONLOOKER_EVENT_JS" ]]; then
|
|
14
|
+
printf '%s' "$_ONLOOKER_EVENT_JS"
|
|
15
|
+
return 0
|
|
16
|
+
fi
|
|
17
|
+
local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
|
|
18
|
+
local candidates=(
|
|
19
|
+
"${plugin_root}/scripts/lib/onlooker-event.mjs"
|
|
20
|
+
"${plugin_root}/../../scripts/lib/onlooker-event.mjs"
|
|
21
|
+
)
|
|
22
|
+
local c
|
|
23
|
+
for c in "${candidates[@]}"; do
|
|
24
|
+
[[ -f "$c" ]] && { printf '%s' "$c"; return 0; }
|
|
25
|
+
done
|
|
26
|
+
return 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_inspector_session_id() {
|
|
30
|
+
if [[ -n "${_HOOK_SESSION_ID:-}" ]]; then
|
|
31
|
+
printf '%s' "$_HOOK_SESSION_ID"
|
|
32
|
+
return 0
|
|
33
|
+
fi
|
|
34
|
+
if [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
|
|
35
|
+
printf '%s' "$CLAUDE_SESSION_ID"
|
|
36
|
+
return 0
|
|
37
|
+
fi
|
|
38
|
+
printf 'unknown'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
inspector_emit_event() {
|
|
42
|
+
local event_type="${1:-}"
|
|
43
|
+
local payload="${2:-}"
|
|
44
|
+
[[ -z "$event_type" || -z "$payload" ]] && return 1
|
|
45
|
+
|
|
46
|
+
local event_js
|
|
47
|
+
event_js=$(_inspector_event_js_path) || {
|
|
48
|
+
printf 'inspector-events: cannot locate onlooker-event.mjs\n' >&2
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
local session_id
|
|
53
|
+
session_id=$(_inspector_session_id)
|
|
54
|
+
|
|
55
|
+
local params
|
|
56
|
+
params=$(jq -n \
|
|
57
|
+
--arg plugin "$_INSPECTOR_PLUGIN_NAME" \
|
|
58
|
+
--arg sid "$session_id" \
|
|
59
|
+
--arg type "$event_type" \
|
|
60
|
+
--argjson payload "$payload" \
|
|
61
|
+
'{"plugin":$plugin,"session_id":$sid,"event_type":$type,"payload":$payload}')
|
|
62
|
+
|
|
63
|
+
local stderr_file
|
|
64
|
+
stderr_file=$(mktemp -t inspector-event-err.XXXXXX 2>/dev/null) \
|
|
65
|
+
|| stderr_file="/tmp/inspector-event-err.$$"
|
|
66
|
+
|
|
67
|
+
local event
|
|
68
|
+
event=$(printf '%s' "$params" \
|
|
69
|
+
| ONLOOKER_DIR="${ONLOOKER_DIR:-$HOME/.onlooker}" \
|
|
70
|
+
ONLOOKER_PLUGIN_NAME="$_INSPECTOR_PLUGIN_NAME" \
|
|
71
|
+
node "$event_js" emit 2>"$stderr_file") || {
|
|
72
|
+
printf 'inspector-events: schema validation failed for %s\n' "$event_type" >&2
|
|
73
|
+
[[ -s "$stderr_file" ]] && cat "$stderr_file" >&2
|
|
74
|
+
rm -f "$stderr_file"
|
|
75
|
+
return 1
|
|
76
|
+
}
|
|
77
|
+
rm -f "$stderr_file"
|
|
78
|
+
|
|
79
|
+
local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
|
|
80
|
+
mkdir -p "$(dirname "$log_path")" 2>/dev/null || return 1
|
|
81
|
+
printf '%s\n' "$event" >>"$log_path"
|
|
82
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# inspector-project-key.sh — stable 12-char hex project key.
|
|
3
|
+
#
|
|
4
|
+
# Derives a key that survives repo renames, clones, and worktrees.
|
|
5
|
+
# Algorithm:
|
|
6
|
+
# 1. git remote get-url origin → sha256("remote:" + url)[0:12]
|
|
7
|
+
# 2. Fallback: git rev-parse --show-toplevel → sha256("root:" + path)[0:12]
|
|
8
|
+
# 3. Non-git: sha256("cwd:" + pwd)[0:12]
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# key=$(inspector_project_key <cwd>)
|
|
12
|
+
# root=$(inspector_project_repo_root <cwd>)
|
|
13
|
+
|
|
14
|
+
_inspector_sha256_first12() {
|
|
15
|
+
local input="$1"
|
|
16
|
+
if command -v sha256sum &>/dev/null; then
|
|
17
|
+
printf '%s' "$input" | sha256sum | cut -c1-12
|
|
18
|
+
elif command -v shasum &>/dev/null; then
|
|
19
|
+
printf '%s' "$input" | shasum -a 256 | cut -c1-12
|
|
20
|
+
else
|
|
21
|
+
printf '%s' "$input" | python3 -c \
|
|
22
|
+
'import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])'
|
|
23
|
+
fi
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
inspector_project_repo_root() {
|
|
27
|
+
local cwd="${1:-$(pwd)}"
|
|
28
|
+
local root
|
|
29
|
+
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) && printf '%s' "$root" && return 0
|
|
30
|
+
printf '%s' "$cwd"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
inspector_project_remote_url() {
|
|
34
|
+
local cwd="${1:-$(pwd)}"
|
|
35
|
+
git -C "$cwd" remote get-url origin 2>/dev/null || true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
inspector_project_key() {
|
|
39
|
+
local cwd="${1:-$(pwd)}"
|
|
40
|
+
local remote
|
|
41
|
+
remote=$(inspector_project_remote_url "$cwd")
|
|
42
|
+
if [[ -n "$remote" ]]; then
|
|
43
|
+
_inspector_sha256_first12 "remote:${remote}"
|
|
44
|
+
return 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
local root
|
|
48
|
+
root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null)
|
|
49
|
+
if [[ -n "$root" ]]; then
|
|
50
|
+
_inspector_sha256_first12 "root:${root}"
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
_inspector_sha256_first12 "cwd:${cwd}"
|
|
55
|
+
}
|