@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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.release-please-manifest.json +2 -2
  4. package/CHANGELOG.md +7 -0
  5. package/CLAUDE.md +2 -0
  6. package/docs/plugin-catalog.md +125 -0
  7. package/package.json +3 -3
  8. package/plugins/compass/.claude-plugin/plugin.json +1 -1
  9. package/plugins/compass/CHANGELOG.md +7 -0
  10. package/plugins/compass/README.md +1 -3
  11. package/plugins/compass/config.json +1 -2
  12. package/plugins/compass/docs/design.md +1 -2
  13. package/plugins/compass/scripts/hooks/compass-bash-gate.sh +8 -1
  14. package/plugins/compass/scripts/hooks/compass-pre-tool-use.sh +8 -1
  15. package/plugins/compass/scripts/hooks/compass-record-write.sh +5 -0
  16. package/plugins/compass/scripts/hooks/compass-session-start.sh +0 -8
  17. package/plugins/compass/scripts/lib/compass-evaluator.sh +58 -98
  18. package/plugins/compass/scripts/lib/compass-gate.sh +15 -18
  19. package/plugins/compass/scripts/lib/compass-sanitizer.sh +4 -4
  20. package/plugins/compass/scripts/lib/compass-transcript.sh +79 -112
  21. package/plugins/inspector/.claude-plugin/plugin.json +14 -0
  22. package/plugins/inspector/README.md +155 -0
  23. package/plugins/inspector/config.json +25 -0
  24. package/plugins/inspector/docs/design.md +286 -0
  25. package/plugins/inspector/hooks/hooks.json +33 -0
  26. package/plugins/inspector/scripts/hooks/inspector-post-write.sh +124 -0
  27. package/plugins/inspector/scripts/lib/inspector-config.sh +108 -0
  28. package/plugins/inspector/scripts/lib/inspector-events.sh +82 -0
  29. package/plugins/inspector/scripts/lib/inspector-project-key.sh +55 -0
  30. package/plugins/inspector/scripts/lib/inspector-run.sh +305 -0
  31. package/plugins/inspector/scripts/lib/inspector-ulid.sh +45 -0
  32. package/test/bats/archivist-project-key.bats +79 -0
  33. package/test/bats/archivist-storage.bats +79 -0
  34. package/test/bats/compact-tracker.bats +125 -0
  35. package/test/bats/compass-config.bats +65 -0
  36. package/test/bats/compass-gate.bats +129 -0
  37. package/test/bats/compass-sanitizer.bats +69 -0
  38. package/test/bats/compass-symbolic-skip.bats +88 -0
  39. package/test/bats/compass-transcript.bats +80 -0
  40. package/test/bats/inspector-config.bats +118 -0
  41. package/test/bats/inspector-events.bats +156 -0
  42. package/test/bats/inspector-post-write-hook.bats +164 -0
  43. package/test/bats/inspector-project-key.bats +68 -0
  44. package/test/bats/inspector-ulid.bats +34 -0
  45. package/test/bats/onlooker-schema.bats +111 -0
  46. package/test/bats/prompt-rules.bats +98 -0
  47. package/test/bats/session-tracker.bats +260 -0
  48. package/test/bats/skill-usage-tracker.bats +63 -0
  49. package/test/bats/task-tracker.bats +102 -0
  50. package/test/bats/turn-tracker.bats +180 -0
  51. package/test/bats/validate-path.bats +125 -0
  52. package/test/bats/worktree-tracker.bats +167 -0
@@ -92,21 +92,18 @@ _compass_matches_skip_glob() {
92
92
  local globs_json="$2"
93
93
  [[ -z "$file_path" || -z "$globs_json" ]] && return 1
94
94
 
95
- # Convert JSON array to bash array and check each pattern.
96
- local globs
97
- mapfile -t globs < <(printf '%s' "$globs_json" | jq -r '.[]' 2>/dev/null) || return 1
98
-
99
- local glob
100
- for glob in "${globs[@]}"; do
101
- # Use bash glob matching (extglob not needed for ** — use case).
102
- # Convert ** to a catch-all for simple prefix/suffix matching.
103
- local pattern="${glob//\*\*/DOUBLE_STAR}"
95
+ # Bash 3.2 (macOS) lacks mapfile, so stream via read-while.
96
+ local glob pattern
97
+ while IFS= read -r glob; do
98
+ [[ -z "$glob" ]] && continue
99
+ # Translate ** → .* and * → [^/]* for simple prefix/suffix matching.
100
+ pattern="${glob//\*\*/DOUBLE_STAR}"
104
101
  pattern="${pattern//\*/[^/]*}"
105
102
  pattern="${pattern//DOUBLE_STAR/.*}"
106
103
  if [[ "$file_path" =~ $pattern ]]; then
107
104
  return 0
108
105
  fi
109
- done
106
+ done < <(printf '%s' "$globs_json" | jq -r '.[]' 2>/dev/null)
110
107
  return 1
111
108
  }
