@onlooker-community/ecosystem 0.29.1 → 0.29.3

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.
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: writing-tests
3
+ description: How to write tests in the Onlooker ecosystem repo — bats integration tests for hooks and the node:test schema suite. Use when adding or changing anything under test/, when writing a new hook or plugin that needs tests, or when deciding which shared helper to reach for instead of hand-rolling setup, dates, CLI stubs, project keys, or event-log assertions. Steers toward test/helpers/setup.bash and each plugin's own libs so tests stay isolated and never become time bombs.
4
+ ---
5
+
6
+ # Writing tests
7
+
8
+ This repo has two test suites. Use the right one for what you're testing, and lean on the shared helpers below — most fragility in this codebase has come from tests hand-rolling things a helper already does.
9
+
10
+ - **bats** (`test/bats/*.bats`) — bash hooks and end-to-end plugin behavior. This is where almost all tests live.
11
+ - **node:test** (`test/node/*.test.mjs`) — schema mapping, manifest, and reference validation. Pure data-in/data-out, no hooks.
12
+
13
+ ## Run the suite
14
+
15
+ ```bash
16
+ npm test # bats + node schema suite (what most changes need)
17
+ npm run test:bats # just bats (bats test/bats)
18
+ npm run test:schema # just node (node --test test/node/*.test.mjs)
19
+ npm run test:ci # shellcheck + bats + schema + biome + manifest/reference lint
20
+ ```
21
+
22
+ Run a single bats file while iterating: `bats test/bats/<name>.bats`. `bats` comes from mise (it is on `PATH`), not `npx`.
23
+
24
+ ## Golden rules
25
+
26
+ 1. Every bats test starts by isolating the filesystem — source `setup.bash` and call `setup_test_env`. Never touch the real `$HOME` or `~/.onlooker`.
27
+ 2. Always reference `$ONLOOKER_DIR` / `$ONLOOKER_EVENTS_LOG`, never a literal `~/.onlooker`.
28
+ 3. Never hardcode a date that feeds a relative window. Use `relative_iso_days_ago`.
29
+ 4. Resolve project keys, emit events, and generate ULIDs through the plugin's own libs — don't reimplement them.
30
+ 5. Assert behavior through the **event log** and on-disk artifacts, the same surfaces real consumers read.
31
+
32
+ ## bats tests
33
+
34
+ ### Isolate the environment first
35
+
36
+ Every test file's `setup()` sources the shared helper and calls `setup_test_env`, which repoints `HOME`, `ONLOOKER_DIR`, `CLAUDE_HOME`, and `CLAUDE_PLUGIN_ROOT` into `$BATS_TEST_TMPDIR` and severs git from your global config:
37
+
38
+ ```bash
39
+ setup() {
40
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
41
+ setup_test_env
42
+
43
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/<plugin>"
44
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
45
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
46
+ }
47
+ ```
48
+
49
+ If your hook needs the resolved Onlooker paths (`$ONLOOKER_EVENTS_LOG`, the session/metrics dirs) materialized, call `load_validate_path` instead — it runs `setup_test_env`, sources `scripts/lib/validate-path.sh`, and `mkdir -p`s the standard directories.
50
+
51
+ `setup_test_env` exports (read `test/helpers/setup.bash` for the authoritative list):
52
+
53
+ | Variable | Points at |
54
+ |----------|-----------|
55
+ | `HOME` | `$BATS_TEST_TMPDIR/home` (isolated) |
56
+ | `ONLOOKER_DIR` | `$HOME/.onlooker` |
57
+ | `ONLOOKER_EVENTS_LOG` | `$ONLOOKER_DIR/logs/onlooker-events.jsonl` (after `validate-path.sh` is sourced) |
58
+ | `CLAUDE_HOME` | `$HOME/.claude` |
59
+ | `REPO_ROOT` | the repo root (set when `setup.bash` is sourced) |
60
+
61
+ ### Dates: use `relative_iso_days_ago`, never a literal
62
+
63
+ A hardcoded ISO date is a **time bomb** when the code under test computes a window from "now" (for example librarian's "now − `bootstrap_lookback_days`" scan window). The fixture passes today and silently fails once wall-clock now drifts past the threshold. Date such fixtures relative to now:
64
+
65
+ ```bash
66
+ # yesterday, UTC — comfortably inside a 14-day lookback window
67
+ created_at=$(relative_iso_days_ago 1)
68
+ ```
69
+
70
+ `relative_iso_days_ago N` lives in `test/helpers/setup.bash` and returns an ISO-8601 UTC timestamp N days in the past (0 = now, negative = future). It uses `python3` for portable date math, since `date -d` (GNU) and `date -v` (BSD/macOS) disagree. For a plain "now" timestamp, `date -u +%Y-%m-%dT%H:%M:%SZ` is fine and portable; only the *offset* math needs the helper.
71
+
72
+ ### Resolve project keys through the plugin lib
73
+
74
+ Artifacts are partitioned by a project key. Don't recompute the SHA — source the plugin's `*-project-key.sh` and call its function (`<plugin>_project_key <repo-root>`):
75
+
76
+ ```bash
77
+ source "${PLUGIN_ROOT}/scripts/lib/librarian-project-key.sh"
78
+ PROJECT_KEY=$(librarian_project_key "$PROJECT_REPO")
79
+ ARTIFACT_DIR="${ONLOOKER_DIR}/<plugin>/${PROJECT_KEY}"
80
+ ```
81
+
82
+ For key resolution to succeed the test needs a git context — stand up a throwaway repo in `setup()`:
83
+
84
+ ```bash
85
+ PROJECT_REPO="${BATS_TEST_TMPDIR}/repo"
86
+ mkdir -p "$PROJECT_REPO"
87
+ git -C "$PROJECT_REPO" init -q
88
+ git -C "$PROJECT_REPO" config user.email t@example.com
89
+ git -C "$PROJECT_REPO" config user.name "Test"
90
+ git -C "$PROJECT_REPO" remote add origin git@github.com:org/fixture.git
91
+ ```
92
+
93
+ ### Stub the `claude` CLI on `PATH`
94
+
95
+ Hooks that classify or judge shell out to `claude`. Stub it deterministically — branch on the prompt so each case returns a known response — and prepend the stub dir to `PATH`:
96
+
97
+ ```bash
98
+ STUB_BIN="${BATS_TEST_TMPDIR}/bin"
99
+ mkdir -p "$STUB_BIN"
100
+ cat > "${STUB_BIN}/claude" <<'STUB'
101
+ #!/usr/bin/env bash
102
+ prompt=$(cat)
103
+ if [[ "$prompt" == *"some-marker"* ]]; then
104
+ printf '%s' '{"type":"feedback","title":"...","body":"...","confidence":0.84}'
105
+ else
106
+ printf '%s' '{"type":null,"title":"","body":"","confidence":0.2}'
107
+ fi
108
+ STUB
109
+ chmod +x "${STUB_BIN}/claude"
110
+ export PATH="${STUB_BIN}:${PATH}"
111
+ ```
112
+
113
+ Same pattern stubs any external CLI a hook depends on (`curl`, `git`, …). Keep the responses minimal but schema-valid.
114
+
115
+ ### Build hook input with `jq`
116
+
117
+ Hooks read a JSON payload on stdin. Build it with `jq -cn` and feed it in — don't hand-concatenate JSON strings:
118
+
119
+ ```bash
120
+ _hook_input() {
121
+ jq -cn --arg cwd "$PROJECT_REPO" --arg sid "sess-test" \
122
+ '{cwd: $cwd, session_id: $sid, hook_event_name: "SessionEnd"}'
123
+ }
124
+
125
+ run bash -c "printf '%s' '$(_hook_input)' | '$HOOK'"
126
+ [ "$status" -eq 0 ] # hooks must always exit 0 — they never block the session
127
+ ```
128
+
129
+ ### Assert against the event log
130
+
131
+ Plugins communicate through the canonical JSONL event bus, so that's where you assert. Grep for the event type, then check the payload with `jq -e`:
132
+
133
+ ```bash
134
+ grep '"event_type":"<plugin>.scan.complete"' "$ONLOOKER_EVENTS_LOG" \
135
+ | jq -e '.payload.outcome == "ok" and .payload.candidates_proposed == 2' >/dev/null
136
+ ```
137
+
138
+ To assert an emitted line is a **schema-valid** envelope (not just present), pipe it through the canonical validator rather than eyeballing fields:
139
+
140
+ ```bash
141
+ tail -n 1 "$ONLOOKER_EVENTS_LOG" \
142
+ | ONLOOKER_DIR="$ONLOOKER_DIR" node "${REPO_ROOT}/scripts/lib/onlooker-event.mjs" validate >/dev/null
143
+ ```
144
+
145
+ Production code must emit only through `scripts/lib/onlooker-event.mjs` (often via a plugin wrapper like `librarian_emit` / `assayer_emit_event`) — never by writing the log directly. New event types must be registered in `@onlooker-community/schema` before they validate.
146
+
147
+ ### Seed fixtures and generate IDs
148
+
149
+ Write fixtures with `jq -n` into the plugin's project-keyed directory, and generate IDs with the plugin's `*-ulid.sh` (ULIDs, not UUIDs — a repo-wide convention). A typical artifact seeder:
150
+
151
+ ```bash
152
+ _seed_artifact() {
153
+ local kind="$1" id="$2" summary="$3" detail="$4" created_at="${5:-$(relative_iso_days_ago 1)}"
154
+ mkdir -p "${ARTIFACT_DIR}/${kind}"
155
+ jq -n --arg id "$id" --arg summary "$summary" --arg detail "$detail" \
156
+ --arg created_at "$created_at" \
157
+ '{ id: $id, created_at: $created_at, updated_at: $created_at,
158
+ summary: $summary, detail: $detail }' \
159
+ > "${ARTIFACT_DIR}/${kind}/${id}.json"
160
+ }
161
+ ```
162
+
163
+ ## Anti-patterns
164
+
165
+ Don't hand-roll these — each has bitten the suite before:
166
+
167
+ - **Hardcoded dates in window-sensitive fixtures.** Use `relative_iso_days_ago`. A literal like `2026-06-01T12:00:00Z` aged out of librarian's lookback window and broke two tests the day wall-clock now crossed it.
168
+ - **Literal `~/.onlooker` or `$HOME`.** Always `$ONLOOKER_DIR` and the `validate-path.sh` exports, so the isolated temp home is honored.
169
+ - **Reimplementing the project key, ULID, or event envelope.** Source the plugin lib (`*-project-key.sh`, `*-ulid.sh`) and emit through `onlooker-event.mjs`.
170
+ - **Concatenating JSON by hand.** Build payloads and hook input with `jq -n` / `jq -cn --arg`.
171
+ - **Asserting on log lines with `grep` substring matches alone** when you mean "valid event" — validate the envelope with `onlooker-event.mjs validate`.
172
+ - **Expecting a hook to exit non-zero.** Hooks fail soft and exit 0; assert on emitted events or absent side effects, not exit codes.
173
+
174
+ ## Node tests
175
+
176
+ `test/node/*.test.mjs` use the built-in `node:test` runner and `node:assert/strict` — no external framework. They cover schema mapping (`schema-events.test.mjs`), manifest validation (`check-manifests.test.mjs`), and cross-references (`check-references.test.mjs`), driven by fixtures under `test/fixtures/`.
177
+
178
+ ```javascript
179
+ import test from 'node:test';
180
+ import assert from 'node:assert/strict';
181
+
182
+ test('maps PostToolUse Read to tool.file.read', () => {
183
+ const mapped = mapHookInputToCanonical(loadFixture('post-tool-use-read.json'), { plugin: 'onlooker' });
184
+ assert.equal(mapped.valid, true);
185
+ assert.equal(mapped.event.event_type, 'tool.file.read');
186
+ });
187
+ ```
188
+
189
+ Prefer adding a fixture under `test/fixtures/` and asserting the mapping over inlining large JSON literals. Run with `npm run test:schema`.
190
+
191
+ ## Adding tests for a new plugin
192
+
193
+ 1. Create `test/bats/<plugin>-<surface>.bats` (e.g. `<plugin>-session-end.bats`).
194
+ 2. `setup()`: source `setup.bash`, call `setup_test_env`, set `PLUGIN_ROOT` / `CLAUDE_PLUGIN_ROOT`, stand up a fixture git repo, resolve the project key via the plugin lib.
195
+ 3. Stub `claude` (and any other CLI) on `PATH` if the hook shells out.
196
+ 4. Drive the hook with `jq`-built input; assert on `$ONLOOKER_EVENTS_LOG` and on-disk artifacts.
197
+ 5. Date any window-sensitive fixture with `relative_iso_days_ago`.
198
+ 6. Run `npm run test:ci` before opening the PR — it adds shellcheck and the manifest/reference linters on top of the tests.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ecosystem",
3
- "version": "0.29.1",
3
+ "version": "0.29.3",
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.29.1",
2
+ ".": "0.29.3",
3
3
  "plugins/archivist": "0.1.0",
4
4
  "plugins/tribunal": "1.0.1",
5
5
  "plugins/echo": "0.2.0",
@@ -13,7 +13,7 @@
13
13
  "plugins/curator": "0.1.0",
14
14
  "plugins/historian": "0.2.0",
15
15
  "plugins/assayer": "1.0.0",
16
- "plugins/bursar": "0.1.0",
16
+ "plugins/bursar": "0.1.1",
17
17
  "plugins/lineage": "0.1.0",
18
18
  "plugins/inspector": "0.2.0"
19
19
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.29.3](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.2...ecosystem-v0.29.3) (2026-06-24)
4
+
5
+
6
+ ### Performance Improvements
7
+
8
+ * **bursar:** collapse process forks in SessionEnd hot path :relieved: ([#101](https://github.com/onlooker-community/ecosystem/issues/101)) ([7a426fe](https://github.com/onlooker-community/ecosystem/commit/7a426fe359785eca35ea1ad61523b05fda79e0da))
9
+
10
+ ## [0.29.2](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.1...ecosystem-v0.29.2) (2026-06-21)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **emitter:** make emission dependency-free and fail-open :relieved: ([#99](https://github.com/onlooker-community/ecosystem/issues/99)) ([0dda7f8](https://github.com/onlooker-community/ecosystem/commit/0dda7f803ed2dfa28561bd8d9e4193b2b18e5bbf))
16
+
3
17
  ## [0.29.1](https://github.com/onlooker-community/ecosystem/compare/ecosystem-v0.29.0...ecosystem-v0.29.1) (2026-06-15)
4
18
 
5
19
 
package/CLAUDE.md CHANGED
@@ -73,7 +73,7 @@ See `plugins/compass/docs/adr/001-evaluate-prompts-in-context.md` for the full d
73
73
  2. Use `scripts/lib/onlooker-event.mjs` for all event emission — never write directly to the JSONL log.
74
74
  3. Store runtime artifacts under `${ONLOOKER_DIR:-$HOME/.onlooker}/<name>/<project-key>/`. Always use `$ONLOOKER_DIR` — never hardcode `~/.onlooker` — so the test suite's isolated temp home is respected.
75
75
  4. Derive the project key via `tribunal_project_key` (or equivalent) — first 12 hex chars of SHA256(`remote:<origin-url>`), falling back to SHA256(`root:<repo-root>`) for repos without a remote. See `plugins/tribunal/scripts/lib/tribunal-project-key.sh`.
76
- 5. Register event types in `@onlooker-community/schema` before emitting them (the emitter validates the envelope).
76
+ 5. Register event types in `@onlooker-community/schema` before emitting them. The runtime emitter is dependency-free and **fails open**: it validates against the schema package only when that package is resolvable (dev, CI, tests) and emits unconditionally otherwise, because installed marketplace plugins ship no `node_modules`. Schema drift is caught in CI against the published schemas at `schema.onlooker.dev`. See [ADR-005](docs/adr/005-runtime-emitter-fails-open.md).
77
77
  6. Fail-soft when `~/.onlooker/` is absent — plugins must not block a session they were not invited to.
78
78
 
79
79
  ## Development
@@ -1,7 +1,8 @@
1
1
  # ADR-002: Centralized JSONL Event Log with Schema Validation
2
2
 
3
3
  **Status:** Accepted
4
- **Date:** 2026-05-24
4
+ **Date:** 2026-05-24
5
+ **Amended by:** [ADR-005](005-runtime-emitter-fails-open.md) — runtime validation was moved off the hot path; the emitter now fails open. The "schema validation at write time" rationale below holds only for dev/CI, not installed plugins.
5
6
 
6
7
  ## Context
7
8
 
@@ -0,0 +1,46 @@
1
+ # ADR-005: The Runtime Emitter Is Dependency-Free and Fails Open
2
+
3
+ **Status:** Accepted
4
+ **Date:** 2026-06-20
5
+ **Amends:** [ADR-002](002-centralized-jsonl-event-log.md)
6
+
7
+ ## Context
8
+
9
+ The canonical emitter `scripts/lib/onlooker-event.mjs` built each event envelope and **validated it against `@onlooker-community/schema` before printing it** (ADR-002: "schema validation at write time prevents silent corruption"). `@onlooker-community/schema` was a runtime `dependency`, and it pulls in `ajv` + `ajv-formats`. The package also reads its JSON schema files from disk at import time.
10
+
11
+ This assumed `node_modules` would be present wherever a hook runs. It is not. **Claude Code installs a marketplace plugin by cloning the marketplace repo; it never runs `npm install`.** `node_modules/` is git-ignored, so the installed plugin ships none. Every emission therefore ran:
12
+
13
+ ```
14
+ node scripts/lib/onlooker-event.mjs emit
15
+ └─ import '@onlooker-community/schema' → ERR_MODULE_NOT_FOUND → exit 1
16
+ ```
17
+
18
+ The bash side of each hook kept working (hook-health, per-session trackers need no node), so hooks looked healthy while **every event was silently dropped**. In one observed case `~/.onlooker/logs/onlooker-events.jsonl` received nothing for 18 days. Fail-closed validation, intended to prevent "silent corruption," instead produced total silent loss — a strictly worse failure than the one it guarded against.
19
+
20
+ Mechanisms considered to make the dependency available in the install: vendor a committed `node_modules`, bundle the emitter + `ajv` into one file with esbuild, or a self-healing `SessionStart` hook that runs `npm install`. Each ships or fetches a validator with the plugin and carries cost (repo bloat, a build step, or a network dependency on every fresh install).
21
+
22
+ ## Decision
23
+
24
+ **The runtime emitter has zero external dependencies and fails open.** Validation is best-effort, not a gate:
25
+
26
+ - `createEvent` (envelope assembly) and the event-type constants are **inlined** into `onlooker-event.mjs` — pure `node:` stdlib, no package import.
27
+ - Validation is attempted via a lazy `await import('@onlooker-community/schema')`. When the package resolves (dev, CI, tests) the emitter validates and **rejects** invalid events. When it does not resolve (installed plugins) the emitter **emits anyway**.
28
+ - `@onlooker-community/schema` moves from `dependencies` to `devDependencies`.
29
+ - A CI test (`test/node/schema-published.test.mjs`) validates representative emitter output against the canonical schemas **published at `https://schema.onlooker.dev`** — the contract downstream consumers actually enforce. It skips when the endpoint is unreachable.
30
+
31
+ ## Rationale
32
+
33
+ **Observability must not be fragile.** A telemetry pipeline that deletes everything when a dependency is missing — or when a payload drifts from the schema — is worse than one that records a slightly-off event. For an observability substrate, fail-open is the correct default: capture the signal, surface problems out-of-band.
34
+
35
+ **Best-effort keeps the dev-time guard.** The same lazy import that degrades gracefully in production still gates strictly wherever the validator is installed. The negative tests across the plugins ("emission fails loudly on a bogus event_type") keep passing because CI runs with dev dependencies present. We lose nothing in the loop that catches mistakes, and gain resilience where mistakes are unrecoverable.
36
+
37
+ **Validation belongs where it can act, not on the hot path.** A hook firing on every tool call cannot meaningfully respond to a validation failure except by dropping data. CI can: a red test blocks the release. Moving the authoritative check to CI — against the *published* schemas, not just the pinned npm copy — catches drift between what the emitter produces and what the deployed contract accepts.
38
+
39
+ **No artifact to ship or fetch.** Dependency-free is simpler than vendoring a `node_modules`, bundling with esbuild, or bootstrapping `npm install` at session start. There is nothing to keep in sync, nothing to build, and the install works offline.
40
+
41
+ ## Consequences
42
+
43
+ - The JSONL log may, in an installed plugin, contain an event that violates the schema if a payload builder drifts. Downstream consumers (the onlooker agent, the backend) validate on ingest; CI catches drift before release. This is the accepted cost of fail-open.
44
+ - `node` is still required to emit (the emitter is JS), but **no npm packages are**. Hooks that shell out to `onlooker-event.mjs` work on a fresh clone with nothing installed.
45
+ - The `validate` CLI subcommand and `onlooker_validate_event` still require the dev dependency; they are used only by the test suite, never on a runtime hook path.
46
+ - `test:schema` now makes a network call to `schema.onlooker.dev`. It skips cleanly offline, so local and air-gapped runs are unaffected; CI with egress exercises the live contract.
@@ -22,7 +22,7 @@ flowchart TB
22
22
  echo --> emitter
23
23
  cartographer --> emitter
24
24
 
25
- emitter -->|"schema-validated<br/>event envelope"| log
25
+ emitter -->|"event envelope<br/>(validated in dev/CI)"| log
26
26
  log -.->|"append-only JSONL"| log
27
27
  end
28
28
  ```
@@ -34,7 +34,7 @@ The `ecosystem` plugin (repo root) is not optional — it provides the infrastru
34
34
  | Component | What it does |
35
35
  |-----------|-------------|
36
36
  | `~/.onlooker/` directory | Shared storage root, created by the Onlooker installer. All plugins store artifacts here under their own sub-path. |
37
- | `scripts/lib/onlooker-event.mjs` | Canonical event builder. Accepts a JSON payload on stdin, validates it against `@onlooker-community/schema`, and prints the validated envelope to stdout. Callers capture the output and append it to the JSONL log. |
37
+ | `scripts/lib/onlooker-event.mjs` | Canonical event builder. Accepts a JSON payload on stdin and prints a canonical envelope to stdout; callers capture it and append to the JSONL log. **Dependency-free and fail-open**: it validates against `@onlooker-community/schema` only when that package is resolvable (dev/CI) and emits unconditionally otherwise, so a fresh marketplace install which ships no `node_modules` never loses telemetry. See [ADR-005](adr/005-runtime-emitter-fails-open.md). |
38
38
  | `scripts/lib/onlooker-schema.sh` | Bash convenience wrapper around `onlooker-event.mjs`. Provides `onlooker_event_from_hook` (builds an envelope via `node`) and `onlooker_append_event` (appends a pre-built envelope to the log). Node is still required. |
39
39
  | `scripts/lib/validate-path.sh` | Sets canonical `$ONLOOKER_*` environment variables (log path, tracker dirs, etc.) so every hook uses consistent paths. |
40
40
  | Session trackers | `SessionStart`, `SessionEnd`, `PreCompact`, `PostCompact`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `TaskCreated`, `TaskCompleted`, `WorktreeCreate`, and `WorktreeRemove` hooks that emit `session.*`, `tool.*`, `turn.*` events for the observability layer. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlooker-community/ecosystem",
3
- "version": "0.29.1",
3
+ "version": "0.29.3",
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",
@@ -18,9 +18,6 @@
18
18
  "bin": {
19
19
  "onlooker-install": "install.sh"
20
20
  },
21
- "dependencies": {
22
- "@onlooker-community/schema": "^2.9.0"
23
- },
24
21
  "scripts": {
25
22
  "postinstall": "echo '\\n onlooker-ecosystem installed!\\n Run: npx onlooker-install typescript\\n Docs: https://github.com/onlooker-community/ecosystem\\n'",
26
23
  "test": "npm run test:bats && npm run test:schema",
@@ -45,6 +42,7 @@
45
42
  },
46
43
  "devDependencies": {
47
44
  "@biomejs/biome": "2.4.15",
45
+ "@onlooker-community/schema": "^2.9.0",
48
46
  "globals": "^17.6.0",
49
47
  "markdownlint-cli": "^0.48.0"
50
48
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bursar",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Multi-session, per-project budget accounting for the Onlooker ecosystem. Rolls each session's spend into a per-project ledger on SessionEnd and surfaces \"this project burned $X this week\" at SessionStart. Where governor regulates a single session, bursar is the cross-session rollup: it reads governor.session.complete off the shared event bus and emits bursar.* events for audit. Named for the officer who keeps the accounts. 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.1.1](https://github.com/onlooker-community/ecosystem/compare/bursar-v0.1.0...bursar-v0.1.1) (2026-06-24)
4
+
5
+
6
+ ### Performance Improvements
7
+
8
+ * **bursar:** collapse process forks in SessionEnd hot path :relieved: ([#101](https://github.com/onlooker-community/ecosystem/issues/101)) ([7a426fe](https://github.com/onlooker-community/ecosystem/commit/7a426fe359785eca35ea1ad61523b05fda79e0da))
9
+
3
10
  ## [0.1.0](https://github.com/onlooker-community/ecosystem/compare/bursar-v0.0.1...bursar-v0.1.0) (2026-06-12)
4
11
 
5
12
 
@@ -33,11 +33,12 @@ source "${PLUGIN_ROOT}/scripts/lib/bursar-project-key.sh"
33
33
  source "${PLUGIN_ROOT}/scripts/lib/bursar-ledger.sh"
34
34
 
35
35
  INPUT=$(cat)
36
- SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // ""' 2>/dev/null) || SESSION_ID=""
37
- CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // ""' 2>/dev/null) || CWD=""
38
36
 
39
37
  _done() { exit 0; }
40
38
 
39
+ # Parse session_id and cwd in a single jq pass (one fork, not two).
40
+ { IFS= read -r SESSION_ID; IFS= read -r CWD; } < <(printf '%s' "$INPUT" | jq -r '.session_id // "", .cwd // ""' 2>/dev/null)
41
+
41
42
  [[ -z "$SESSION_ID" ]] && _done
42
43
 
43
44
  ONLOOKER_DIR="${ONLOOKER_DIR:-${HOME}/.onlooker}"
@@ -47,11 +48,13 @@ TRACKER="${ONLOOKER_DIR}/session-trackers/${SESSION_ID}"
47
48
 
48
49
  # -----------------------------------------------------------------------
49
50
  # Resolve project key + cwd: breadcrumb → substrate tracker → live cwd.
51
+ # The breadcrumb (dropped at SessionStart) usually carries both, which lets us
52
+ # skip the git + shasum project-key derivation entirely in the common case.
50
53
  # -----------------------------------------------------------------------
51
54
  PROJECT_KEY=""
52
55
  if [[ -f "$BREADCRUMB" ]]; then
53
- PROJECT_KEY=$(jq -r '.project_key // ""' "$BREADCRUMB" 2>/dev/null) || PROJECT_KEY=""
54
- [[ -z "$CWD" ]] && CWD=$(jq -r '.cwd // ""' "$BREADCRUMB" 2>/dev/null)
56
+ { IFS= read -r PROJECT_KEY; IFS= read -r bc_cwd; } < <(jq -r '.project_key // "", .cwd // ""' "$BREADCRUMB" 2>/dev/null)
57
+ [[ -z "$CWD" ]] && CWD="$bc_cwd"
55
58
  fi
56
59
  if [[ -z "$CWD" && -f "$TRACKER" ]]; then
57
60
  CWD=$(jq -r '.cwd // ""' "$TRACKER" 2>/dev/null) || CWD=""
@@ -74,66 +77,74 @@ COST=""
74
77
  TOKENS=""
75
78
  CALLS=""
76
79
 
77
- # Reads event-log lines on stdin; echoes the latest matching session.complete payload.
78
- _latest_governor_payload() {
80
+ # Reads event-log lines on stdin; emits one TSV line "cost<TAB>tokens<TAB>calls"
81
+ # for the latest governor.session.complete matching this session (empty if none).
82
+ # grep pre-filters so jq only parses the handful of matching lines, and the
83
+ # field extraction happens in the same jq pass that selects the latest match —
84
+ # replacing the prior select + three separate jq extractions.
85
+ _latest_governor_spend() {
79
86
  grep -F '"governor.session.complete"' 2>/dev/null \
80
- | jq -c --arg sid "$SESSION_ID" \
81
- 'select(.event_type == "governor.session.complete" and .payload.session_id == $sid) | .payload' \
82
- 2>/dev/null \
83
- | tail -n 1
87
+ | jq -rs --arg sid "$SESSION_ID" '
88
+ [ .[]
89
+ | select(.event_type == "governor.session.complete" and .payload.session_id == $sid)
90
+ | .payload ]
91
+ | if length == 0 then empty
92
+ else (.[-1] | [(.total_cost_usd // ""), (.total_tokens // ""), (.total_api_calls // "")] | @tsv)
93
+ end' \
94
+ 2>/dev/null
84
95
  }
85
96
 
86
97
  if [[ -f "$LOG" ]]; then
87
98
  # The matching event was emitted seconds ago (governor's final Stop), so it is
88
99
  # almost always near the tail. Scan a recent slice first to keep this hook
89
100
  # fast as the global log grows; fall back to the full file only on a miss.
90
- SPEND=$(tail -n 2000 "$LOG" 2>/dev/null | _latest_governor_payload)
91
- [[ -z "$SPEND" ]] && SPEND=$(_latest_governor_payload < "$LOG")
101
+ SPEND=$(tail -n 2000 "$LOG" 2>/dev/null | _latest_governor_spend)
102
+ [[ -z "$SPEND" ]] && SPEND=$(_latest_governor_spend < "$LOG")
92
103
  if [[ -n "$SPEND" ]]; then
93
104
  GOV_PRESENT="true"
94
- COST=$(printf '%s' "$SPEND" | jq -r '.total_cost_usd // empty' 2>/dev/null) || COST=""
95
- TOKENS=$(printf '%s' "$SPEND" | jq -r '.total_tokens // empty' 2>/dev/null) || TOKENS=""
96
- CALLS=$(printf '%s' "$SPEND" | jq -r '.total_api_calls // empty' 2>/dev/null) || CALLS=""
105
+ IFS=$'\t' read -r COST TOKENS CALLS <<<"$SPEND"
97
106
  fi
98
107
  fi
99
108
 
100
109
  MODEL=""
101
110
  [[ -f "$TRACKER" ]] && MODEL=$(jq -r '.model // ""' "$TRACKER" 2>/dev/null)
102
111
 
112
+ # One date fork yields both the epoch and the RFC3339 stamp (was two).
113
+ { IFS= read -r NOW_EPOCH; IFS= read -r NOW_ISO; } < <(date -u +'%s%n%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)
114
+ [[ -z "$NOW_EPOCH" ]] && NOW_EPOCH=0
115
+
103
116
  # -----------------------------------------------------------------------
104
- # Build the record and the event payload, attaching spend fields only when
105
- # governor supplied them.
117
+ # Build the ledger record AND the (smaller) event payload in a single jq pass.
118
+ # Spend fields are passed as strings and coerced with tonumber so an empty value
119
+ # is simply omitted — replacing the per-field add_fields() helper that forked a
120
+ # jq for every field, twice. The event payload is the record minus the
121
+ # ledger-only ts/ts_epoch fields.
106
122
  # -----------------------------------------------------------------------
107
- add_fields() {
108
- # Echoes the input JSON ($1) with cost/tokens/calls/model merged in.
109
- local base="$1"
110
- [[ -n "$COST" ]] && base=$(printf '%s' "$base" | jq --argjson v "$COST" '. + {cost_usd: $v}' 2>/dev/null)
111
- [[ -n "$TOKENS" ]] && base=$(printf '%s' "$base" | jq --argjson v "$TOKENS" '. + {tokens: $v}' 2>/dev/null)
112
- [[ -n "$CALLS" ]] && base=$(printf '%s' "$base" | jq --argjson v "$CALLS" '. + {api_calls: $v}' 2>/dev/null)
113
- [[ -n "$MODEL" ]] && base=$(printf '%s' "$base" | jq --arg v "$MODEL" '. + {model: $v}' 2>/dev/null)
114
- printf '%s' "$base"
115
- }
116
-
117
- RECORD=$(jq -n \
118
- --arg ts "$(bursar_now_iso)" \
119
- --argjson te "$(bursar_now_epoch)" \
123
+ { IFS= read -r RECORD; IFS= read -r EV; } < <(jq -rn \
124
+ --arg ts "$NOW_ISO" \
125
+ --argjson te "$NOW_EPOCH" \
120
126
  --arg sid "$SESSION_ID" \
121
127
  --arg pk "$PROJECT_KEY" \
122
128
  --argjson gp "$GOV_PRESENT" \
123
- '{ts: $ts, ts_epoch: $te, session_id: $sid, project_key: $pk, governor_present: $gp}' 2>/dev/null)
124
- RECORD=$(add_fields "$RECORD")
129
+ --arg cost "$COST" \
130
+ --arg tokens "$TOKENS" \
131
+ --arg calls "$CALLS" \
132
+ --arg model "$MODEL" \
133
+ '
134
+ ( {ts: $ts, ts_epoch: $te, session_id: $sid, project_key: $pk, governor_present: $gp}
135
+ + (if $cost != "" then {cost_usd: ($cost | tonumber)} else {} end)
136
+ + (if $tokens != "" then {tokens: ($tokens | tonumber)} else {} end)
137
+ + (if $calls != "" then {api_calls: ($calls | tonumber)} else {} end)
138
+ + (if $model != "" then {model: $model} else {} end)
139
+ ) as $record
140
+ | ($record | tojson), ($record | del(.ts, .ts_epoch) | tojson)
141
+ ' 2>/dev/null)
125
142
 
126
143
  # Only claim the session was recorded — and only drop the breadcrumb — once the
127
144
  # ledger upsert actually succeeds. A failed write (lock timeout, mv failure)
128
145
  # must keep the breadcrumb so the session→project attribution survives for a
129
146
  # later attempt rather than being lost behind a false "recorded" event.
130
147
  if [[ -n "$RECORD" ]] && bursar_ledger_record "$PROJECT_KEY" "$RECORD"; then
131
- EV=$(jq -n \
132
- --arg pk "$PROJECT_KEY" \
133
- --arg sid "$SESSION_ID" \
134
- --argjson gp "$GOV_PRESENT" \
135
- '{project_key: $pk, session_id: $sid, governor_present: $gp}' 2>/dev/null)
136
- EV=$(add_fields "$EV")
137
148
  [[ -n "$EV" ]] && bursar_emit_event "bursar.session.recorded" "$EV" "$SESSION_ID" || true
138
149
 
139
150
  rm -f "$BREADCRUMB" 2>/dev/null || true
@@ -21,40 +21,43 @@ bursar_config_load() {
21
21
  local plugin_root="${CLAUDE_PLUGIN_ROOT:-}"
22
22
  local home_dir="${HOME:-}"
23
23
 
24
- local merged="{}"
25
- local file
24
+ # Read each layer's raw text with the no-fork `$(<file)` builtin (NOT `cat`),
25
+ # then deep-merge all three layers in a SINGLE jq invocation. The dominant
26
+ # cost in the SessionEnd hook is jq process startup, not the merge itself, so
27
+ # this collapses what was one-jq-per-file (up to 6 forks) down to one.
28
+ local default_txt="" home_txt="" repo_txt=""
29
+ local default_file="${plugin_root}/config.json"
30
+ local home_file="${home_dir}/.claude/settings.json"
31
+ local repo_file=""
32
+ [[ -n "$repo_root" ]] && repo_file="${repo_root}/.claude/settings.json"
26
33
 
27
- file="${plugin_root}/config.json"
28
- if [[ -f "$file" ]]; then
29
- local defaults
30
- defaults=$(jq '.' "$file" 2>/dev/null) || defaults="{}"
31
- merged=$(jq -n --argjson a "$merged" --argjson b "$defaults" '$a * $b' 2>/dev/null) \
32
- || merged="$defaults"
33
- fi
34
+ [[ -f "$default_file" ]] && default_txt="$(<"$default_file")"
35
+ [[ -f "$home_file" ]] && home_txt="$(<"$home_file")"
36
+ [[ -n "$repo_file" && -f "$repo_file" ]] && repo_txt="$(<"$repo_file")"
34
37
 
35
- local repo_settings=""
36
- [[ -n "$repo_root" ]] && repo_settings="${repo_root}/.claude/settings.json"
37
-
38
- for file in "${home_dir}/.claude/settings.json" "$repo_settings"; do
39
- [[ -n "$file" && -f "$file" ]] || continue
40
- local overlay
41
- overlay=$(jq '{ bursar: (.bursar // {}) }' "$file" 2>/dev/null) || continue
42
- [[ -z "$overlay" ]] && continue
43
- local attempt
44
- if attempt=$(jq -n --argjson a "$merged" --argjson b "$overlay" '
45
- def deepmerge($a; $b):
46
- if ($a|type) == "object" and ($b|type) == "object" then
47
- reduce (($a|keys) + ($b|keys) | unique)[] as $k
48
- ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
49
- elif $b == null then $a
50
- else $b end;
51
- deepmerge($a; $b)
52
- ' 2>/dev/null) && [[ -n "$attempt" ]]; then
53
- merged="$attempt"
54
- fi
55
- done
56
-
57
- _BURSAR_CONFIG="$merged"
38
+ # Precedence (latest wins): defaults < home settings < repo settings. The
39
+ # defaults file is merged whole; settings files contribute only their .bursar
40
+ # key. `fromjson? // {}` parses each layer defensively — a missing or malformed
41
+ # file degrades to {} rather than aborting the merge (matches the prior
42
+ # per-file fallback).
43
+ _BURSAR_CONFIG=$(jq -n \
44
+ --arg d "$default_txt" \
45
+ --arg h "$home_txt" \
46
+ --arg r "$repo_txt" \
47
+ '
48
+ def deepmerge($a; $b):
49
+ if ($a|type) == "object" and ($b|type) == "object" then
50
+ reduce (($a|keys) + ($b|keys) | unique)[] as $k
51
+ ({}; .[$k] = deepmerge($a[$k]; $b[$k]))
52
+ elif $b == null then $a
53
+ else $b end;
54
+ ($d | fromjson? // {}) as $defaults
55
+ | (($h | fromjson? // {}) | {bursar: (.bursar // {})}) as $home
56
+ | (($r | fromjson? // {}) | {bursar: (.bursar // {})}) as $repo
57
+ | deepmerge(deepmerge($defaults; $home); $repo)
58
+ ' 2>/dev/null) || _BURSAR_CONFIG="{}"
59
+ [[ -z "$_BURSAR_CONFIG" ]] && _BURSAR_CONFIG="{}"
60
+ return 0
58
61
  }
59
62
 
60
63
  bursar_config_get() {
@@ -55,12 +55,13 @@ bursar_ledger_record() {
55
55
  local record="${2:-}"
56
56
  [[ -z "$project_key" || -z "$record" ]] && return 1
57
57
 
58
- local sid
59
- sid=$(printf '%s' "$record" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
60
- [[ -z "$sid" ]] && return 1
61
-
62
- local record_compact
63
- record_compact=$(printf '%s' "$record" | jq -c . 2>/dev/null) || return 1
58
+ # Pull the session_id and the compacted record out in a single jq pass:
59
+ # line 1 is the key, line 2 is the line we will write.
60
+ local sid record_compact
61
+ { IFS= read -r sid; IFS= read -r record_compact; } < <(
62
+ printf '%s' "$record" | jq -r '.session_id // empty, tojson' 2>/dev/null
63
+ )
64
+ [[ -z "$sid" || -z "$record_compact" ]] && return 1
64
65
 
65
66
  local dir ledger_path lock_path
66
67
  dir=$(bursar_ledger_dir "$project_key")
@@ -6,20 +6,24 @@
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import {
10
- createEvent,
11
- SKILL_INVOKED,
12
- TASK_COMPLETE,
13
- TASK_START,
14
- TOOL_AGENT_COMPLETE,
15
- TOOL_AGENT_SPAWN,
16
- TOOL_FILE_EDIT,
17
- TOOL_FILE_READ,
18
- TOOL_FILE_WRITE,
19
- TOOL_SHELL_EXEC,
20
- TOOL_WEB_FETCH,
21
- validate,
22
- } from '@onlooker-community/schema';
9
+
10
+ // Canonical event-type constants, inlined rather than imported from
11
+ // @onlooker-community/schema so this emitter has ZERO runtime dependencies.
12
+ // Claude Code installs plugins by cloning the marketplace repo and never runs
13
+ // `npm install`, so an installed plugin has no node_modules — a runtime import
14
+ // of the schema package fails with ERR_MODULE_NOT_FOUND and silently kills all
15
+ // telemetry. The full contract still lives in @onlooker-community/schema; it is
16
+ // now a devDependency used to validate emitter output in CI (see tryValidate).
17
+ const SKILL_INVOKED = 'skill.invoked';
18
+ const TASK_START = 'task.start';
19
+ const TASK_COMPLETE = 'task.complete';
20
+ const TOOL_AGENT_COMPLETE = 'tool.agent.complete';
21
+ const TOOL_AGENT_SPAWN = 'tool.agent.spawn';
22
+ const TOOL_FILE_EDIT = 'tool.file.edit';
23
+ const TOOL_FILE_READ = 'tool.file.read';
24
+ const TOOL_FILE_WRITE = 'tool.file.write';
25
+ const TOOL_SHELL_EXEC = 'tool.shell.exec';
26
+ const TOOL_WEB_FETCH = 'tool.web.fetch';
23
27
 
24
28
  export function ensureMachineId(onlookerDir) {
25
29
  const path = join(onlookerDir, 'machine_id');
@@ -55,21 +59,52 @@ export function buildCanonicalEvent({
55
59
  cost_usd,
56
60
  token_count,
57
61
  }) {
58
- const event = createEvent({
62
+ // Envelope assembly inlined from @onlooker-community/schema's createEvent so
63
+ // emission needs no external package. The schema is the source of truth for
64
+ // this shape; CI validates against it (see test/node/schema-*.test.mjs).
65
+ const event = {
66
+ id: randomUUID(),
67
+ schema_version: '1.0',
59
68
  runtime,
60
- adapter_id,
61
69
  plugin,
62
70
  machine_id: ensureMachineId(onlookerDir),
71
+ timestamp: new Date().toISOString(),
63
72
  session_id,
73
+ sequence: nextSequence(onlookerDir),
64
74
  event_type,
65
75
  payload,
66
- cost_usd,
67
- token_count,
68
- });
69
- event.sequence = nextSequence(onlookerDir);
76
+ redacted: false,
77
+ };
78
+ if (adapter_id !== undefined) event.adapter_id = adapter_id;
79
+ if (cost_usd !== undefined) event.cost_usd = cost_usd;
80
+ if (token_count !== undefined) event.token_count = token_count;
70
81
  return event;
71
82
  }
72
83
 
84
+ /**
85
+ * Best-effort schema validation.
86
+ *
87
+ * @onlooker-community/schema is a devDependency, so it resolves in dev, CI, and
88
+ * tests — where we DO want emitter output gated against the contract (this is
89
+ * what the negative tests across the plugins rely on) — but is absent in an
90
+ * installed marketplace plugin. When it is absent we fail OPEN: build and emit
91
+ * anyway, so schema drift can never silently kill telemetry the way a hard
92
+ * runtime dependency did. Drift is caught in CI, which validates emitter output
93
+ * against the published schemas at https://schema.onlooker.dev.
94
+ *
95
+ * Returns { available: false } when no validator is installed, otherwise the
96
+ * { valid, errors? } result from the schema package.
97
+ */
98
+ async function tryValidate(event) {
99
+ let schema;
100
+ try {
101
+ schema = await import('@onlooker-community/schema');
102
+ } catch {
103
+ return { available: false };
104
+ }
105
+ return { available: true, ...schema.validate(event) };
106
+ }
107
+
73
108
  function summarizeText(value, maxLen = 1000) {
74
109
  if (value == null) return undefined;
75
110
  const text = String(value).replace(/\s+/g, ' ').trim();
@@ -219,11 +254,7 @@ export function mapSkillHookInput(hookInput, options) {
219
254
  payload,
220
255
  });
221
256
 
222
- const result = validate(event);
223
- if (!result.valid) {
224
- return { valid: false, errors: result.errors, event_type: SKILL_INVOKED };
225
- }
226
- return { valid: true, event: result.event };
257
+ return { valid: true, event };
227
258
  }
228
259
 
229
260
  /**
@@ -269,11 +300,7 @@ export function mapTaskHookInput(hookInput, options) {
269
300
  payload,
270
301
  });
271
302
 
272
- const result = validate(event);
273
- if (!result.valid) {
274
- return { valid: false, errors: result.errors, event_type: eventType };
275
- }
276
- return { valid: true, event: result.event };
303
+ return { valid: true, event };
277
304
  }
278
305
 
279
306
  /**
@@ -322,11 +349,7 @@ export function mapWorktreeHookInput(hookInput, options) {
322
349
  payload,
323
350
  });
324
351
 
325
- const result = validate(event);
326
- if (!result.valid) {
327
- return { valid: false, errors: result.errors, event_type: TOOL_SHELL_EXEC };
328
- }
329
- return { valid: true, event: result.event };
352
+ return { valid: true, event };
330
353
  }
331
354
 
332
355
  /**
@@ -444,11 +467,7 @@ export function mapHookInputToCanonical(hookInput, options) {
444
467
  payload,
445
468
  });
446
469
 
447
- const result = validate(event);
448
- if (!result.valid) {
449
- return { valid: false, errors: result.errors, event_type: eventType };
450
- }
451
- return { valid: true, event: result.event };
470
+ return { valid: true, event };
452
471
  }
453
472
 
454
473
  function readStdin() {
@@ -468,12 +487,16 @@ async function main() {
468
487
  if (command === 'validate') {
469
488
  const raw = await readStdin();
470
489
  const parsed = JSON.parse(raw || '{}');
471
- const result = validate(parsed);
472
- if (!result.valid) {
473
- console.error(JSON.stringify(result.errors, null, 2));
490
+ const check = await tryValidate(parsed);
491
+ if (!check.available) {
492
+ console.error('@onlooker-community/schema is not installed; cannot validate');
493
+ process.exit(1);
494
+ }
495
+ if (!check.valid) {
496
+ console.error(JSON.stringify(check.errors, null, 2));
474
497
  process.exit(1);
475
498
  }
476
- console.log(JSON.stringify(result.event));
499
+ console.log(JSON.stringify(parsed));
477
500
  return;
478
501
  }
479
502
 
@@ -484,8 +507,11 @@ async function main() {
484
507
  if (!mapped) {
485
508
  process.exit(0);
486
509
  }
487
- if (!mapped.valid) {
488
- console.error(JSON.stringify(mapped.errors, null, 2));
510
+ // Best-effort: reject when a validator is present (dev/CI), fail open in
511
+ // installed plugins so the event is still emitted.
512
+ const check = await tryValidate(mapped.event);
513
+ if (check.available && !check.valid) {
514
+ console.error(JSON.stringify(check.errors, null, 2));
489
515
  process.exit(1);
490
516
  }
491
517
  console.log(JSON.stringify(mapped.event));
@@ -509,12 +535,14 @@ async function main() {
509
535
  event_type: params.event_type,
510
536
  payload: params.payload,
511
537
  });
512
- const result = validate(event);
513
- if (!result.valid) {
514
- console.error(JSON.stringify(result.errors, null, 2));
538
+ // Best-effort: reject when a validator is present (dev/CI), fail open in
539
+ // installed plugins so the event is still emitted.
540
+ const check = await tryValidate(event);
541
+ if (check.available && !check.valid) {
542
+ console.error(JSON.stringify(check.errors, null, 2));
515
543
  process.exit(1);
516
544
  }
517
- console.log(JSON.stringify(result.event));
545
+ console.log(JSON.stringify(event));
518
546
  return;
519
547
  }
520
548
 
@@ -65,10 +65,10 @@ STUB
65
65
 
66
66
  # On its first scan the hook has no watermark and falls back to a relative
67
67
  # "now - bootstrap_lookback_days" window (default 14 days). Fixtures must be
68
- # dated inside that window or the scan sees nothing. Compute a recent
69
- # timestamp at runtime a hardcoded date silently ages out of the window
70
- # and turns these tests into a time bomb. One day back keeps a wide margin.
71
- FIXTURE_CREATED_AT=$(python3 -c "import datetime; print((datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ'))")
68
+ # dated inside that window or the scan sees nothing, so date them relative to
69
+ # now one day back keeps a wide margin. See relative_iso_days_ago in
70
+ # test/helpers/setup.bash for why a hardcoded date is a time bomb here.
71
+ FIXTURE_CREATED_AT=$(relative_iso_days_ago 1)
72
72
  }
73
73
 
74
74
  # Helper: write an archivist artifact for the project.
@@ -31,6 +31,29 @@ setup_test_env() {
31
31
  unset XDG_CONFIG_HOME
32
32
  }
33
33
 
34
+ # Produce an ISO-8601 UTC timestamp offset from "now" by N days into the past.
35
+ # Positive N = N days ago, 0 = now, negative N = N days in the future.
36
+ #
37
+ # Use this for any fixture whose date must fall inside a relative window the
38
+ # code computes from "now" (e.g. a "now - lookback_days" scan window). A
39
+ # hardcoded ISO date silently ages out of such a window and turns the test
40
+ # into a time bomb that passes today and fails on some future date. Always
41
+ # date those fixtures relative to now.
42
+ #
43
+ # Uses python3 (already a hook dependency) for portable date math — `date -d`
44
+ # vs `date -v` diverges between GNU and BSD/macOS.
45
+ #
46
+ # Usage: created_at=$(relative_iso_days_ago 1) # yesterday, UTC
47
+ relative_iso_days_ago() {
48
+ local days="${1:-0}"
49
+ python3 -c '
50
+ import datetime, sys
51
+ delta = datetime.timedelta(days=int(sys.argv[1]))
52
+ now = datetime.datetime.now(datetime.timezone.utc)
53
+ print((now - delta).strftime("%Y-%m-%dT%H:%M:%SZ"))
54
+ ' "$days"
55
+ }
56
+
34
57
  # Source validate-path.sh with test env vars already set.
35
58
  load_validate_path() {
36
59
  setup_test_env
@@ -0,0 +1,92 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { Ajv2020 } from 'ajv/dist/2020.js';
7
+ import _addFormats from 'ajv-formats';
8
+ import { buildCanonicalEvent } from '../../scripts/lib/onlooker-event.mjs';
9
+
10
+ // The runtime emitter is dependency-free and fails open (it never drops events
11
+ // in an installed plugin — see scripts/lib/onlooker-event.mjs). This test is the
12
+ // other half of that contract: it proves the emitter's output still conforms to
13
+ // the canonical schemas PUBLISHED at https://schema.onlooker.dev — the contract
14
+ // downstream consumers (the onlooker agent, the backend) actually validate
15
+ // against. Schema drift that would silently pass at runtime is caught here.
16
+ //
17
+ // Network-resilient: when the published endpoint is unreachable (offline dev,
18
+ // CI without egress) the test skips rather than failing red.
19
+
20
+ const addFormats = _addFormats.default ?? _addFormats;
21
+ const BASE = 'https://schema.onlooker.dev/schemas';
22
+
23
+ async function fetchJson(url) {
24
+ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
25
+ if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`);
26
+ return res.json();
27
+ }
28
+
29
+ // Representative events the emitter produces, paired with the payload schema
30
+ // file each event family maps to.
31
+ const SAMPLES = [
32
+ {
33
+ event_type: 'session.start',
34
+ payloadFile: 'session.json',
35
+ payload: { working_directory: '/tmp', git_branch: 'main' },
36
+ },
37
+ { event_type: 'session.prompt', payloadFile: 'session.json', payload: { turn_number: 2, input_summary: 'hello' } },
38
+ { event_type: 'tool.shell.exec', payloadFile: 'tool.json', payload: { command: 'ls -la', exit_code: 0 } },
39
+ {
40
+ event_type: 'skill.invoked',
41
+ payloadFile: 'skill.json',
42
+ payload: { skill_name: 'commit', invocation_source: 'tool' },
43
+ },
44
+ { event_type: 'task.start', payloadFile: 'task.json', payload: { task_summary: 'implement the thing' } },
45
+ ];
46
+
47
+ test('emitter output conforms to the published schemas at schema.onlooker.dev', async (t) => {
48
+ let envelope;
49
+ const payloadDocs = new Map();
50
+ try {
51
+ envelope = await fetchJson(`${BASE}/event.v1.json`);
52
+ for (const file of new Set(SAMPLES.map((s) => s.payloadFile))) {
53
+ payloadDocs.set(file, await fetchJson(`${BASE}/payload/${file}`));
54
+ }
55
+ } catch (err) {
56
+ t.skip(`published schemas unreachable (${err.message}); skipping live-contract check`);
57
+ return;
58
+ }
59
+
60
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
61
+ addFormats(ajv);
62
+ const validateEnvelope = ajv.compile(envelope);
63
+
64
+ const tmp = mkdtempSync(join(tmpdir(), 'onlooker-published-'));
65
+ try {
66
+ for (const sample of SAMPLES) {
67
+ const event = buildCanonicalEvent({
68
+ onlookerDir: tmp,
69
+ plugin: 'onlooker',
70
+ session_id: 'published-contract-test',
71
+ event_type: sample.event_type,
72
+ payload: sample.payload,
73
+ });
74
+
75
+ assert.ok(
76
+ validateEnvelope(event),
77
+ `published envelope rejected ${sample.event_type}: ${ajv.errorsText(validateEnvelope.errors)}`,
78
+ );
79
+
80
+ const def = payloadDocs.get(sample.payloadFile)?.$defs?.[sample.event_type];
81
+ assert.ok(def, `published payload/${sample.payloadFile} has no $defs entry for ${sample.event_type}`);
82
+
83
+ const validatePayload = ajv.compile(def);
84
+ assert.ok(
85
+ validatePayload(event.payload),
86
+ `published payload schema rejected ${sample.event_type}: ${ajv.errorsText(validatePayload.errors)}`,
87
+ );
88
+ }
89
+ } finally {
90
+ rmSync(tmp, { recursive: true, force: true });
91
+ }
92
+ });