@onlooker-community/ecosystem 0.43.2 → 0.43.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,1115 @@
1
+ # Schema Emission Harness Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make a payload that drifts from the schema fail CI, instead of being
6
+ silently dropped by an emitter whose rejection the hook swallows.
7
+
8
+ **Architecture:** The emitter appends one line per emission to a suite-durable
9
+ report directory when `ONLOOKER_TEST_REPORT_DIR` is set. Two gates then read
10
+ that report after the suite: Gate A fails on any rejected emission, Gate B
11
+ fails when a registered event type appears in neither list of a committed
12
+ manifest. Nothing changes in production, which never sets the variable.
13
+
14
+ **Tech Stack:** Node 22 ESM, `node:test`, bats, `@onlooker-community/schema`
15
+ (devDependency), npm scripts.
16
+
17
+ **Spec:** `docs/superpowers/specs/2026-08-21-schema-emission-harness-design.md`
18
+
19
+ ## Global Constraints
20
+
21
+ - The emitter has **zero runtime dependencies** and **fails open** (ADR-005).
22
+ Nothing in this plan may add an import that runs on the emit path, change an
23
+ exit code, or make emission depend on the report succeeding.
24
+ - Report writes happen **only** when `ONLOOKER_TEST_REPORT_DIR` is set.
25
+ - The `validate` subcommand must **not** write a report line. Only `emit` and
26
+ `emit-from-hook` do. A test that validates a fixture is not an emission, and
27
+ counting it would give Gate B false coverage.
28
+ - Runtime artifacts go under `${ONLOOKER_DIR:-$HOME/.onlooker}`; the report
29
+ directory is a **test** artifact and lives under `test/`, gitignored.
30
+ - American English in all comments, docs, and commit messages.
31
+ - Every commit goes through `/commit`. Never push to `main`; open a PR.
32
+ - Event types are `<plugin>.<noun>.<verb>`; ULIDs, not UUIDs, for IDs.
33
+
34
+ ---
35
+
36
+ ## File Structure
37
+
38
+ | File | Responsibility |
39
+ |------|----------------|
40
+ | `scripts/lib/onlooker-event.mjs` (modify) | Add `recordEmission()`; call it from `emit` and `emit-from-hook` only |
41
+ | `test/node/emission-report.test.mjs` (create) | Proves the emitter records correctly and stays silent when unset |
42
+ | `scripts/lint/check-bus-coverage.mjs` (create) | The gate runner — Gate A in Task 2, Gate B added in Task 4 |
43
+ | `test/node/check-bus-coverage.test.mjs` (create) | Fixture-driven tests for the gate runner itself |
44
+ | `test/helpers/setup.bash` (modify) | Add `expect_emission_rejected`, the opt-out for deliberate-rejection tests |
45
+ | `test/bats/emission-report-optout.bats` (create) | Proves the opt-out suppresses a rejection and restores the report dir |
46
+ | `test/bats/*-events.bats` (modify) | Convert negative emission call sites to the opt-out helper |
47
+ | `test/bus-coverage.json` (create) | The committed manifest: `expected` list + `excluded` map |
48
+ | `package.json` (modify) | Export the report dir; add `test:bus`; wire into `test:ci` |
49
+ | `.gitignore` (modify) | Ignore `test/tmp-emission-report/` |
50
+ | `docs/architecture.md`, `CLAUDE.md`, `.claude/skills/writing-tests/SKILL.md` (modify) | Document the gates |
51
+
52
+ ---
53
+
54
+ ## Task 1: Emitter records every emission when the report dir is set
55
+
56
+ **Files:**
57
+
58
+ - Modify: `scripts/lib/onlooker-event.mjs:7` (import), after `tryValidate`
59
+ (~line 107), and both emit call sites (~line 512, ~line 540)
60
+ - Test: `test/node/emission-report.test.mjs`
61
+
62
+ **Interfaces:**
63
+
64
+ - Consumes: nothing from earlier tasks.
65
+ - Produces: a report file at `$ONLOOKER_TEST_REPORT_DIR/emissions.jsonl`. Each
66
+ line is `{ event_type: string|null, validated: boolean, valid: boolean|null,
67
+ errors?: object[] }`. `validated` is whether the schema package resolved;
68
+ `valid` is `null` when it did not. Task 2 and Task 5 read this shape.
69
+
70
+ - [ ] **Step 1: Write the failing test**
71
+
72
+ Create `test/node/emission-report.test.mjs`:
73
+
74
+ ```javascript
75
+ import assert from 'node:assert/strict';
76
+ import { spawnSync } from 'node:child_process';
77
+ import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
78
+ import { tmpdir } from 'node:os';
79
+ import { dirname, join, resolve } from 'node:path';
80
+ import { describe, it } from 'node:test';
81
+ import { fileURLToPath } from 'node:url';
82
+
83
+ const HERE = dirname(fileURLToPath(import.meta.url));
84
+ const REPO_ROOT = resolve(HERE, '..', '..');
85
+ const EMITTER = join(REPO_ROOT, 'scripts', 'lib', 'onlooker-event.mjs');
86
+
87
+ // session.start requires only working_directory and forbids extra properties.
88
+ // Verified against node_modules/@onlooker-community/schema/schemas/payload/session.json
89
+ const VALID = {
90
+ plugin: 'onlooker',
91
+ session_id: '01JZZZZZZZZZZZZZZZZZZZZZZZ',
92
+ event_type: 'session.start',
93
+ payload: { working_directory: '/tmp/x' },
94
+ };
95
+ const INVALID = { ...VALID, payload: { working_directory: 42 } };
96
+
97
+ function emit(params, { reportDir } = {}) {
98
+ const env = {
99
+ ...process.env,
100
+ ONLOOKER_DIR: mkdtempSync(join(tmpdir(), 'emit-onlooker-')),
101
+ };
102
+ if (reportDir) env.ONLOOKER_TEST_REPORT_DIR = reportDir;
103
+ else delete env.ONLOOKER_TEST_REPORT_DIR;
104
+ return spawnSync('node', [EMITTER, 'emit'], {
105
+ input: JSON.stringify(params),
106
+ encoding: 'utf8',
107
+ env,
108
+ });
109
+ }
110
+
111
+ function readReport(dir) {
112
+ const p = join(dir, 'emissions.jsonl');
113
+ if (!existsSync(p)) return null;
114
+ return readFileSync(p, 'utf8')
115
+ .trim()
116
+ .split('\n')
117
+ .filter(Boolean)
118
+ .map((l) => JSON.parse(l));
119
+ }
120
+
121
+ describe('emission report', () => {
122
+ it('records a valid emission as validated and valid', () => {
123
+ const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
124
+ const r = emit(VALID, { reportDir: dir });
125
+ assert.equal(r.status, 0, r.stderr);
126
+ const lines = readReport(dir);
127
+ assert.equal(lines.length, 1);
128
+ assert.equal(lines[0].event_type, 'session.start');
129
+ assert.equal(lines[0].validated, true);
130
+ assert.equal(lines[0].valid, true);
131
+ });
132
+
133
+ it('records a rejected emission as invalid, with its errors', () => {
134
+ const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
135
+ const r = emit(INVALID, { reportDir: dir });
136
+ assert.equal(r.status, 1);
137
+ const lines = readReport(dir);
138
+ assert.equal(lines.length, 1);
139
+ assert.equal(lines[0].validated, true);
140
+ assert.equal(lines[0].valid, false);
141
+ assert.ok(Array.isArray(lines[0].errors) && lines[0].errors.length > 0);
142
+ });
143
+
144
+ it('writes nothing when the report dir is unset', () => {
145
+ const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
146
+ emit(VALID, { reportDir: dir });
147
+ assert.equal(readReport(dir).length, 1);
148
+ // Same directory, but this emission is never told about it. The count must
149
+ // not move. Asserting on an untouched temp dir would pass either way.
150
+ const r = emit(VALID);
151
+ assert.equal(r.status, 0, r.stderr);
152
+ assert.equal(readReport(dir).length, 1);
153
+ });
154
+
155
+ it('appends across emissions rather than truncating', () => {
156
+ const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
157
+ emit(VALID, { reportDir: dir });
158
+ emit(VALID, { reportDir: dir });
159
+ assert.equal(readReport(dir).length, 2);
160
+ });
161
+
162
+ it('does not record for the validate subcommand', () => {
163
+ const dir = mkdtempSync(join(tmpdir(), 'emit-report-'));
164
+ spawnSync('node', [EMITTER, 'validate'], {
165
+ input: JSON.stringify({ nonsense: true }),
166
+ encoding: 'utf8',
167
+ env: { ...process.env, ONLOOKER_TEST_REPORT_DIR: dir },
168
+ });
169
+ assert.equal(readReport(dir), null);
170
+ });
171
+ });
172
+ ```
173
+
174
+ - [ ] **Step 2: Run the test to verify it fails**
175
+
176
+ Run: `node --test test/node/emission-report.test.mjs`
177
+ Expected: FAIL — every test that reads the report fails, because no report
178
+ file is written yet. `readReport` returns `null` and the `.length` accesses
179
+ throw. That is the correct failure: each test now depends on the report
180
+ actually existing.
181
+
182
+ - [ ] **Step 3: Add `appendFileSync` to the fs import**
183
+
184
+ In `scripts/lib/onlooker-event.mjs`, replace line 7:
185
+
186
+ ```javascript
187
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
188
+ ```
189
+
190
+ with:
191
+
192
+ ```javascript
193
+ import {
194
+ appendFileSync,
195
+ existsSync,
196
+ mkdirSync,
197
+ readFileSync,
198
+ statSync,
199
+ writeFileSync,
200
+ } from 'node:fs';
201
+ ```
202
+
203
+ - [ ] **Step 4: Add `recordEmission` immediately after `tryValidate`**
204
+
205
+ Insert after the closing brace of `tryValidate` (~line 107):
206
+
207
+ ```javascript
208
+ /**
209
+ * Test-only emission report.
210
+ *
211
+ * When ONLOOKER_TEST_REPORT_DIR is set, append one line per emission recording
212
+ * the event type, whether validation actually ran, and whether it passed.
213
+ *
214
+ * This exists because both signals the emitter produces on rejection — a
215
+ * non-zero exit and stderr — are destroyed by the hook's fail-soft exit 0,
216
+ * which leaves a dropped event indistinguishable from one that never fired.
217
+ * The report lives outside the per-test BATS_TEST_TMPDIR so the suite can gate
218
+ * on it after the fact.
219
+ *
220
+ * Production never sets the variable, so nothing is written there and the
221
+ * fail-open contract in ADR-005 is untouched. `validated` is recorded
222
+ * separately from `valid` so a run where the schema package never resolved is
223
+ * distinguishable from a run where everything passed — without it, a missing
224
+ * node_modules would make the gate pass while checking nothing.
225
+ */
226
+ function recordEmission(event, check) {
227
+ const dir = process.env.ONLOOKER_TEST_REPORT_DIR;
228
+ if (!dir) return;
229
+ const record = {
230
+ event_type: event?.event_type ?? null,
231
+ validated: check.available === true,
232
+ valid: check.available === true ? check.valid === true : null,
233
+ };
234
+ if (check.available && !check.valid) record.errors = check.errors;
235
+ try {
236
+ mkdirSync(dir, { recursive: true });
237
+ appendFileSync(join(dir, 'emissions.jsonl'), `${JSON.stringify(record)}\n`);
238
+ } catch {
239
+ // A broken report must never break an emission.
240
+ }
241
+ }
242
+ ```
243
+
244
+ - [ ] **Step 5: Call it from both emit paths — and only those**
245
+
246
+ In the `emit-from-hook` block, after `const check = await tryValidate(mapped.event);`:
247
+
248
+ ```javascript
249
+ const check = await tryValidate(mapped.event);
250
+ recordEmission(mapped.event, check);
251
+ ```
252
+
253
+ In the `emit` block, after `const check = await tryValidate(event);`:
254
+
255
+ ```javascript
256
+ const check = await tryValidate(event);
257
+ recordEmission(event, check);
258
+ ```
259
+
260
+ Do **not** add a call in the `validate` block.
261
+
262
+ - [ ] **Step 6: Run the test to verify it passes**
263
+
264
+ Run: `node --test test/node/emission-report.test.mjs`
265
+ Expected: PASS, 5 tests.
266
+
267
+ - [ ] **Step 7: Confirm production behavior is unchanged**
268
+
269
+ Run: `npm run test:schema && npm run test:bats`
270
+ Expected: PASS. No test sets `ONLOOKER_TEST_REPORT_DIR` yet, so this proves the
271
+ change is inert when the variable is absent.
272
+
273
+ - [ ] **Step 8: Format and commit**
274
+
275
+ ```bash
276
+ ./node_modules/.bin/biome check --write scripts/lib/onlooker-event.mjs test/node/emission-report.test.mjs
277
+ git add scripts/lib/onlooker-event.mjs test/node/emission-report.test.mjs
278
+ ```
279
+
280
+ Then run `/commit` with: emitter records each emission to a test-only report so
281
+ a rejection outlives the hook's fail-soft exit.
282
+
283
+ ---
284
+
285
+ ## Task 2: Gate A — fail on a rejected emission, or on validation never running
286
+
287
+ **Files:**
288
+
289
+ - Create: `scripts/lint/check-bus-coverage.mjs`
290
+ - Test: `test/node/check-bus-coverage.test.mjs`
291
+
292
+ **Interfaces:**
293
+
294
+ - Consumes: the report line shape from Task 1.
295
+ - Produces: a CLI `check-bus-coverage.mjs [--report <dir>]` exiting 0 on pass,
296
+ 1 on gate failure, 2 on bad arguments. Task 5 extends it with `--manifest`.
297
+
298
+ - [ ] **Step 1: Write the failing test**
299
+
300
+ Create `test/node/check-bus-coverage.test.mjs`:
301
+
302
+ ```javascript
303
+ import assert from 'node:assert/strict';
304
+ import { spawnSync } from 'node:child_process';
305
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
306
+ import { tmpdir } from 'node:os';
307
+ import { dirname, join, resolve } from 'node:path';
308
+ import { describe, it } from 'node:test';
309
+ import { fileURLToPath } from 'node:url';
310
+
311
+ const HERE = dirname(fileURLToPath(import.meta.url));
312
+ const REPO_ROOT = resolve(HERE, '..', '..');
313
+ const GATE = join(REPO_ROOT, 'scripts', 'lint', 'check-bus-coverage.mjs');
314
+
315
+ function reportDir(lines) {
316
+ const dir = mkdtempSync(join(tmpdir(), 'bus-report-'));
317
+ mkdirSync(dir, { recursive: true });
318
+ writeFileSync(
319
+ join(dir, 'emissions.jsonl'),
320
+ lines.map((l) => JSON.stringify(l)).join('\n') + (lines.length ? '\n' : ''),
321
+ );
322
+ return dir;
323
+ }
324
+
325
+ function run(dir) {
326
+ const r = spawnSync('node', [GATE, '--report', dir], { encoding: 'utf8' });
327
+ return { code: r.status, stdout: r.stdout, stderr: r.stderr };
328
+ }
329
+
330
+ const OK = { event_type: 'session.start', validated: true, valid: true };
331
+
332
+ describe('check-bus-coverage gate A', () => {
333
+ it('passes when every emission validated', () => {
334
+ const r = run(reportDir([OK, OK]));
335
+ assert.equal(r.code, 0, r.stderr);
336
+ });
337
+
338
+ it('fails on a rejected emission and names the type', () => {
339
+ const bad = {
340
+ event_type: 'librarian.scan.complete',
341
+ validated: true,
342
+ valid: false,
343
+ errors: [{ path: '/outcome', message: 'must be equal to one of the allowed values' }],
344
+ };
345
+ const r = run(reportDir([OK, bad]));
346
+ assert.equal(r.code, 1);
347
+ assert.match(r.stderr, /librarian\.scan\.complete/);
348
+ });
349
+
350
+ it('fails when validation never ran, rather than passing vacuously', () => {
351
+ const unvalidated = { event_type: 'session.start', validated: false, valid: null };
352
+ const r = run(reportDir([unvalidated, unvalidated]));
353
+ assert.equal(r.code, 1);
354
+ assert.match(r.stderr, /did not resolve|never validated|no emission was validated/i);
355
+ });
356
+
357
+ it('fails when the report is missing entirely', () => {
358
+ const r = run(mkdtempSync(join(tmpdir(), 'bus-empty-')));
359
+ assert.equal(r.code, 1);
360
+ assert.match(r.stderr, /no emissions recorded/i);
361
+ });
362
+ });
363
+ ```
364
+
365
+ - [ ] **Step 2: Run the test to verify it fails**
366
+
367
+ Run: `node --test test/node/check-bus-coverage.test.mjs`
368
+ Expected: FAIL — `Cannot find module .../check-bus-coverage.mjs`.
369
+
370
+ - [ ] **Step 3: Write the gate runner**
371
+
372
+ Create `scripts/lint/check-bus-coverage.mjs`:
373
+
374
+ ```javascript
375
+ #!/usr/bin/env node
376
+ /**
377
+ * Bus coverage gates.
378
+ *
379
+ * Gate A: no emission recorded during the suite was rejected by the schema.
380
+ *
381
+ * Reads the report the emitter writes when ONLOOKER_TEST_REPORT_DIR is set —
382
+ * see recordEmission in scripts/lib/onlooker-event.mjs. A rejected emission is
383
+ * invisible any other way: the emitter exits 1 and prints ajv errors, and the
384
+ * hook's fail-soft exit 0 destroys both.
385
+ *
386
+ * Usage: check-bus-coverage.mjs [--report <dir>]
387
+ */
388
+ import { existsSync, readFileSync } from 'node:fs';
389
+ import { dirname, join, resolve } from 'node:path';
390
+ import { fileURLToPath } from 'node:url';
391
+
392
+ const HERE = dirname(fileURLToPath(import.meta.url));
393
+ const REPO_ROOT = resolve(HERE, '..', '..');
394
+
395
+ function parseArgs(argv) {
396
+ const out = { report: join(REPO_ROOT, 'test', 'tmp-emission-report') };
397
+ for (let i = 2; i < argv.length; i += 1) {
398
+ const a = argv[i];
399
+ if (a === '--report') out.report = argv[++i];
400
+ else if (a === '--help') {
401
+ process.stderr.write('Usage: check-bus-coverage.mjs [--report <dir>]\n');
402
+ process.exit(0);
403
+ } else {
404
+ process.stderr.write(`check-bus-coverage: unknown argument: ${a}\n`);
405
+ process.exit(2);
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+
411
+ function loadReport(dir) {
412
+ const p = join(dir, 'emissions.jsonl');
413
+ if (!existsSync(p)) return [];
414
+ return readFileSync(p, 'utf8')
415
+ .trim()
416
+ .split('\n')
417
+ .filter(Boolean)
418
+ .map((l) => JSON.parse(l));
419
+ }
420
+
421
+ function gateA(lines) {
422
+ const failures = [];
423
+ if (lines.length === 0) {
424
+ failures.push(
425
+ 'no emissions recorded — run `npm run test:bats` with ONLOOKER_TEST_REPORT_DIR set',
426
+ );
427
+ return failures;
428
+ }
429
+ if (!lines.some((l) => l.validated === true)) {
430
+ failures.push(
431
+ 'no emission was validated: @onlooker-community/schema did not resolve, so this gate ' +
432
+ 'checked nothing. Run `npm ci` and try again.',
433
+ );
434
+ }
435
+ for (const l of lines.filter((x) => x.valid === false)) {
436
+ failures.push(`rejected emission: ${l.event_type} — ${JSON.stringify(l.errors)}`);
437
+ }
438
+ return failures;
439
+ }
440
+
441
+ function main() {
442
+ const args = parseArgs(process.argv);
443
+ const lines = loadReport(args.report);
444
+ const failures = gateA(lines);
445
+ if (failures.length) {
446
+ for (const f of failures) process.stderr.write(`check-bus-coverage: ${f}\n`);
447
+ process.exit(1);
448
+ }
449
+ process.stdout.write(`check-bus-coverage: ok (${lines.length} emission(s))\n`);
450
+ }
451
+
452
+ const isMain = process.argv[1]?.endsWith('check-bus-coverage.mjs') ?? false;
453
+ if (isMain) main();
454
+ ```
455
+
456
+ - [ ] **Step 4: Run the test to verify it passes**
457
+
458
+ Run: `node --test test/node/check-bus-coverage.test.mjs`
459
+ Expected: PASS, 4 tests.
460
+
461
+ - [ ] **Step 5: Format and commit**
462
+
463
+ ```bash
464
+ ./node_modules/.bin/biome check --write scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs
465
+ git add scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs
466
+ ```
467
+
468
+ Then run `/commit` with: add Gate A, which fails on a rejected emission and on a
469
+ run where validation never happened at all.
470
+
471
+ ---
472
+
473
+ ## Task 3: Keep deliberate rejections out of the report
474
+
475
+ **Files:**
476
+
477
+ - Modify: `test/helpers/setup.bash` (add `expect_emission_rejected`)
478
+ - Create: `test/bats/emission-report-optout.bats`
479
+ - Modify: every bats file with a negative emission assertion — discovered
480
+ empirically in Step 3, not from a fixed list
481
+
482
+ **Interfaces:**
483
+
484
+ - Consumes: the report shape from Task 1; the gate CLI from Task 2.
485
+ - Produces: `expect_emission_rejected <command> [args...]`, a bats helper that
486
+ runs a command expected to fail validation with `ONLOOKER_TEST_REPORT_DIR`
487
+ unset, then restores it. Sets `$status` and `$output` exactly as `run` does.
488
+ Task 4 depends on this existing, or the first wired CI run is red.
489
+
490
+ **Why this task exists:** ADR-005 names the negative tests — "emission fails
491
+ loudly on a bogus event_type" — and the repo has roughly ten. Each deliberately
492
+ emits an invalid event and asserts the emitter rejects it. Without an opt-out
493
+ every one writes a `valid:false` line and Gate A is permanently red from
494
+ intentional tests.
495
+
496
+ Two flavors, and the second is why filtering by registered type does not work:
497
+
498
+ - **Unregistered types**, e.g. `warden.bogus.event`, `bursar.no_such_event`.
499
+ - **Registered types with deliberately bad payloads** —
500
+ `cartographer-events.bats` "the retired pre-implementation vocabulary no
501
+ longer validates" and "a typeless finding puts nothing on the bus" (both
502
+ `cartographer.issue.found`), and `lineage-events.bats` "an invalid tool enum
503
+ is rejected by the schema" (`lineage.change.recorded`). These are
504
+ indistinguishable from real drift, which is exactly what Gate A must catch.
505
+
506
+ Note that "returns 1 when payload is empty" tests need no conversion: the bash
507
+ wrapper returns 1 before reaching the emitter, so nothing is ever recorded.
508
+
509
+ - [ ] **Step 1: Write the failing test**
510
+
511
+ Create `test/bats/emission-report-optout.bats`:
512
+
513
+ ```bash
514
+ #!/usr/bin/env bats
515
+
516
+ setup() {
517
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
518
+ setup_test_env
519
+
520
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/warden"
521
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
522
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
523
+ source "${PLUGIN_ROOT}/scripts/lib/warden-events.sh"
524
+
525
+ export ONLOOKER_TEST_REPORT_DIR="${BATS_TEST_TMPDIR}/report"
526
+ mkdir -p "$ONLOOKER_TEST_REPORT_DIR"
527
+ REPORT="${ONLOOKER_TEST_REPORT_DIR}/emissions.jsonl"
528
+ }
529
+
530
+ _valid_payload() {
531
+ jq -cn '{source_type:"web_fetch", threat_type:"prompt_injection", confidence:0.5}'
532
+ }
533
+
534
+ # Positive control. Without this, the opt-out test below could pass because
535
+ # nothing writes a report at all, rather than because the helper suppressed it.
536
+ @test "a normal emission does write a report line" {
537
+ run warden_emit_event "warden.threat.detected" "$(_valid_payload)"
538
+ [ "$status" -eq 0 ] || return 1
539
+ [ -s "$REPORT" ]
540
+ }
541
+
542
+ @test "expect_emission_rejected keeps a deliberate rejection out of the report" {
543
+ expect_emission_rejected warden_emit_event "warden.bogus.event" "$(_valid_payload)"
544
+ [ "$status" -ne 0 ] || return 1
545
+ [ ! -f "$REPORT" ]
546
+ }
547
+
548
+ @test "expect_emission_rejected restores the report dir afterward" {
549
+ expect_emission_rejected warden_emit_event "warden.bogus.event" "$(_valid_payload)"
550
+ [ "$ONLOOKER_TEST_REPORT_DIR" = "${BATS_TEST_TMPDIR}/report" ] || return 1
551
+ run warden_emit_event "warden.threat.detected" "$(_valid_payload)"
552
+ [ "$status" -eq 0 ] || return 1
553
+ [ -s "$REPORT" ]
554
+ }
555
+ ```
556
+
557
+ - [ ] **Step 2: Run it to verify it fails**
558
+
559
+ Run: `bats test/bats/emission-report-optout.bats`
560
+ Expected: FAIL — `expect_emission_rejected: command not found`. The positive
561
+ control should already pass; if it does not, Task 1 is broken and you should
562
+ stop and report that rather than continuing.
563
+
564
+ - [ ] **Step 3: Add the helper to `test/helpers/setup.bash`**
565
+
566
+ Append after `load_validate_path`:
567
+
568
+ ```bash
569
+ # Run a command that is expected to fail schema validation, without recording
570
+ # the deliberate rejection in the suite-wide emission report.
571
+ #
572
+ # The report exists so a payload that drifts from the schema turns CI red. A
573
+ # test that deliberately emits an invalid payload would otherwise write a
574
+ # valid:false line indistinguishable from real drift, making the gate
575
+ # permanently red from intentional tests. Unsetting the report directory for
576
+ # the duration keeps the negative test honest — it still asserts the emitter
577
+ # rejects — without polluting the gate.
578
+ #
579
+ # Sets $status and $output exactly as bats' `run` does.
580
+ #
581
+ # Usage: expect_emission_rejected <command> [args...]
582
+ expect_emission_rejected() {
583
+ local saved="${ONLOOKER_TEST_REPORT_DIR:-}"
584
+ unset ONLOOKER_TEST_REPORT_DIR
585
+ run "$@"
586
+ if [ -n "$saved" ]; then
587
+ export ONLOOKER_TEST_REPORT_DIR="$saved"
588
+ fi
589
+ }
590
+ ```
591
+
592
+ - [ ] **Step 4: Run the test to verify it passes**
593
+
594
+ Run: `bats test/bats/emission-report-optout.bats`
595
+ Expected: PASS, 3 tests.
596
+
597
+ - [ ] **Step 5: Discover every call site that needs converting**
598
+
599
+ Do not work from a hand-written list — find them empirically, so the set is
600
+ complete by construction:
601
+
602
+ ```bash
603
+ rm -rf /tmp/optout-report
604
+ ONLOOKER_TEST_REPORT_DIR=/tmp/optout-report bats test/bats
605
+ node -e '
606
+ const { readFileSync } = require("node:fs");
607
+ const lines = readFileSync("/tmp/optout-report/emissions.jsonl", "utf8")
608
+ .trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
609
+ const bad = lines.filter((l) => l.valid === false);
610
+ console.log("rejected emissions recorded:", bad.length);
611
+ for (const t of [...new Set(bad.map((l) => l.event_type))].sort()) console.log(" ", t);
612
+ '
613
+ ```
614
+
615
+ Every type printed corresponds to at least one negative test. Locate each with
616
+ `grep -rn '<type>' test/bats/`.
617
+
618
+ - [ ] **Step 6: Convert each negative call site**
619
+
620
+ For each, replace the `run` with `expect_emission_rejected` and leave the
621
+ assertions untouched. For example, in `test/bats/warden-events.bats`:
622
+
623
+ ```bash
624
+ run warden_emit_event "warden.bogus.event" "$p"
625
+ [ "$status" -ne 0 ]
626
+ ```
627
+
628
+ becomes:
629
+
630
+ ```bash
631
+ expect_emission_rejected warden_emit_event "warden.bogus.event" "$p"
632
+ [ "$status" -ne 0 ]
633
+ ```
634
+
635
+ Multi-line invocations keep their continuations — only the leading `run` word
636
+ changes. Do **not** convert the "returns 1 when payload is empty" tests; they
637
+ never reach the emitter.
638
+
639
+ - [ ] **Step 7: Verify no rejection is recorded any more**
640
+
641
+ Re-run the discovery command from Step 5.
642
+ Expected: `rejected emissions recorded: 0`.
643
+
644
+ If any remain, convert those call sites too and repeat until the count is zero.
645
+
646
+ - [ ] **Step 8: Confirm the negative tests still fail for the right reason**
647
+
648
+ The conversion must not have neutered them. Pick
649
+ `test/bats/cartographer-events.bats` "a typeless finding puts nothing on the
650
+ bus", temporarily change its expected status from `-ne 0` to `-eq 0`, and
651
+ confirm the test now FAILS. Restore it afterward.
652
+
653
+ This is the check that matters: a helper that silently swallowed the failure
654
+ would leave the test passing either way, which is worse than no test.
655
+
656
+ - [ ] **Step 9: Run the full suite**
657
+
658
+ Run: `npm run test:bats && npm run test:schema`
659
+ Expected: PASS, same test count as before your changes plus the 3 new ones.
660
+
661
+ - [ ] **Step 10: Commit**
662
+
663
+ ```bash
664
+ git add test/helpers/setup.bash test/bats/emission-report-optout.bats test/bats/
665
+ ```
666
+
667
+ Then run `/commit` with: keep deliberate rejections out of the emission report
668
+ so the gate stays red only for real drift.
669
+
670
+ ---
671
+
672
+ ## Task 4: Wire the report and the gate into the test scripts
673
+
674
+ **Files:**
675
+
676
+ - Modify: `package.json` (`test:bats`, `test:schema`, new `test:bus`, `test:ci`)
677
+ - Modify: `.gitignore`
678
+
679
+ **Interfaces:**
680
+
681
+ - Consumes: the gate CLI from Task 2, and `expect_emission_rejected` from
682
+ Task 3 — without it this task's first green run is impossible, because the
683
+ negative tests would fill the report with deliberate rejections.
684
+ - Produces: `test/tmp-emission-report/emissions.jsonl` populated by a real suite
685
+ run. Task 5 reads it to bootstrap the manifest.
686
+
687
+ - [ ] **Step 1: Ignore the report directory**
688
+
689
+ Add to `.gitignore` beneath the existing `test/tmp-*` entries:
690
+
691
+ ```gitignore
692
+ test/tmp-emission-report/
693
+ ```
694
+
695
+ - [ ] **Step 2: Update the test scripts**
696
+
697
+ In `package.json`, replace `test:bats` and `test:schema` and add `test:bus`:
698
+
699
+ ```json
700
+ "test:bats": "rm -rf \"$PWD/test/tmp-emission-report\" && ONLOOKER_TEST_REPORT_DIR=\"$PWD/test/tmp-emission-report\" bats test/bats",
701
+ "test:schema": "ONLOOKER_TEST_REPORT_DIR=\"$PWD/test/tmp-emission-report\" node --test test/node/*.test.mjs",
702
+ "test:bus": "node scripts/lint/check-bus-coverage.mjs",
703
+ ```
704
+
705
+ `$PWD` is absolute; a relative path would break because hooks run the emitter
706
+ from arbitrary working directories. `test:bats` clears the report so a stale
707
+ run cannot mask a regression; `test:schema` appends to it.
708
+
709
+ - [ ] **Step 3: Sequence the gate after both suites in `test:ci`**
710
+
711
+ Replace `test:ci` with:
712
+
713
+ ```json
714
+ "test:ci": "npm run test:shellcheck && npm run test:bats && npm run test:schema && npm run test:bus && npm run lint:check && npm run lint:manifests && npm run lint:references && npm run lint:lesson-schema",
715
+ ```
716
+
717
+ `test:bus` runs after both suites so emissions from each are counted. It is
718
+ deliberately not folded into `test:schema`: a node test that skipped when the
719
+ report was absent would pass vacuously for anyone running `test:schema` alone,
720
+ which is the exact failure mode under repair.
721
+
722
+ - [ ] **Step 4: Verify the report is actually produced**
723
+
724
+ Run: `npm run test:bats && npm run test:schema`
725
+ Then: `wc -l test/tmp-emission-report/emissions.jsonl`
726
+ Expected: a non-zero line count. If the file is missing, the environment
727
+ variable is not reaching the emitter — check that `$PWD` expanded.
728
+
729
+ - [ ] **Step 5: Verify Gate A passes on the real suite**
730
+
731
+ Run: `npm run test:bus`
732
+ Expected: `check-bus-coverage: ok (N emission(s))`, exit 0.
733
+
734
+ If it reports a rejected emission, that is a real pre-existing bug — likely
735
+ `ecosystem-ci0`. Stop and report it rather than working around the gate.
736
+
737
+ - [ ] **Step 6: Break it on purpose**
738
+
739
+ Temporarily append a rejected line and confirm the gate goes red:
740
+
741
+ ```bash
742
+ echo '{"event_type":"fake.bad.event","validated":true,"valid":false,"errors":[{"path":"/x","message":"nope"}]}' \
743
+ >> test/tmp-emission-report/emissions.jsonl
744
+ npm run test:bus; echo "EXIT=$?"
745
+ ```
746
+
747
+ Expected: EXIT=1, stderr naming `fake.bad.event`. Then re-run
748
+ `npm run test:bats && npm run test:schema` to restore a clean report.
749
+
750
+ - [ ] **Step 7: Commit**
751
+
752
+ ```bash
753
+ git add package.json .gitignore
754
+ ```
755
+
756
+ Then run `/commit` with: produce the emission report during the suite and gate
757
+ on it in CI.
758
+
759
+ ---
760
+
761
+ ## Task 5: The manifest and Gate B
762
+
763
+ **Files:**
764
+
765
+ - Create: `test/bus-coverage.json`
766
+ - Modify: `scripts/lint/check-bus-coverage.mjs`
767
+ - Modify: `test/node/check-bus-coverage.test.mjs`
768
+
769
+ **Interfaces:**
770
+
771
+ - Consumes: the populated report from Task 4; `ALL_EVENT_TYPES` from
772
+ `@onlooker-community/schema` (125 entries at time of writing).
773
+ - Produces: `test/bus-coverage.json` with `{ expected: string[], excluded:
774
+ Record<string, string> }`, and a `--manifest <path>` flag on the gate CLI.
775
+
776
+ - [ ] **Step 1: Generate the manifest skeleton from a real run**
777
+
778
+ With a clean report present from Task 4, run:
779
+
780
+ ```bash
781
+ node -e "
782
+ import('@onlooker-community/schema').then(async (m) => {
783
+ const { readFileSync } = await import('node:fs');
784
+ const lines = readFileSync('test/tmp-emission-report/emissions.jsonl', 'utf8')
785
+ .trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
786
+ const seen = new Set(lines.filter((l) => l.valid === true).map((l) => l.event_type));
787
+ const expected = m.ALL_EVENT_TYPES.filter((t) => seen.has(t)).sort();
788
+ const excluded = {};
789
+ for (const t of m.ALL_EVENT_TYPES.filter((t) => !seen.has(t)).sort()) excluded[t] = 'FILL IN';
790
+ console.log(JSON.stringify({ expected, excluded }, null, 2));
791
+ });" > test/bus-coverage.json
792
+ ```
793
+
794
+ - [ ] **Step 2: Replace every `FILL IN` with a real reason**
795
+
796
+ Open `test/bus-coverage.json` and write a specific reason for each exclusion.
797
+ Use these exact reasons for the known groups — they were established during
798
+ design:
799
+
800
+ | Prefix | Reason string |
801
+ |--------|---------------|
802
+ | `meridian.*`, `sentinel.*`, `oracle.*`, `relay.*` | `plugin lives in another repo` |
803
+ | `onlooker.session.summary` | `emitted by the agent, not this repo` |
804
+ | `curator.*` | `curator check deferred; see plugins/curator/README.md` |
805
+ | `compass.*` | `compass has no implementation yet; design phase` |
806
+ | anything else | one line saying which branch would emit it and why no test reaches that branch yet |
807
+
808
+ No reason may remain `FILL IN`; Step 5's test enforces that.
809
+
810
+ - [ ] **Step 3: Write the failing Gate B tests**
811
+
812
+ Append to `test/node/check-bus-coverage.test.mjs`:
813
+
814
+ ```javascript
815
+ import { ALL_EVENT_TYPES } from '@onlooker-community/schema';
816
+
817
+ function manifestFile(manifest) {
818
+ const dir = mkdtempSync(join(tmpdir(), 'bus-manifest-'));
819
+ const p = join(dir, 'bus-coverage.json');
820
+ writeFileSync(p, JSON.stringify(manifest, null, 2));
821
+ return p;
822
+ }
823
+
824
+ function runWith(dir, manifestPath) {
825
+ const r = spawnSync('node', [GATE, '--report', dir, '--manifest', manifestPath], {
826
+ encoding: 'utf8',
827
+ });
828
+ return { code: r.status, stdout: r.stdout, stderr: r.stderr };
829
+ }
830
+
831
+ // A manifest that accounts for all 125 types, expecting only session.start.
832
+ function fullManifest(expected = ['session.start']) {
833
+ const excluded = {};
834
+ for (const t of ALL_EVENT_TYPES) {
835
+ if (!expected.includes(t)) excluded[t] = 'not emitted in tests';
836
+ }
837
+ return { expected, excluded };
838
+ }
839
+
840
+ describe('check-bus-coverage gate B', () => {
841
+ it('passes when every expected type has a validated emission', () => {
842
+ const r = runWith(reportDir([OK]), manifestFile(fullManifest()));
843
+ assert.equal(r.code, 0, r.stderr);
844
+ });
845
+
846
+ it('fails when an expected type never appeared', () => {
847
+ const m = fullManifest(['session.start', 'session.end']);
848
+ const r = runWith(reportDir([OK]), manifestFile(m));
849
+ assert.equal(r.code, 1);
850
+ assert.match(r.stderr, /session\.end/);
851
+ });
852
+
853
+ it('fails when a registered type is in neither list', () => {
854
+ const m = fullManifest();
855
+ delete m.excluded[ALL_EVENT_TYPES.find((t) => t !== 'session.start')];
856
+ const r = runWith(reportDir([OK]), manifestFile(m));
857
+ assert.equal(r.code, 1);
858
+ assert.match(r.stderr, /accounted for|neither/i);
859
+ });
860
+
861
+ it('fails when the manifest names a type the schema does not register', () => {
862
+ const m = fullManifest();
863
+ m.excluded['not.a.real.type'] = 'bogus';
864
+ const r = runWith(reportDir([OK]), manifestFile(m));
865
+ assert.equal(r.code, 1);
866
+ assert.match(r.stderr, /not\.a\.real\.type/);
867
+ });
868
+
869
+ it('fails when an exclusion has an empty reason', () => {
870
+ const m = fullManifest();
871
+ m.excluded[Object.keys(m.excluded)[0]] = '';
872
+ const r = runWith(reportDir([OK]), manifestFile(m));
873
+ assert.equal(r.code, 1);
874
+ assert.match(r.stderr, /reason/i);
875
+ });
876
+
877
+ it('the committed manifest accounts for every registered type', () => {
878
+ const committed = JSON.parse(
879
+ readFileSync(join(REPO_ROOT, 'test', 'bus-coverage.json'), 'utf8'),
880
+ );
881
+ const accounted = new Set([...committed.expected, ...Object.keys(committed.excluded)]);
882
+ const missing = ALL_EVENT_TYPES.filter((t) => !accounted.has(t));
883
+ assert.deepEqual(missing, [], `unaccounted event types: ${missing.join(', ')}`);
884
+ for (const [type, reason] of Object.entries(committed.excluded)) {
885
+ assert.ok(reason && reason.trim() && reason !== 'FILL IN', `${type} needs a real reason`);
886
+ }
887
+ });
888
+ });
889
+ ```
890
+
891
+ Add `readFileSync` to the `node:fs` import at the top of the file.
892
+
893
+ - [ ] **Step 4: Run the tests to verify they fail**
894
+
895
+ Run: `node --test test/node/check-bus-coverage.test.mjs`
896
+ Expected: FAIL — the gate ignores `--manifest` and exits 2 on an unknown
897
+ argument.
898
+
899
+ - [ ] **Step 5: Implement Gate B**
900
+
901
+ In `scripts/lint/check-bus-coverage.mjs`, add `--manifest` to `parseArgs`:
902
+
903
+ ```javascript
904
+ const out = {
905
+ report: join(REPO_ROOT, 'test', 'tmp-emission-report'),
906
+ manifest: join(REPO_ROOT, 'test', 'bus-coverage.json'),
907
+ };
908
+ ```
909
+
910
+ and inside the loop, before the `--help` branch:
911
+
912
+ ```javascript
913
+ else if (a === '--manifest') out.manifest = argv[++i];
914
+ ```
915
+
916
+ Add the gate itself:
917
+
918
+ ```javascript
919
+ /**
920
+ * Gate B: every registered event type is accounted for.
921
+ *
922
+ * `expected` types must have produced a validated emission during the suite.
923
+ * `excluded` types must carry a reason. Together the two lists must equal
924
+ * ALL_EVENT_TYPES exactly, so a newly registered type belongs to neither and
925
+ * fails here until someone triages it deliberately.
926
+ */
927
+ async function gateB(lines, manifestPath) {
928
+ const failures = [];
929
+ let schema;
930
+ try {
931
+ schema = await import('@onlooker-community/schema');
932
+ } catch {
933
+ return ['@onlooker-community/schema is not installed; run `npm ci`'];
934
+ }
935
+ const registered = new Set(schema.ALL_EVENT_TYPES);
936
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
937
+ const expected = manifest.expected ?? [];
938
+ const excluded = manifest.excluded ?? {};
939
+
940
+ const emitted = new Set(lines.filter((l) => l.valid === true).map((l) => l.event_type));
941
+ for (const t of expected) {
942
+ if (!emitted.has(t)) failures.push(`expected type never emitted during the suite: ${t}`);
943
+ }
944
+
945
+ const accounted = new Set([...expected, ...Object.keys(excluded)]);
946
+ for (const t of registered) {
947
+ if (!accounted.has(t)) {
948
+ failures.push(`registered type is in neither list — triage it in the manifest: ${t}`);
949
+ }
950
+ }
951
+ for (const t of accounted) {
952
+ if (!registered.has(t)) {
953
+ failures.push(`manifest names a type the schema does not register: ${t}`);
954
+ }
955
+ }
956
+ for (const [t, reason] of Object.entries(excluded)) {
957
+ if (!reason || !String(reason).trim()) {
958
+ failures.push(`excluded type needs a reason: ${t}`);
959
+ }
960
+ }
961
+ return failures;
962
+ }
963
+ ```
964
+
965
+ Replace the existing `main` function and the `isMain` block at the bottom of
966
+ the file — do not append a second copy:
967
+
968
+ ```javascript
969
+ async function main() {
970
+ const args = parseArgs(process.argv);
971
+ const lines = loadReport(args.report);
972
+ const failures = gateA(lines);
973
+ // Skip Gate B when nothing was recorded. Every expected type would report
974
+ // as missing, burying the single failure that actually matters.
975
+ if (lines.length > 0) failures.push(...(await gateB(lines, args.manifest)));
976
+ if (failures.length) {
977
+ for (const f of failures) process.stderr.write(`check-bus-coverage: ${f}\n`);
978
+ process.exit(1);
979
+ }
980
+ process.stdout.write(`check-bus-coverage: ok (${lines.length} emission(s))\n`);
981
+ }
982
+
983
+ const isMain = process.argv[1]?.endsWith('check-bus-coverage.mjs') ?? false;
984
+ if (isMain) {
985
+ main().catch((err) => {
986
+ process.stderr.write(`check-bus-coverage: ${err.message}\n`);
987
+ process.exit(1);
988
+ });
989
+ }
990
+ ```
991
+
992
+ - [ ] **Step 6: Run the tests to verify they pass**
993
+
994
+ Run: `node --test test/node/check-bus-coverage.test.mjs`
995
+ Expected: PASS, 10 tests.
996
+
997
+ - [ ] **Step 7: Run the whole gate against the real suite**
998
+
999
+ Run: `npm run test:ci`
1000
+ Expected: PASS end to end, including `test:bus`.
1001
+
1002
+ - [ ] **Step 8: Break it on purpose**
1003
+
1004
+ Move one type from `expected` into `excluded` in `test/bus-coverage.json`, run
1005
+ `npm run test:bus`, and confirm it fails naming that type — the manifest must
1006
+ not be able to silently under-claim. Restore the file afterward.
1007
+
1008
+ - [ ] **Step 9: Format and commit**
1009
+
1010
+ ```bash
1011
+ ./node_modules/.bin/biome check --write scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs test/bus-coverage.json
1012
+ git add test/bus-coverage.json scripts/lint/check-bus-coverage.mjs test/node/check-bus-coverage.test.mjs
1013
+ ```
1014
+
1015
+ Then run `/commit` with: require every registered event type to be either
1016
+ covered by a test or excluded with a stated reason.
1017
+
1018
+ ---
1019
+
1020
+ ## Task 6: Documentation
1021
+
1022
+ **Files:**
1023
+
1024
+ - Modify: `docs/architecture.md`
1025
+ - Modify: `CLAUDE.md`
1026
+ - Modify: `.claude/skills/writing-tests/SKILL.md`
1027
+
1028
+ **Interfaces:**
1029
+
1030
+ - Consumes: the finished harness from Tasks 1–5. Produces no code.
1031
+
1032
+ - [ ] **Step 1: Document the gates in `docs/architecture.md`**
1033
+
1034
+ Add a subsection to the event-bus discussion:
1035
+
1036
+ ```markdown
1037
+ ### Emission gates
1038
+
1039
+ Payload drift used to be invisible. The emitter validates against
1040
+ `@onlooker-community/schema` wherever it resolves and rejects a bad event with
1041
+ a non-zero exit, but hooks fail soft and exit 0, so the rejection was destroyed
1042
+ and the event simply never appeared.
1043
+
1044
+ Two CI gates close that hole. During the test suite `ONLOOKER_TEST_REPORT_DIR`
1045
+ is set, and the emitter appends one line per emission to
1046
+ `emissions.jsonl` recording whether validation ran and whether it passed.
1047
+ `npm run test:bus` then fails if any emission was rejected, if validation never
1048
+ ran at all, or if a registered event type appears in neither list of
1049
+ `test/bus-coverage.json`.
1050
+
1051
+ Adding an event type therefore requires triaging it into that manifest — as
1052
+ `expected`, meaning a test exercises the branch that emits it, or as
1053
+ `excluded` with a stated reason.
1054
+ ```
1055
+
1056
+ - [ ] **Step 2: Add the step to the plugin checklist in `CLAUDE.md`**
1057
+
1058
+ In the "Adding a new plugin" numbered list, after the step about registering
1059
+ event types in `@onlooker-community/schema`, add:
1060
+
1061
+ ```markdown
1062
+ 6. Triage every new event type into `test/bus-coverage.json` — `expected` when
1063
+ a test drives the branch that emits it, `excluded` with a reason when not.
1064
+ `npm run test:bus` fails on any registered type that appears in neither list.
1065
+ ```
1066
+
1067
+ Renumber the steps that follow.
1068
+
1069
+ - [ ] **Step 3: Note the suite-wide guarantee in the testing skill**
1070
+
1071
+ In `.claude/skills/writing-tests/SKILL.md`, under "Assert against the event
1072
+ log", add:
1073
+
1074
+ ```markdown
1075
+ You no longer need a bespoke per-plugin test proving a payload validates. The
1076
+ suite gates every emission at once: `ONLOOKER_TEST_REPORT_DIR` is set during
1077
+ `test:bats` and `test:schema`, and `npm run test:bus` fails on any rejected
1078
+ emission. Write the test that drives the branch; the gate does the validating.
1079
+
1080
+ What still matters is exercising the *rare* branches. A payload is only checked
1081
+ when some test makes the code emit it, so an enum bug on an error path stays
1082
+ invisible until a test reaches that path.
1083
+ ```
1084
+
1085
+ - [ ] **Step 4: Lint the docs**
1086
+
1087
+ Run: `npm run lint:check`
1088
+ Expected: PASS.
1089
+
1090
+ - [ ] **Step 5: Commit**
1091
+
1092
+ ```bash
1093
+ git add docs/architecture.md CLAUDE.md .claude/skills/writing-tests/SKILL.md
1094
+ ```
1095
+
1096
+ Then run `/commit` with: document the emission gates and the manifest step for
1097
+ new plugins.
1098
+
1099
+ ---
1100
+
1101
+ ## Acceptance
1102
+
1103
+ Verified by running `npm run test:ci` from a clean tree:
1104
+
1105
+ - [ ] `test:bus` runs after `test:bats` and `test:schema`, and fails when the
1106
+ report is missing.
1107
+ - [ ] Gate A fails on a rejected emission, naming the type and its ajv errors.
1108
+ - [ ] Gate A fails when no emission was validated, rather than passing green.
1109
+ - [ ] Gate B fails when a registered type is in neither manifest list.
1110
+ - [ ] Gate B fails when an exclusion carries an empty reason.
1111
+ - [ ] Emitting with `ONLOOKER_TEST_REPORT_DIR` unset writes no report and
1112
+ leaves exit codes unchanged.
1113
+ - [ ] `plugins/cartographer`'s `finding_type:"unknown"` payload
1114
+ (`ecosystem-ci0`) is caught by Gate A once a test exercises that branch,
1115
+ with no bespoke test written for it.