112
109
 
@@ -241,12 +238,13 @@ Choose a path:
241
238
 
242
239
  # -----------------------------------------------------------------------
243
240
  # Main gate function.
244
- # $1 — tool_name (Write | Edit | MultiEdit | Bash)
245
- # $2 — file_path (may be empty for Bash)
246
- # $3 — operation (write | edit | multi_edit | bash_write)
247
- # $4 — context (context excerpt or bash command string)
241
+ # $1 — tool_name (Write | Edit | MultiEdit | Bash)
242
+ # $2 — file_path (may be empty for Bash)
243
+ # $3 — operation (write | edit | multi_edit | bash_write)
244
+ # $4 — context (context excerpt or bash command string)
248
245
  # $5 — session_id
249
246
  # $6 — cwd
247
+ # $7 — transcript_path (from hook JSON; may be empty)
250
248
  # -----------------------------------------------------------------------
251
249
  compass_run_gate() {
252
250
  local tool_name="$1"
@@ -255,6 +253,7 @@ compass_run_gate() {
255
253
  local context="$4"
256
254
  local session_id="${5:-unknown}"
257
255
  local cwd="${6:-}"
256
+ local transcript_path="${7:-}"
258
257
 
259
258
  local _allow_exit=0
260
259
  local _block_exit=0
@@ -348,14 +347,12 @@ compass_run_gate() {
348
347
  _compass_increment_turn_count "$session_id" 2>/dev/null || true
349
348
 
350
349
  # ---- Read prior assistant turn -----------------------------------
351
- local prior_turn_chars_max transcript_max_age
350
+ local prior_turn_chars_max
352
351
  prior_turn_chars_max=$(compass_config_get '.compass.transcript.prior_turn_chars_max')
353
352
  prior_turn_chars_max="${prior_turn_chars_max:-800}"
354
- transcript_max_age=$(compass_config_get '.compass.transcript.transcript_max_age_seconds')
355
- transcript_max_age="${transcript_max_age:-300}"
356
353
 
357
354
  local prior_turn=""
358
- prior_turn=$(compass_read_prior_turn "$session_id" "$prior_turn_chars_max" "$transcript_max_age") \
355
+ prior_turn=$(compass_read_prior_turn "$transcript_path" "$prior_turn_chars_max") \
359
356
  || prior_turn=""
360
357
 
361
358
  # ---- Symbolic skip layer -----------------------------------------
@@ -42,13 +42,13 @@ _compass_strip_control_chars() {
42
42
  }
43
43
 
44
44
  # Replace all occurrences of a literal string with [STRIPPED].
45
+ # Uses bash native parameter expansion to avoid the sed delimiter trap —
46
+ # needles like </prior_assistant_turn> contain '/' which breaks sed s///.
45
47
  _compass_strip_literal() {
46
48
  local input="$1"
47
49
  local needle="$2"
48
- # Use printf + sed; escape needle for BRE (basic regex)
49
- local escaped_needle
50
- escaped_needle=$(printf '%s' "$needle" | sed 's/[[\.*^$()+?{|]/\\&/g' 2>/dev/null) || escaped_needle="$needle"
51
- printf '%s' "$input" | sed "s/${escaped_needle}/[STRIPPED]/g" 2>/dev/null
50
+ [[ -z "$needle" ]] && { printf '%s' "$input"; return; }
51
+ printf '%s' "${input//"$needle"/[STRIPPED]}"
52
52
  }
53
53
 
54
54
  # Truncate a string to at most max_chars UTF-8 characters.
@@ -1,135 +1,102 @@
1
1
  #!/usr/bin/env bash
2
2
  # Prior assistant turn reader for Compass.
3
3
  #
4
- # Resolves the most recent assistant turn from the session transcript so
5
- # the evaluator can operate on the pair {prior_assistant_turn, context}
6
- # rather than context alone avoiding false positives on question-answer
7
- # turns (see ADR-001).
4
+ # Resolves the most recent assistant turn from the session transcript JSONL
5
+ # so the evaluator can operate on the pair {prior_assistant_turn, context}
6
+ # rather than context alone. Avoids false positives on question-answer
7
+ # turns. See ADR-001 (plugins/compass/docs/adr/001-evaluate-prompts-in-context.md).
8
8
  #
9
- # Resolution order:
10
- # 1. CLAUDE_TRANSCRIPT_PATH env var parse as JSONL, find most recent
11
- # entry with role:"assistant"
12
- # 2. Onlooker JSONL event log (~/.onlooker/logs/onlooker-events.jsonl)
13
- # filtered by session_id and event_type:"session.prompt", most recent
14
- # assistant-role entry
15
- # 3. Empty string — degrades gracefully; evaluator runs on context alone
9
+ # Source: the `transcript_path` field from the hook JSON payload. This is
10
+ # the same field tribunal-stop-gate.sh reads (`jq -r '.transcript_path // ""'`).
11
+ # When `transcript_path` is absent or unreadable, this function returns the
12
+ # empty string and the evaluator runs on the current context alone.
16
13
  #
17
14
  # Exposes:
18
- # compass_read_prior_turn <session_id> <max_chars> <max_age_seconds>
19
- # Echoes the prior assistant turn text (possibly empty).
20
-
21
- _compass_transcript_from_path() {
22
- local transcript_path="$1"
23
- local session_id="$2"
24
- local max_age_seconds="$3"
25
-
26
- [[ -f "$transcript_path" ]] || return 1
27
-
28
- local now
29
- now=$(date +%s 2>/dev/null) || now=0
30
-
31
- # Parse JSONL: find most recent entry with role=assistant within max_age_seconds.
32
- # Each line is a JSON object. We want the last one matching our criteria.
33
- local prior_turn=""
34
- local line
35
- while IFS= read -r line; do
36
- [[ -z "$line" ]] && continue
37
- local role ts content
38
- role=$(printf '%s' "$line" | jq -r '.role // empty' 2>/dev/null) || continue
39
- [[ "$role" == "assistant" ]] || continue
40
-
41
- # Age check — skip entries older than max_age_seconds.
42
- if [[ "$max_age_seconds" -gt 0 ]]; then
43
- ts=$(printf '%s' "$line" | jq -r '.timestamp // empty' 2>/dev/null) || ts=""
44
- if [[ -n "$ts" ]]; then
45
- local entry_time
46
- entry_time=$(date -d "$ts" +%s 2>/dev/null) \
47
- || entry_time=$(date -jf '%Y-%m-%dT%H:%M:%SZ' "$ts" +%s 2>/dev/null) \
48
- || entry_time=0
49
- local age=$(( now - entry_time ))
50
- [[ "$age" -gt "$max_age_seconds" ]] && continue
51
- fi
52
- fi
53
-
54
- content=$(printf '%s' "$line" | jq -r '.content // .text // empty' 2>/dev/null) || continue
55
- [[ -n "$content" ]] && prior_turn="$content"
56
- done < "$transcript_path"
57
-
58
- [[ -n "$prior_turn" ]] || return 1
59
- printf '%s' "$prior_turn"
15
+ # compass_read_prior_turn <transcript_path> <max_chars>
16
+ # Echoes the sanitized, truncated prior assistant turn, or empty string.
17
+
18
+ # Extract the text portion of a transcript line. The Claude Code session
19
+ # transcript stores assistant messages as `{"type":"assistant","message":{...}}`
20
+ # where `message.content` may be a string or an array of content blocks.
21
+ _compass_transcript_extract_text() {
22
+ local line="$1"
23
+ # Prefer message.content[*].text for the array-of-blocks shape; fall back
24
+ # to message.content when it is already a string. Final fallback: any
25
+ # top-level .content or .text field (covers legacy/alternate writers).
26
+ printf '%s' "$line" | jq -r '
27
+ if (.message? // null) != null then
28
+ if (.message.content | type) == "array" then
29
+ [.message.content[]? | select(type == "object") | (.text // "")]
30
+ | map(select(. != "")) | join("\n")
31
+ elif (.message.content | type) == "string" then
32
+ .message.content
33
+ else "" end
34
+ else
35
+ (.content // .text // "")
36
+ end
37
+ ' 2>/dev/null
60
38
  }
61
39
 
62
- _compass_transcript_from_event_log() {
63
- local session_id="$1"
64
- local max_age_seconds="$2"
65
- local log_path="${ONLOOKER_EVENTS_LOG:-${ONLOOKER_DIR:-$HOME/.onlooker}/logs/onlooker-events.jsonl}"
66
-
67
- [[ -f "$log_path" ]] || return 1
68
- [[ -n "$session_id" && "$session_id" != "unknown" ]] || return 1
69
-
70
- local now
71
- now=$(date +%s 2>/dev/null) || now=0
72
-
73
- local prior_turn=""
74
- local line
75
- while IFS= read -r line; do
76
- [[ -z "$line" ]] && continue
77
-
78
- local sid etype role
79
- sid=$(printf '%s' "$line" | jq -r '.session_id // empty' 2>/dev/null) || continue
80
- [[ "$sid" == "$session_id" ]] || continue
81
-
82
- etype=$(printf '%s' "$line" | jq -r '.event_type // empty' 2>/dev/null) || continue
83
- [[ "$etype" == "session.prompt" ]] || continue
84
-
85
- role=$(printf '%s' "$line" | jq -r '.payload.role // empty' 2>/dev/null) || continue
86
- [[ "$role" == "assistant" ]] || continue
40
+ # Return the role for a transcript line, falling back to .message.role.
41
+ _compass_transcript_role() {
42
+ local line="$1"
43
+ printf '%s' "$line" \
44
+ | jq -r '(.role // .message.role // .type // empty)' 2>/dev/null
45
+ }
87
46
 
88
- if [[ "$max_age_seconds" -gt 0 ]]; then
89
- local ts entry_time
90
- ts=$(printf '%s' "$line" | jq -r '.timestamp // empty' 2>/dev/null) || ts=""
91
- if [[ -n "$ts" ]]; then
92
- entry_time=$(date -d "$ts" +%s 2>/dev/null) \
93
- || entry_time=$(date -jf '%Y-%m-%dT%H:%M:%SZ' "$ts" +%s 2>/dev/null) \
94
- || entry_time=0
95
- local age=$(( now - entry_time ))
96
- [[ "$age" -gt "$max_age_seconds" ]] && continue
97
- fi
98
- fi
47
+ # Walk a JSONL transcript file backwards to find the most recent assistant
48
+ # turn with non-empty text. Avoids loading the entire file into memory by
49
+ # streaming with `tac` when available; falls back to `tail -r` on BSD.
50
+ _compass_transcript_read_from_file() {
51
+ local path="$1"
52
+ [[ -f "$path" ]] || return 1
53
+
54
+ local reverser=""
55
+ if command -v tac >/dev/null 2>&1; then
56
+ reverser="tac"
57
+ elif command -v tail >/dev/null 2>&1 && tail -r </dev/null >/dev/null 2>&1; then
58
+ reverser="tail -r"
59
+ fi
99
60
 
100
- local content
101
- content=$(printf '%s' "$line" | jq -r '.payload.content // .payload.text // empty' 2>/dev/null) || continue
102
- [[ -n "$content" ]] && prior_turn="$content"
103
- done < "$log_path"
61
+ local line role content
62
+ if [[ -n "$reverser" ]]; then
63
+ while IFS= read -r line; do
64
+ [[ -z "$line" ]] && continue
65
+ role=$(_compass_transcript_role "$line") || continue
66
+ [[ "$role" == "assistant" ]] || continue
67
+ content=$(_compass_transcript_extract_text "$line") || continue
68
+ [[ -n "$content" ]] && { printf '%s' "$content"; return 0; }
69
+ done < <(eval "$reverser" "\"$path\"" 2>/dev/null)
70
+ else
71
+ # Final fallback: forward scan, keep the last match.
72
+ local found=""
73
+ while IFS= read -r line; do
74
+ [[ -z "$line" ]] && continue
75
+ role=$(_compass_transcript_role "$line") || continue
76
+ [[ "$role" == "assistant" ]] || continue
77
+ content=$(_compass_transcript_extract_text "$line") || continue
78
+ [[ -n "$content" ]] && found="$content"
79
+ done < "$path"
80
+ [[ -n "$found" ]] && { printf '%s' "$found"; return 0; }
81
+ fi
104
82
 
105
- [[ -n "$prior_turn" ]] || return 1
106
- printf '%s' "$prior_turn"
83
+ return 1
107
84
  }
108
85
 
109
86
  # Read the prior assistant turn.
110
- # $1 — session_id
87
+ # $1 — transcript_path (from hook JSON payload; may be empty)
111
88
  # $2 — max_chars (from config: transcript.prior_turn_chars_max)
112
- # $3 max_age_seconds (from config: transcript.transcript_max_age_seconds)
113
- # Echoes the sanitized, truncated prior assistant turn, or empty string.
89
+ # Echoes the sanitized, truncated prior assistant turn, or the empty string.
114
90
  compass_read_prior_turn() {
115
- local session_id="${1:-unknown}"
91
+ local transcript_path="${1:-}"
116
92
  local max_chars="${2:-800}"
117
- local max_age_seconds="${3:-300}"
118
93
 
119
- local raw=""
120
-
121
- # Source 1: CLAUDE_TRANSCRIPT_PATH
122
- if [[ -n "${CLAUDE_TRANSCRIPT_PATH:-}" ]]; then
123
- raw=$(_compass_transcript_from_path "$CLAUDE_TRANSCRIPT_PATH" "$session_id" "$max_age_seconds") || raw=""
124
- fi
125
-
126
- # Source 2: Onlooker event log
127
- if [[ -z "$raw" ]]; then
128
- raw=$(_compass_transcript_from_event_log "$session_id" "$max_age_seconds") || raw=""
129
- fi
94
+ [[ -z "$transcript_path" ]] && return 0
130
95
 
96
+ local raw=""
97
+ raw=$(_compass_transcript_read_from_file "$transcript_path") || raw=""
131
98
  [[ -z "$raw" ]] && return 0
132
99
 
133
- # Sanitize and truncate — compass-sanitizer.sh must be sourced by the caller.
100
+ # compass-sanitizer.sh is sourced by the caller (hook script).
134
101
  compass_sanitize "$raw" "$max_chars"
135
102
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "inspector",
3
+ "version": "0.1.0",
4
+ "description": "Per-edit lint and typecheck gate. Runs the project's configured checks on just the touched file after every Write / Edit / MultiEdit, so the agent sees its own type errors before claiming success. Cheaper than proctor (which runs project-wide verification at Stop); complements assayer (which catches the agent lying about claims). Builds on the Onlooker ecosystem plugin.",
5
+ "author": {
6
+ "name": "Onlooker Community",
7
+ "url": "https://onlooker.dev"
8
+ },
9
+ "homepage": "https://onlooker.dev",
10
+ "repository": "https://github.com/onlooker-community/ecosystem",
11
+ "license": "MIT",
12
+ "skills": [],
13
+ "agents": []
14
+ }
@@ -0,0 +1,155 @@
1
+ # Inspector
2
+
3
+ Per-edit lint and typecheck gate for the Onlooker ecosystem — runs the project's
4
+ configured checks on **just the touched file** after every `Write`, `Edit`, and
5
+ `MultiEdit`, so the agent sees its own lint and type errors before it claims
6
+ success.
7
+
8
+ Inspector is a sibling plugin to [`ecosystem`](../../) and assumes the Onlooker
9
+ observability substrate (`~/.onlooker/`) is present.
10
+
11
+ ## Why it exists
12
+
13
+ The ecosystem already has plugins that judge agent output after the fact and
14
+ plugins that gate ambiguous writes before they happen. What it didn't have until
15
+ now is a fast feedback loop that runs after every edit and tells the agent
16
+ *whether the code it just wrote actually compiles*. Inspector is that loop.
17
+
18
+ - **Not [proctor]** (planned) — proctor runs the project's full verification
19
+ command at `Stop`. Inspector runs only on touched files, only on `PostToolUse`.
20
+ Cheaper, fires far more often, narrower scope.
21
+ - **Not [assayer]** — assayer parses the agent's final message for testable
22
+ claims and cross-checks them against actual exit codes in the transcript.
23
+ Assayer catches the agent *lying* about claims. Inspector ensures the agent
24
+ has *accurate ground truth* to make claims from. They compose: inspector
25
+ emits real pass/fail signals; assayer can later confirm the agent's claims
26
+ line up with those signals.
27
+ - **Not a build system** — inspector runs the configured command, captures the
28
+ result, emits an event, exits. No cross-file caching, no dependency graphs.
29
+
30
+ ## How it works
31
+
32
+ | Hook | Matcher | What Inspector does |
33
+ |------|---------|---------------------|
34
+ | `PostToolUse` | `Edit`, `Write`, `MultiEdit` | Resolves the touched file from `tool_input.file_path`, looks up the configured checks for the file's extension, runs each check with a per-check timeout, and emits `inspector.check.passed` / `.failed` / `.skipped` plus a single `inspector.run.completed` summary. Bounded by `total_timeout_seconds`. Always exits 0 — inspector is advisory and never blocks the tool call. |
35
+
36
+ The hook's stdout (the additional-context channel for `PostToolUse` hooks) is
37
+ the agent-facing summary. By default it's quiet on clean runs:
38
+
39
+ ```
40
+ inspector: src/cart.ts
41
+ ✗ biome (3 issues, exit 1)
42
+ src/cart.ts:42:5 — Unused variable 'subtotal'
43
+ src/cart.ts:51:3 — Missing return type annotation
44
+ src/cart.ts:64:9 — Unreachable code
45
+ ✗ tsc (1 issue(s), exit 2)
46
+ src/cart.ts:42:5 — Type 'string | undefined' is not assignable to 'string'
47
+ ```
48
+
49
+ Set `inspector.show_clean_runs: true` to surface the file header on passing
50
+ checks too. The agent sees this on its next turn.
51
+
52
+ ## Activation
53
+
54
+ Inspector ships disabled. Opt in per project (or globally) by adding the
55
+ `inspector` block to `.claude/settings.json`:
56
+
57
+ ```jsonc
58
+ {
59
+ "inspector": {
60
+ "enabled": true,
61
+ "checks": {
62
+ ".ts": [{ "name": "biome", "kind": "lint", "argv": ["biome", "check", "${file}"] },
63
+ { "name": "tsc", "kind": "typecheck", "argv": ["tsc", "--noEmit"] }],
64
+ ".tsx": [{ "name": "biome", "kind": "lint", "argv": ["biome", "check", "${file}"] },
65
+ { "name": "tsc", "kind": "typecheck", "argv": ["tsc", "--noEmit"] }],
66
+ ".py": [{ "name": "ruff", "kind": "lint", "argv": ["ruff", "check", "${file}"] }],
67
+ ".sh": [{ "name": "shellcheck", "kind": "lint", "argv": ["shellcheck", "${file}"] }]
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ Each check is an `{ name, kind, argv }` object. `kind` is one of `lint` or
74
+ `typecheck` (used for downstream grouping). `argv` is the literal argv array;
75
+ the following placeholders are expanded before exec:
76
+
77
+ | Placeholder | Resolves to |
78
+ |-------------------|--------------------------------------------|
79
+ | `${file}` | absolute path to the touched file |
80
+ | `${file_relative}`| path relative to the repo root |
81
+ | `${repo_root}` | the repo's `git rev-parse --show-toplevel` |
82
+
83
+ A bare argv array (`["shellcheck", "${file}"]`) is also accepted as a shorthand
84
+ — inspector treats the first entry as the check name and the kind as `lint`.
85
+
86
+ ## Config
87
+
88
+ | Field | Default | Meaning |
89
+ |---|---|---|
90
+ | `enabled` | `false` | Master switch. |
91
+ | `timeout_seconds_per_check` | `10` | Wall-clock cap per check. Exceeded → `inspector.check.skipped` with `reason: "timeout"`. |
92
+ | `total_timeout_seconds` | `30` | Wall-clock cap for the whole run. Remaining checks emit `.skipped` with `reason: "total_budget_exhausted"`. |
93
+ | `output_excerpt_max_bytes` | `4096` | Cap on captured output, both in the event and shown to the agent. Excess is replaced with `…[truncated]`. |
94
+ | `show_clean_runs` | `false` | If `true`, the agent-facing summary includes passing checks too. Off by default to keep token usage low. |
95
+ | `exclude_paths` | `["node_modules", ".git", "vendor", ".venv", "dist", ".next", ".nuxt", "build", "__pycache__", "target", "coverage"]` | Containment match against the file's path relative to the repo root. A match emits `inspector.check.skipped` with `reason: "excluded_path"` and runs no checks. |
96
+ | `checks` | `{}` | Map of file extension (with leading dot) to an array of check definitions. Empty by default — opt in per project. |
97
+
98
+ Config precedence: plugin defaults < `~/.claude/settings.json` (`.inspector`) <
99
+ `<repo>/.claude/settings.json` (`.inspector`). Each layer fully replaces the
100
+ `checks` array for a given extension; per-entry deep-merge is intentionally not
101
+ supported because the override behavior would be unpredictable.
102
+
103
+ ## Events
104
+
105
+ All events are registered in `@onlooker-community/schema`.
106
+
107
+ | Event | When | Notable payload fields |
108
+ |---|---|---|
109
+ | `inspector.check.passed` | A check returned exit 0 | `file_path`, `tool_name`, `check_name`, `check_kind`, `argv`, `duration_ms` |
110
+ | `inspector.check.failed` | A check returned non-zero | `exit_code`, `issue_count` (best-effort, may be `null`), `output_excerpt`, `output_truncated` |
111
+ | `inspector.check.skipped` | A check or whole file was not run | `reason`: one of `disabled`, `excluded_path`, `no_extension_match`, `not_in_repo`, `tool_missing`, `timeout`, `total_budget_exhausted` |
112
+ | `inspector.run.completed` | Once per hook fire after all checks | `checks_run`, `checks_passed`, `checks_failed`, `checks_skipped`, `duration_ms` |
113
+
114
+ Downstream consumers that just want "did this edit produce broken code?" read
115
+ `inspector.run.completed` and check `checks_failed > 0`. Consumers that want
116
+ per-tool detail subscribe to `inspector.check.*`.
117
+
118
+ ## Whole-project checks (tsc, mypy, …)
119
+
120
+ TypeScript's typecheck is project-scoped: `tsc --noEmit` checks every file, not
121
+ just the touched one. There is no meaningful "tsc on one file." The supported
122
+ pattern is to run the full project tsc and rely on its incremental cache
123
+ (`tsBuildInfoFile`) to keep latency down. Same applies to mypy, cargo check,
124
+ and golangci-lint.
125
+
126
+ The downside is that `tsc --noEmit` reports errors in *every* file. Inspector's
127
+ v1 surfaces all of them to the agent. A follow-up will add an opt-in filter
128
+ that shows only errors mentioning the touched file plus a collateral-error
129
+ count.
130
+
131
+ ## Failure modes
132
+
133
+ Inspector is advisory — it never blocks the tool call. Specifically:
134
+
135
+ - Missing tool on PATH → `.skipped` event, no agent-facing output
136
+ - Timeout → `.skipped` event, one-line note to the agent
137
+ - Hook script error → exit 0 with no event (last-resort path)
138
+ - Schema validation error → logged to stderr, hook continues
139
+
140
+ ## Compatibility
141
+
142
+ - bash 3.2+ (the macOS system bash is supported; no `mapfile` / `readarray` /
143
+ associative arrays)
144
+ - `jq` is required (already a hard requirement for the ecosystem substrate)
145
+ - `timeout` from coreutils is used when present; falls back to no-timeout mode
146
+ when absent (and emits a warning to the inspector hook log)
147
+
148
+ ## Design
149
+
150
+ See [`docs/design.md`](docs/design.md) for the full design record, including
151
+ the rationale for project-wide check semantics, output filtering, and open
152
+ questions.
153
+
154
+ [proctor]: https://github.com/onlooker-community/ecosystem#planned
155
+ [assayer]: ../assayer
@@ -0,0 +1,25 @@
1
+ {
2
+ "plugin_name": "inspector",
3
+ "storage_path": "~/.onlooker",
4
+ "inspector": {
5
+ "enabled": false,
6
+ "timeout_seconds_per_check": 10,
7
+ "total_timeout_seconds": 30,
8
+ "output_excerpt_max_bytes": 4096,
9
+ "show_clean_runs": false,
10
+ "exclude_paths": [
11
+ "node_modules",
12
+ ".git",
13
+ "vendor",
14
+ ".venv",
15
+ "dist",
16
+ ".next",
17
+ ".nuxt",
18
+ "build",
19
+ "__pycache__",
20
+ "target",
21
+ "coverage"
22
+ ],
23
+ "checks": {}
24
+ }
25
+ }