@onlooker-community/ecosystem 0.43.5 → 0.43.6

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.43.5",
3
+ "version": "0.43.6",
4
4
  "description": "Observability substrate for Claude Code. Provides the shared $ONLOOKER_DIR storage root (default $HOME/.onlooker), canonical schema-validated event emission, session and tool tracking hooks, and prompt rules. Required by all other Onlooker plugins.",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,5 @@
1
1
  {
2
- ".": "0.43.5",
2
+ ".": "0.43.6",
3
3
  "plugins/archivist": "0.3.2",
4
4
  "plugins/tribunal": "1.2.8",
5
5
  "plugins/echo": "0.3.2",
@@ -15,5 +15,5 @@
15
15
  "plugins/assayer": "1.1.4",
16
16
  "plugins/bursar": "0.3.1",
17
17
  "plugins/lineage": "0.2.3",
18
- "plugins/inspector": "0.3.3"
18
+ "plugins/inspector": "0.3.4"
19
19
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.43.6](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.43.5...ecosystem-v0.43.6) (2026-08-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **events:** drop safe_emit's hand-built envelope fallback :broom: ([#202](https://github.com/onlooker-community/ecosystem/issues/202)) ([bad684a](https://github.com/onlooker-community/ecosystem/commit/bad684a4f7c535d72f91ed2b1d4928a4dd9a10e8))
9
+ * **inspector:** canonicalize the file and repo root the same way :mag: ([0f850e9](https://github.com/onlooker-community/ecosystem/commit/0f850e92d7e620e4d7e06f512ef81efe4255f316))
10
+
3
11
  ## [0.43.5](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.43.4...ecosystem-v0.43.5) (2026-08-22)
4
12
 
5
13
 
@@ -93,9 +93,9 @@ Every observable event flows through `onlooker-event.mjs` before being written t
93
93
 
94
94
  The schema is versioned independently and published to npm. Plugin shell scripts invoke `onlooker-event.mjs` at runtime so schema validation always reflects the installed version.
95
95
 
96
- > **Note:** Every emission path that fires in normal operation routes through `onlooker-event.mjs`. `prompt_rule.*` was the last routine exception `prompt_rules_emit` hand-built its envelope with `jq` and wrote straight to `$ONLOOKER_EVENTS_LOG`, so those lines carried none of the required `id`/`schema_version`/`runtime`/`machine_id`/`sequence` fields and added a `turn` field the envelope forbids. It now goes through the emitter like everything else.
96
+ > **Note:** Every emission path in this repo routes through `onlooker-event.mjs`. Nothing hand-builds an envelope and appends it to the log. Two paths used to: `prompt_rules_emit` (ecosystem-aaz) and the `safe_emit` fallback in `scripts/lib/validate-path.sh` (ecosystem-0tm). Both wrote lines missing all five required `id`/`schema_version`/`runtime`/`machine_id`/`sequence` fields, plus fields the envelope forbids `event.v1.json` is `additionalProperties: false`.
97
97
  >
98
- > One hand-built envelope remains, in the `safe_emit` fallback in `scripts/lib/validate-path.sh`. It is reached only when `$ONLOOKER_EMIT` is missing, which does not happen in a normal checkout or install, so it is latent rather than live but if it ever fires it writes the same unvalidatable shape. Route new event types through the emitter rather than writing to the log directly: an envelope assembled anywhere else will drift from the schema without anything noticing.
98
+ > Route a new event type the same way rather than writing to the log directly. The shape to copy is a `{plugin, session_id, event_type, payload}` params object piped to `onlooker-event.mjs emit`, whose output you append; the emitter owns envelope assembly so there is exactly one place for it to drift. When the emitter is unavailable, emit nothing and return non-zero an unparseable line on the bus is worse than a missing one.
99
99
 
100
100
  ### Emission gates
101
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.43.5",
3
+ "version": "0.43.6",
4
4
  "description": "Agents, skills, hooks, commands, rules, and MCP configurations that power [Onlooker](https://onlooker.dev)",
5
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inspector",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
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
5
  "author": {
6
6
  "name": "Onlooker Community",
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.4](https://github.com/onlooker-community/ecosystem/compare/inspector-v0.3.3...inspector-v0.3.4) (2026-08-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **inspector:** canonicalize the file and repo root the same way :mag: ([0f850e9](https://github.com/onlooker-community/ecosystem/commit/0f850e92d7e620e4d7e06f512ef81efe4255f316))
9
+
3
10
  ## [0.3.3](https://github.com/onlooker-community/ecosystem/compare/inspector-v0.3.2...inspector-v0.3.3) (2026-08-22)
4
11
 
5
12
 
@@ -40,14 +40,10 @@ TOOL_TARGET=$(printf '%s' "$HOOK_INPUT" \
40
40
  | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null)
41
41
  [[ -z "$TOOL_TARGET" ]] && exit 0
42
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
43
+ # Canonicalize through the same helper the repo root uses. The containment
44
+ # check below is a prefix match, so both sides have to be resolved the same
45
+ # way see inspector_canonical_path and ecosystem-foi.
46
+ CANONICAL=$(inspector_canonical_path "$TOOL_TARGET")
51
47
  export INSPECTOR_FILE="$CANONICAL"
52
48
 
53
49
  REPO_ROOT=$(inspector_project_repo_root "$CWD")
@@ -23,11 +23,68 @@ _inspector_sha256_first12() {
23
23
  fi
24
24
  }
25
25
 
26
+ # Resolve a path to its physical form — symlinks expanded, no trailing slash.
27
+ #
28
+ # The hook decides whether a touched file lives inside the repo by prefix-
29
+ # matching one canonicalized path against the other. Both sides MUST come
30
+ # through this one function. They used to be canonicalized independently
31
+ # (realpath for the file, `git rev-parse --show-toplevel` for the root), each
32
+ # with its own fallback, and on macOS /var is a symlink to /private/var — so
33
+ # whenever either mechanism fell back, the two sides landed in different
34
+ # namespaces and the prefix match failed. The hook then reported not_in_repo
35
+ # for a file plainly inside the repo, exiting 0 with no output. See
36
+ # ecosystem-foi, where that surfaced as an intermittent test failure.
37
+ #
38
+ # The last resort is `cd` + `pwd -P`, a bash builtin that needs no external
39
+ # tool, so this still returns a physical path when neither realpath nor a
40
+ # GNU-style `readlink -f` is available.
41
+ #
42
+ # Usage: canonical=$(inspector_canonical_path "$some_path")
43
+ inspector_canonical_path() {
44
+ local p="${1:-}"
45
+ [[ -z "$p" ]] && return 0
46
+
47
+ local out
48
+ if out=$(realpath "$p" 2>/dev/null) && [[ -n "$out" ]]; then
49
+ printf '%s' "$out"
50
+ return 0
51
+ fi
52
+ # BSD readlink has no -f, so this silently no-ops there and falls through.
53
+ if out=$(readlink -f "$p" 2>/dev/null) && [[ -n "$out" ]]; then
54
+ printf '%s' "$out"
55
+ return 0
56
+ fi
57
+
58
+ if [[ -d "$p" ]]; then
59
+ out=$(cd "$p" 2>/dev/null && pwd -P) && [[ -n "$out" ]] && {
60
+ printf '%s' "$out"
61
+ return 0
62
+ }
63
+ else
64
+ local dir base
65
+ dir=$(dirname "$p")
66
+ base=$(basename "$p")
67
+ out=$(cd "$dir" 2>/dev/null && pwd -P) && [[ -n "$out" ]] && {
68
+ printf '%s/%s' "$out" "$base"
69
+ return 0
70
+ }
71
+ fi
72
+
73
+ # Nothing resolved it — hand back the input rather than an empty string,
74
+ # so a caller comparing two of these still compares like with like.
75
+ printf '%s' "$p"
76
+ }
77
+
26
78
  inspector_project_repo_root() {
27
79
  local cwd="${1:-$(pwd)}"
28
80
  local root
29
- root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) && printf '%s' "$root" && return 0
30
- printf '%s' "$cwd"
81
+ # Canonicalize BOTH the git answer and the fallback. git already returns a
82
+ # physical path, so the first call is a no-op; the fallback is the caller's
83
+ # raw cwd, which is exactly the case that used to diverge.
84
+ root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) \
85
+ && [[ -n "$root" ]] \
86
+ && { inspector_canonical_path "$root"; return 0; }
87
+ inspector_canonical_path "$cwd"
31
88
  }
32
89
 
33
90
  inspector_project_remote_url() {
@@ -530,11 +530,32 @@ turn_state_next_tool() {
530
530
  fi
531
531
  }
532
532
 
533
- # Safely emit dev-os event (validates emit script exists)
534
- # Automatically exports turn state if not already set, so every emission
535
- # gets hook_type/turn/tool_call_seq in the envelope without callers needing
536
- # to call turn_state_export() explicitly.
537
- # Usage: echo "$INPUT" | safe_emit "event_type" '{"key":"value"}'
533
+ # Safely emit an Onlooker event through the canonical emitter.
534
+ #
535
+ # Automatically exports turn state if not already set, so a caller does not have
536
+ # to call turn_state_export() itself. Note the turn rides in the PAYLOAD, as
537
+ # turn_number, for the event types whose schema declares it — the envelope has
538
+ # no turn field and is additionalProperties:false.
539
+ #
540
+ # Returns non-zero and writes NOTHING when $ONLOOKER_EMIT is unavailable.
541
+ #
542
+ # There used to be a fallback here that hand-built an envelope with jq and
543
+ # appended it directly. Every line it wrote was unvalidatable: it omitted all
544
+ # five required id/schema_version/runtime/machine_id/sequence fields, and added
545
+ # four the envelope forbids outright (hook_type, tool_name, turn,
546
+ # tool_call_seq). It was reached only when $ONLOOKER_EMIT was missing — a file
547
+ # that ships next to this one, so it never fired in a normal checkout or
548
+ # install — which is how it stayed broken and unnoticed. See ecosystem-0tm.
549
+ #
550
+ # Dropping it rather than teaching it to call the emitter is the same
551
+ # fail-closed choice made for prompt_rules_emit in ecosystem-aaz: an
552
+ # unparseable line on the bus is worse than a missing one, and a second way to
553
+ # build an envelope is a second thing to drift.
554
+ #
555
+ # Callers must tolerate a non-zero return — hooks run under `set -e`, so use
556
+ # `safe_emit ... || true` when the emission is advisory.
557
+ #
558
+ # Usage: safe_emit "event_type" '{"key":"value"}'
538
559
  safe_emit() {
539
560
  local event_type="$1"
540
561
  local payload="$2"
@@ -544,38 +565,8 @@ safe_emit() {
544
565
  turn_state_export "$_HOOK_SESSION_ID"
545
566
  fi
546
567
 
547
- if validate_file_exists "$ONLOOKER_EMIT"; then
548
- "$ONLOOKER_EMIT" "$event_type" "$payload"
549
- else
550
- # Fallback: write directly to events log with envelope enrichment.
551
- # Uses env vars set by hook_set_context() — NOT stdin (already consumed).
552
- ensure_file_exists "$ONLOOKER_EVENTS_LOG" || return 1
553
- local timestamp session_id plugin_name hook_type tool_name turn tool_seq
554
- timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
555
- session_id="${_HOOK_SESSION_ID:-}"
556
- if [[ -z "$session_id" ]]; then
557
- session_id=$(echo "$payload" | jq -r '.session_id // "unknown"' 2>/dev/null) || session_id="unknown"
558
- fi
559
- plugin_name="${ONLOOKER_PLUGIN_NAME:-unknown}"
560
- hook_type="${ONLOOKER_HOOK_TYPE:-}"
561
- tool_name="${ONLOOKER_TOOL_NAME:-}"
562
- turn="${ONLOOKER_TURN_NUMBER:-}"
563
- tool_seq="${ONLOOKER_TURN_TOOL_SEQ:-}"
564
- jq -cn \
565
- --arg ts "$timestamp" \
566
- --arg sid "$session_id" \
567
- --arg plugin "$plugin_name" \
568
- --arg type "$event_type" \
569
- --arg hook_type "$hook_type" \
570
- --arg tool_name "$tool_name" \
571
- --arg turn "$turn" \
572
- --arg tool_seq "$tool_seq" \
573
- --argjson payload "$payload" \
574
- '{timestamp: $ts, session_id: $sid, plugin: $plugin, event_type: $type, payload: $payload}
575
- + (if $hook_type != "" then {hook_type: $hook_type} else {} end)
576
- + (if $tool_name != "" then {tool_name: $tool_name} else {} end)
577
- + (if $turn != "" then {turn: ($turn | tonumber)} else {} end)
578
- + (if $tool_seq != "" then {tool_call_seq: ($tool_seq | tonumber)} else {} end)
579
- ' >> "$ONLOOKER_EVENTS_LOG"
568
+ if ! validate_file_exists "$ONLOOKER_EMIT"; then
569
+ return 1
580
570
  fi
571
+ "$ONLOOKER_EMIT" "$event_type" "$payload"
581
572
  }
@@ -155,3 +155,95 @@ EOF
155
155
  # The last argv slot should now hold the resolved touched file path.
156
156
  [[ "$(echo "$argv" | jq -r '.[-1]')" == *"src/sample.ts" ]]
157
157
  }
158
+
159
+ # ── path canonicalization (ecosystem-foi) ────────────────────────────────────
160
+ #
161
+ # The hook decides "is this file inside the repo?" by prefix-matching the
162
+ # canonicalized file path against the repo root. Those two paths used to be
163
+ # canonicalized by INDEPENDENT mechanisms — realpath for the file, `git
164
+ # rev-parse --show-toplevel` for the root — each with its own fallback. On
165
+ # macOS /var is a symlink to /private/var, so whenever either mechanism fell
166
+ # back, the two sides landed in different namespaces, the prefix match failed,
167
+ # and the hook reported not_in_repo for a file plainly inside the repo: exit 0,
168
+ # no agent output, one misleading .skipped event.
169
+ #
170
+ # That is the exact signature of the intermittent failure in ecosystem-foi,
171
+ # where the hook exited 0 but emitted no file header. These tests force each
172
+ # fallback deterministically instead of waiting for the flake.
173
+
174
+ _stub_dir() {
175
+ local d="${BATS_TEST_TMPDIR}/stub-$1"
176
+ mkdir -p "$d"
177
+ printf '%s' "$d"
178
+ }
179
+
180
+ _failing_check_settings() {
181
+ cat <<'EOF' | _settings
182
+ {"inspector":{"checks":{".ts":[
183
+ {"name":"broken","kind":"lint","argv":["sh","-c","echo 'src/sample.ts:1:1 - Bad'; exit 2"]}
184
+ ]}}}
185
+ EOF
186
+ }
187
+
188
+ @test "the repo-root fallback still finds a file inside the repo when git fails" {
189
+ # git rev-parse failing sends inspector_project_repo_root to its $cwd
190
+ # fallback, which is the caller-supplied (unresolved) path.
191
+ _failing_check_settings
192
+ local stub
193
+ stub=$(_stub_dir git)
194
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/git"
195
+ chmod +x "${stub}/git"
196
+
197
+ run bash -c "printf '%s' '$(_input)' | PATH='${stub}:$PATH' ONLOOKER_DIR='$ONLOOKER_DIR' CLAUDE_PLUGIN_ROOT='$PLUGIN_ROOT' bash '$HOOK'"
198
+ [ "$status" -eq 0 ] || return 1
199
+ [[ "$output" == *"inspector: src/sample.ts"* ]] || return 1
200
+ [ "$(_event_count inspector.check.failed)" = "1" ]
201
+ }
202
+
203
+ @test "the file is still found inside the repo when realpath is unavailable" {
204
+ # realpath failing leaves the file path unresolved while the repo root,
205
+ # coming from git, is resolved — the same mismatch from the other side.
206
+ _failing_check_settings
207
+ local stub
208
+ stub=$(_stub_dir realpath)
209
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/realpath"
210
+ chmod +x "${stub}/realpath"
211
+
212
+ run bash -c "printf '%s' '$(_input)' | PATH='${stub}:$PATH' ONLOOKER_DIR='$ONLOOKER_DIR' CLAUDE_PLUGIN_ROOT='$PLUGIN_ROOT' bash '$HOOK'"
213
+ [ "$status" -eq 0 ] || return 1
214
+ [[ "$output" == *"inspector: src/sample.ts"* ]] || return 1
215
+ [ "$(_event_count inspector.check.failed)" = "1" ]
216
+ }
217
+
218
+ @test "a file genuinely outside the repo still reports not_in_repo" {
219
+ # The containment check must still say no when the answer really is no —
220
+ # canonicalizing both sides must not turn it into a rubber stamp.
221
+ _failing_check_settings
222
+ local outside="${BATS_TEST_TMPDIR}/outside.ts"
223
+ printf 'x\n' >"$outside"
224
+
225
+ run _run_hook "$(_input "$REPO" "Edit" "$outside")"
226
+ [ "$status" -eq 0 ] || return 1
227
+ [ -z "$output" ] || return 1
228
+ [ "$(jq -r 'select(.event_type=="inspector.check.skipped").payload.reason' "$ONLOOKER_EVENTS_LOG")" = "not_in_repo" ]
229
+ }
230
+
231
+ @test "canonicalization survives with neither realpath nor readlink -f" {
232
+ # The last resort is `cd` + `pwd -P`, a builtin. Without it, a box with no
233
+ # GNU coreutils would hit the same namespace mismatch through a third door,
234
+ # and no other test forces this branch.
235
+ _failing_check_settings
236
+ local stub
237
+ stub=$(_stub_dir noresolvers)
238
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/realpath"
239
+ printf '#!/bin/sh\nexit 1\n' >"${stub}/readlink"
240
+ chmod +x "${stub}/realpath" "${stub}/readlink"
241
+
242
+ run bash -c "printf '%s' '$(_input)' | PATH='${stub}:$PATH' ONLOOKER_DIR='$ONLOOKER_DIR' CLAUDE_PLUGIN_ROOT='$PLUGIN_ROOT' bash '$HOOK'"
243
+ [ "$status" -eq 0 ] || return 1
244
+ [[ "$output" == *"inspector: src/sample.ts"* ]] || return 1
245
+ # file_path_relative must still be repo-relative, not an absolute path —
246
+ # that is what proves the prefix strip found the root, rather than the
247
+ # header printing off a coincidentally-passing comparison.
248
+ [ "$(jq -r 'select(.event_type=="inspector.check.failed").payload.file_path_relative' "$ONLOOKER_EVENTS_LOG")" = "src/sample.ts" ]
249
+ }
@@ -108,6 +108,52 @@ setup() {
108
108
  >/dev/null
109
109
  }
110
110
 
111
+ @test "safe_emit's line validates as a canonical envelope, not just a lookalike" {
112
+ # The assertion above spot-checks four fields. A hand-built envelope can pass
113
+ # that while still being rejected by the schema, which is exactly how the
114
+ # fallback below stayed broken. Validate the whole line instead.
115
+ export _HOOK_SESSION_ID="emit-session"
116
+ safe_emit "tool.file.read" '{"path":"/tmp/example.txt","read_mode":"full"}'
117
+ tail -n 1 "$ONLOOKER_EVENTS_LOG" \
118
+ | ONLOOKER_DIR="$ONLOOKER_DIR" node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
119
+ }
120
+
121
+ @test "safe_emit writes nothing when the emit script is missing" {
122
+ # The degraded path. It used to hand-build an envelope with jq that omitted
123
+ # every required id/schema_version/runtime/machine_id/sequence field and added
124
+ # four the schema forbids (hook_type, tool_name, turn, tool_call_seq), on an
125
+ # envelope that is additionalProperties:false. An unparseable line on the bus
126
+ # is worse than a missing one, so this path now writes nothing at all.
127
+ # See ecosystem-0tm.
128
+ export _HOOK_SESSION_ID="emit-session"
129
+ export ONLOOKER_HOOK_TYPE="PreToolUse"
130
+ export ONLOOKER_TOOL_NAME="Read"
131
+ export ONLOOKER_TURN_NUMBER=4
132
+ export ONLOOKER_TURN_TOOL_SEQ=2
133
+ export ONLOOKER_EMIT="${BATS_TEST_TMPDIR}/no-such-emit.sh"
134
+
135
+ : >"$ONLOOKER_EVENTS_LOG"
136
+ run safe_emit "tool.file.read" '{"path":"/tmp/example.txt","read_mode":"full"}'
137
+ [ "$status" -ne 0 ] || return 1
138
+ [ ! -s "$ONLOOKER_EVENTS_LOG" ]
139
+ }
140
+
141
+ @test "safe_emit's failure to emit never aborts the calling hook" {
142
+ # safe_emit is substrate: returning non-zero must be absorbable by a caller
143
+ # running under `set -e`, the shape every hook uses. Assert the caller
144
+ # survives and reaches its next statement.
145
+ export ONLOOKER_EMIT="${BATS_TEST_TMPDIR}/no-such-emit.sh"
146
+ run bash -c "
147
+ set -euo pipefail
148
+ source '${REPO_ROOT}/scripts/lib/validate-path.sh'
149
+ export ONLOOKER_EMIT='${BATS_TEST_TMPDIR}/no-such-emit.sh'
150
+ safe_emit 'tool.file.read' '{\"path\":\"/x\",\"read_mode\":\"full\"}' || true
151
+ echo REACHED
152
+ "
153
+ [ "$status" -eq 0 ] || return 1
154
+ [[ "$output" == *"REACHED"* ]]
155
+ }
156
+
111
157
  # ----------------------------------------------------------------------------
112
158
  # Hook health instrumentation: hook_register / hook_success / hook_failure
113
159
  # ----------------------------------------------------------------------------