@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,206 @@
1
+ #!/usr/bin/env node
2
+ // Managed-block fence checker.
3
+ //
4
+ // Tools like `bd setup` own the text between a `<!-- BEGIN ... -->` and
5
+ // `<!-- END ... -->` marker pair and rewrite it verbatim on every run. Any
6
+ // `markdownlint --fix` we apply inside such a block is silently undone the next
7
+ // time anyone regenerates it. Because CI runs `lint:check` (markdownlint with
8
+ // no `--fix`), that churn lands as a red build that looks unrelated to whatever
9
+ // the person was actually doing.
10
+ //
11
+ // The fix is to fence each block with markdownlint-disable/enable comments
12
+ // placed *outside* the BEGIN/END markers, so the generator cannot clobber them.
13
+ // This script asserts that every managed block in every tracked markdown file
14
+ // carries such a fence, covering the rules the generated text is known to trip.
15
+ //
16
+ // Exit codes:
17
+ // 0 ok
18
+ // 1 one or more unfenced or malformed managed blocks
19
+ // 2 setup/usage error
20
+ //
21
+ // Flags:
22
+ // --root <path> override the repo root (used by the tests)
23
+
24
+ import { execFileSync } from 'node:child_process';
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { dirname, join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ // Rules the `bd`-generated blocks trip. MD034 fires because the generator
30
+ // writes bare URLs where markdownlint wants angle brackets; MD012 because it
31
+ // leaves consecutive blank lines; MD024 because two different `bd` subcommands
32
+ // each emit a "Beads Issue Tracker" heading into the same file.
33
+ const REQUIRED_RULES = ['MD012', 'MD024', 'MD034'];
34
+
35
+ const BEGIN_MARKER = /^<!--\s*BEGIN\s+\S.*-->\s*$/;
36
+ const END_MARKER = /^<!--\s*END\s+\S.*-->\s*$/;
37
+ const DISABLE_COMMENT = /^<!--\s*markdownlint-disable\s+(.*?)\s*-->\s*$/;
38
+ const ENABLE_COMMENT = /^<!--\s*markdownlint-enable\s+(.*?)\s*-->\s*$/;
39
+
40
+ function parseArgs(argv) {
41
+ const args = { root: null };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ if (argv[i] === '--root') {
44
+ args.root = argv[++i];
45
+ if (!args.root) usageError('--root requires a path');
46
+ } else {
47
+ usageError(`unknown argument: ${argv[i]}`);
48
+ }
49
+ }
50
+ return args;
51
+ }
52
+
53
+ function usageError(message) {
54
+ process.stderr.write(`check-managed-blocks: ${message}\n`);
55
+ process.exit(2);
56
+ }
57
+
58
+ // Minimal .markdownlintignore support, matching the two pattern shapes the file
59
+ // actually uses: a directory prefix ("node_modules/") and a recursive basename
60
+ // glob ("**/CHANGELOG.md"). Anything else is compared literally. Kept
61
+ // deliberately small -- if the ignore file grows richer patterns, reach for a
62
+ // real glob matcher rather than extending this.
63
+ function loadIgnorePatterns(root) {
64
+ const ignorePath = join(root, '.markdownlintignore');
65
+ if (!existsSync(ignorePath)) return [];
66
+ return readFileSync(ignorePath, 'utf8')
67
+ .split('\n')
68
+ .map((line) => line.trim())
69
+ .filter((line) => line !== '' && !line.startsWith('#'));
70
+ }
71
+
72
+ function isIgnored(relPath, patterns) {
73
+ for (const pattern of patterns) {
74
+ if (pattern.endsWith('/')) {
75
+ if (relPath === pattern.slice(0, -1) || relPath.startsWith(pattern)) return true;
76
+ } else if (pattern.startsWith('**/')) {
77
+ const basename = pattern.slice(3);
78
+ if (relPath === basename || relPath.endsWith(`/${basename}`)) return true;
79
+ } else if (relPath === pattern) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ function trackedMarkdownFiles(root) {
87
+ let stdout;
88
+ try {
89
+ stdout = execFileSync('git', ['ls-files', '-z', '*.md'], { cwd: root, encoding: 'utf8' });
90
+ } catch (err) {
91
+ usageError(`could not list tracked files under ${root}: ${err.message}`);
92
+ }
93
+ return stdout.split('\0').filter((path) => path !== '');
94
+ }
95
+
96
+ function rulesFrom(match) {
97
+ return match[1].split(/\s+/).filter((rule) => rule !== '');
98
+ }
99
+
100
+ function missingRules(declared) {
101
+ return REQUIRED_RULES.filter((rule) => !declared.includes(rule));
102
+ }
103
+
104
+ // Walks one file and reports every managed block that is not correctly fenced.
105
+ function checkFile(relPath, contents, errors) {
106
+ const lines = contents.split('\n');
107
+
108
+ for (let i = 0; i < lines.length; i++) {
109
+ if (!BEGIN_MARKER.test(lines[i])) continue;
110
+
111
+ const beginLine = lines[i];
112
+ const beginNo = i + 1;
113
+ const label = `${relPath}:${beginNo}`;
114
+
115
+ // Locate the matching END before judging the fence, so an unterminated
116
+ // block is reported as exactly one error rather than cascading.
117
+ let endIndex = -1;
118
+ for (let j = i + 1; j < lines.length; j++) {
119
+ if (BEGIN_MARKER.test(lines[j])) break; // nested/overlapping: stop looking
120
+ if (END_MARKER.test(lines[j])) {
121
+ endIndex = j;
122
+ break;
123
+ }
124
+ }
125
+
126
+ if (endIndex === -1) {
127
+ errors.push(`${label}: managed block "${beginLine.trim()}" has no matching <!-- END ... --> marker`);
128
+ continue;
129
+ }
130
+
131
+ const before = i > 0 ? lines[i - 1] : '';
132
+ const disable = before.match(DISABLE_COMMENT);
133
+ if (!disable) {
134
+ errors.push(
135
+ `${label}: managed block is not fenced -- the line directly above it must be ` +
136
+ `"<!-- markdownlint-disable ${REQUIRED_RULES.join(' ')} -->", found ${JSON.stringify(before)}`,
137
+ );
138
+ } else {
139
+ const missing = missingRules(rulesFrom(disable));
140
+ if (missing.length > 0) {
141
+ errors.push(`${label}: markdownlint-disable above this block omits ${missing.join(', ')}`);
142
+ }
143
+ }
144
+
145
+ const after = endIndex + 1 < lines.length ? lines[endIndex + 1] : '';
146
+ const enable = after.match(ENABLE_COMMENT);
147
+ if (!enable) {
148
+ errors.push(
149
+ `${relPath}:${endIndex + 1}: managed block is not closed -- the line directly below its END marker ` +
150
+ `must be "<!-- markdownlint-enable ${REQUIRED_RULES.join(' ')} -->", found ${JSON.stringify(after)}`,
151
+ );
152
+ } else {
153
+ const missing = missingRules(rulesFrom(enable));
154
+ if (missing.length > 0) {
155
+ errors.push(`${relPath}:${endIndex + 2}: markdownlint-enable below this block omits ${missing.join(', ')}`);
156
+ }
157
+ }
158
+
159
+ i = endIndex;
160
+ }
161
+ }
162
+
163
+ function main() {
164
+ const args = parseArgs(process.argv.slice(2));
165
+ const here = dirname(fileURLToPath(import.meta.url));
166
+ const root = resolve(args.root ?? join(here, '..', '..'));
167
+
168
+ if (!existsSync(root)) usageError(`root does not exist: ${root}`);
169
+
170
+ const ignorePatterns = loadIgnorePatterns(root);
171
+ const errors = [];
172
+ let blockCount = 0;
173
+ let fileCount = 0;
174
+
175
+ for (const relPath of trackedMarkdownFiles(root)) {
176
+ if (isIgnored(relPath, ignorePatterns)) continue;
177
+
178
+ const absPath = join(root, relPath);
179
+ if (!existsSync(absPath)) continue; // staged deletion; nothing to check
180
+
181
+ const contents = readFileSync(absPath, 'utf8');
182
+ if (!contents.includes('<!--')) continue; // fast path: no comments, no markers
183
+
184
+ checkFile(relPath, contents, errors);
185
+
186
+ const found = (contents.match(new RegExp(BEGIN_MARKER.source, 'gm')) ?? []).length;
187
+ if (found > 0) {
188
+ fileCount++;
189
+ blockCount += found;
190
+ }
191
+ }
192
+
193
+ for (const e of errors) process.stderr.write(`error: ${e}\n`);
194
+
195
+ if (errors.length > 0) {
196
+ process.stderr.write(
197
+ `check-managed-blocks: ${errors.length} error(s) across ${blockCount} managed block(s)\n` +
198
+ 'Fence generated blocks so `bd setup` cannot undo `markdownlint --fix`. See ecosystem-55g.\n',
199
+ );
200
+ process.exit(1);
201
+ }
202
+
203
+ process.stdout.write(`check-managed-blocks: ok (${blockCount} managed block(s) in ${fileCount} file(s))\n`);
204
+ }
205
+
206
+ main();
@@ -89,7 +89,7 @@ AUDIT_ID="01J0000000000000000000AB34"
89
89
  }
90
90
 
91
91
  @test "emission fails on unknown event type" {
92
- run assayer_emit_event "assayer.no.such.event" '{"audit_id":"x"}'
92
+ expect_emission_rejected assayer_emit_event "assayer.no.such.event" '{"audit_id":"x"}'
93
93
  [ "$status" -ne 0 ]
94
94
  }
95
95
 
@@ -68,6 +68,6 @@ _validate_latest_event() {
68
68
  }
69
69
 
70
70
  @test "bursar_emit_event returns nonzero for an unknown event type" {
71
- run bursar_emit_event "bursar.no_such_event" '{"project_key":"x"}' "$SID"
71
+ expect_emission_rejected bursar_emit_event "bursar.no_such_event" '{"project_key":"x"}' "$SID"
72
72
  [ "$status" -ne 0 ]
73
73
  }
@@ -147,7 +147,7 @@ _finding() {
147
147
  _require_cartographer_schema
148
148
  # Guards the direction of the fix: if someone "restores" the old schema,
149
149
  # this is the test that objects.
150
- run cartographer_emit_event "cartographer.issue.found" \
150
+ expect_emission_rejected cartographer_emit_event "cartographer.issue.found" \
151
151
  '{"issue_type":"orphaned_plugin","file_path":"CLAUDE.md","severity":"warning"}'
152
152
  [ "$status" -ne 0 ]
153
153
  }
@@ -257,7 +257,7 @@ _finding() {
257
257
  }
258
258
 
259
259
  @test "emission fails on unknown event type" {
260
- run cartographer_emit_event "cartographer.no.such.event" '{"audit_id":"x"}'
260
+ expect_emission_rejected cartographer_emit_event "cartographer.no.such.event" '{"audit_id":"x"}'
261
261
  [ "$status" -ne 0 ]
262
262
  }
263
263
 
@@ -111,7 +111,7 @@ TEST_ID="01J000000000000000000000TT"
111
111
  }
112
112
 
113
113
  @test "emission fails on unknown event type" {
114
- run echo_emit_event "echo.no.such.event" '{"suite_id":"x"}'
114
+ expect_emission_rejected echo_emit_event "echo.no.such.event" '{"suite_id":"x"}'
115
115
  [ "$status" -ne 0 ]
116
116
  }
117
117
 
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bats
2
+
3
+ setup() {
4
+ source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
+ setup_test_env
6
+
7
+ PLUGIN_ROOT="${REPO_ROOT}/plugins/warden"
8
+ export CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT"
9
+ export ONLOOKER_ECOSYSTEM_ROOT="$REPO_ROOT"
10
+ source "${PLUGIN_ROOT}/scripts/lib/warden-events.sh"
11
+
12
+ export ONLOOKER_TEST_REPORT_DIR="${BATS_TEST_TMPDIR}/report"
13
+ mkdir -p "$ONLOOKER_TEST_REPORT_DIR"
14
+ REPORT="${ONLOOKER_TEST_REPORT_DIR}/emissions.jsonl"
15
+ }
16
+
17
+ _valid_payload() {
18
+ jq -cn '{source_type:"web_fetch", threat_type:"prompt_injection", confidence:0.5}'
19
+ }
20
+
21
+ # Positive control. Without this, the opt-out test below could pass because
22
+ # nothing writes a report at all, rather than because the helper suppressed it.
23
+ @test "a normal emission does write a report line" {
24
+ run warden_emit_event "warden.threat.detected" "$(_valid_payload)"
25
+ [ "$status" -eq 0 ] || return 1
26
+ [ -s "$REPORT" ]
27
+ }
28
+
29
+ @test "expect_emission_rejected keeps a deliberate rejection out of the report" {
30
+ expect_emission_rejected warden_emit_event "warden.bogus.event" "$(_valid_payload)"
31
+ [ "$status" -ne 0 ] || return 1
32
+ [ ! -f "$REPORT" ]
33
+ }
34
+
35
+ @test "expect_emission_rejected restores the report dir afterward" {
36
+ expect_emission_rejected warden_emit_event "warden.bogus.event" "$(_valid_payload)"
37
+ [ "$ONLOOKER_TEST_REPORT_DIR" = "${BATS_TEST_TMPDIR}/report" ] || return 1
38
+ run warden_emit_event "warden.threat.detected" "$(_valid_payload)"
39
+ [ "$status" -eq 0 ] || return 1
40
+ [ -s "$REPORT" ]
41
+ }
@@ -233,6 +233,6 @@ AID="bats-agent-000"
233
233
  }
234
234
 
235
235
  @test "governor_emit_event returns nonzero for unknown event type" {
236
- run governor_emit_event "governor.no_such_event" '{"session_id":"x"}'
236
+ expect_emission_rejected governor_emit_event "governor.no_such_event" '{"session_id":"x"}'
237
237
  [ "$status" -ne 0 ]
238
238
  }
@@ -146,7 +146,7 @@ _validate_latest_event() {
146
146
  }
147
147
 
148
148
  @test "emission rejects an unknown event type" {
149
- run inspector_emit_event "inspector.no.such.event" '{"file_path":"x"}'
149
+ expect_emission_rejected inspector_emit_event "inspector.no.such.event" '{"file_path":"x"}'
150
150
  [ "$status" -ne 0 ]
151
151
  }
152
152
 
@@ -329,7 +329,7 @@ _scan_complete() {
329
329
  source "${PLUGIN_ROOT}/scripts/lib/librarian-emit.sh"
330
330
  mkdir -p "$(dirname "$ONLOOKER_EVENTS_LOG")"
331
331
 
332
- librarian_emit "librarian.scan.complete" "sess-bad" \
332
+ expect_emission_rejected librarian_emit "librarian.scan.complete" "sess-bad" \
333
333
  '{"outcome":"gave_up","duration_ms":5}'
334
334
 
335
335
  # A refused payload never reaches the log, so the file may not exist at all.
@@ -71,11 +71,11 @@ _validate_latest_event() {
71
71
  local p
72
72
  p=$(jq -n --arg pk "$PK" --arg sid "$SID" \
73
73
  '{project_key:$pk, session_id:$sid, file_path:"x", tool:"NotebookEdit", operation:"edit"}')
74
- run lineage_emit_event "lineage.change.recorded" "$p" "$SID"
74
+ expect_emission_rejected lineage_emit_event "lineage.change.recorded" "$p" "$SID"
75
75
  [ "$status" -ne 0 ]
76
76
  }
77
77
 
78
78
  @test "lineage_emit_event returns nonzero for an unknown event type" {
79
- run lineage_emit_event "lineage.no_such_event" '{"project_key":"x"}' "$SID"
79
+ expect_emission_rejected lineage_emit_event "lineage.no_such_event" '{"project_key":"x"}' "$SID"
80
80
  [ "$status" -ne 0 ]
81
81
  }
@@ -204,6 +204,6 @@ JUDGE_ID="01J000000000000000000000JJ"
204
204
  }
205
205
 
206
206
  @test "emission fails loudly on bogus event_type (schema rejects)" {
207
- run tribunal_emit_event "tribunal.no.such.event" '{"task_id":"x"}'
207
+ expect_emission_rejected tribunal_emit_event "tribunal.no.such.event" '{"task_id":"x"}'
208
208
  [ "$status" -ne 0 ]
209
209
  }
@@ -80,6 +80,6 @@ _validate_latest_event() {
80
80
  # warden.* type must be rejected so typos never reach the log.
81
81
  local p
82
82
  p=$(jq -n '{source_type:"web_fetch", threat_type:"prompt_injection", confidence:0.5}')
83
- run warden_emit_event "warden.bogus.event" "$p"
83
+ expect_emission_rejected warden_emit_event "warden.bogus.event" "$p"
84
84
  [ "$status" -ne 0 ]
85
85
  }
@@ -0,0 +1,131 @@
1
+ {
2
+ "expected": [
3
+ "assayer.audit.complete",
4
+ "assayer.audit.started",
5
+ "assayer.claim.contradicted",
6
+ "assayer.claim.unverified",
7
+ "bursar.rollup.skipped",
8
+ "bursar.rollup.surfaced",
9
+ "bursar.session.recorded",
10
+ "cartographer.audit.complete",
11
+ "cartographer.issue.found",
12
+ "cartographer.issue.resolved",
13
+ "compass.check.passed",
14
+ "compass.check.skipped",
15
+ "counsel.brief.generated",
16
+ "curator.finding.broken_index",
17
+ "curator.finding.date_decayed",
18
+ "curator.finding.orphaned_memory",
19
+ "curator.finding.path_broken",
20
+ "curator.scan.complete",
21
+ "curator.scan.started",
22
+ "echo.improvement.detected",
23
+ "echo.regression.detected",
24
+ "echo.suite.complete",
25
+ "echo.suite.started",
26
+ "governor.budget.exceeded",
27
+ "governor.budget.warning",
28
+ "governor.call.recorded",
29
+ "governor.child.allocated",
30
+ "governor.child.returned",
31
+ "governor.gate.checked",
32
+ "governor.ledger.write_failed",
33
+ "governor.lock.stale_cleared",
34
+ "governor.session.complete",
35
+ "historian.chunk.dropped",
36
+ "historian.chunk.sanitized",
37
+ "historian.embedder.unavailable",
38
+ "historian.indexing.complete",
39
+ "historian.indexing.started",
40
+ "historian.retrieval.complete",
41
+ "historian.retrieval.started",
42
+ "historian.retrieval.surfaced",
43
+ "inspector.check.failed",
44
+ "inspector.check.passed",
45
+ "inspector.check.skipped",
46
+ "inspector.run.completed",
47
+ "librarian.candidate.dropped",
48
+ "librarian.candidate.proposed",
49
+ "librarian.proposal.accepted",
50
+ "librarian.proposal.rejected",
51
+ "librarian.scan.complete",
52
+ "librarian.scan.started",
53
+ "librarian.tombstone.created",
54
+ "lineage.change.recorded",
55
+ "lineage.query.answered",
56
+ "memory.recalled",
57
+ "onlooker.artifact.ready",
58
+ "session.compact",
59
+ "session.end",
60
+ "session.prompt",
61
+ "session.start",
62
+ "skill.invoked",
63
+ "task.complete",
64
+ "task.start",
65
+ "tool.agent.spawn",
66
+ "tool.file.read",
67
+ "tool.shell.exec",
68
+ "tribunal.actor.complete",
69
+ "tribunal.actor.start",
70
+ "tribunal.consensus.reached",
71
+ "tribunal.dissent.recorded",
72
+ "tribunal.gate.blocked",
73
+ "tribunal.gate.passed",
74
+ "tribunal.iteration.start",
75
+ "tribunal.judge.start",
76
+ "tribunal.jury.empaneled",
77
+ "tribunal.meta.complete",
78
+ "tribunal.meta.start",
79
+ "tribunal.session.complete",
80
+ "tribunal.session.start",
81
+ "tribunal.verdict",
82
+ "warden.gate.blocked",
83
+ "warden.threat.cleared",
84
+ "warden.threat.detected"
85
+ ],
86
+ "excluded": {
87
+ "archivist.compact.complete": "archivist-extract.sh (the PreCompact hook) never calls archivist_emit_event with this type; only onlooker.artifact.ready is wired up",
88
+ "archivist.compact.started": "archivist-extract.sh (the PreCompact hook) never calls archivist_emit_event with this type; only onlooker.artifact.ready is wired up",
89
+ "archivist.extract.complete": "archivist-extract.sh only emits onlooker.artifact.ready; no code path emits archivist.extract.complete yet",
90
+ "archivist.inject.complete": "archivist-inject.sh (the SessionStart hook) has no archivist_emit_event call at all",
91
+ "archivist.inject.started": "archivist-inject.sh (the SessionStart hook) has no archivist_emit_event call at all",
92
+ "compass.check.canceled": "compass has no implementation yet; design phase",
93
+ "compass.check.failed": "compass has no implementation yet; design phase",
94
+ "compass.check.overridden": "compass has no implementation yet; design phase",
95
+ "curator.finding.acknowledged": "curator check deferred; see plugins/curator/README.md",
96
+ "curator.finding.contradiction": "curator check deferred; see plugins/curator/README.md",
97
+ "curator.finding.redundant_pair": "curator check deferred; see plugins/curator/README.md",
98
+ "curator.finding.resolved": "curator check deferred; see plugins/curator/README.md",
99
+ "curator.finding.symbol_missing": "curator check deferred; see plugins/curator/README.md",
100
+ "curator.finding.unused_low_signal": "curator check deferred; see plugins/curator/README.md",
101
+ "curator.finding.url_unchecked": "curator check deferred; see plugins/curator/README.md",
102
+ "historian.config.warning": "no config-validation-warning branch exists in historian-config.sh; nothing in the plugin emits this type yet",
103
+ "historian.prune.complete": "historian/README.md lists retention-sweep pruning under Status as deferred to a follow-up landing",
104
+ "historian.purge.complete": "historian/README.md lists manual purge under Status as deferred to a follow-up landing",
105
+ "librarian.proposal.merged": "librarian-cli.sh only implements the accept/reject subcommands; the merge-into-X resolution from docs/design.md has no code path",
106
+ "librarian.proposal.superseded": "librarian-cli.sh only implements the accept/reject subcommands; the supersede-X resolution from docs/design.md has no code path",
107
+ "meridian.hint.delivered": "plugin lives in another repo",
108
+ "meridian.hint.generated": "plugin lives in another repo",
109
+ "meridian.lesson.curated": "plugin lives in another repo",
110
+ "meridian.outcome.recorded": "plugin lives in another repo",
111
+ "meridian.playbook.updated": "plugin lives in another repo",
112
+ "meridian.reliance.measured": "plugin lives in another repo",
113
+ "onlooker.session.summary": "emitted by the agent, not this repo",
114
+ "oracle.calibration.complete": "plugin lives in another repo",
115
+ "oracle.calibration.requested": "plugin lives in another repo",
116
+ "prompt_rule.applied": "prompt_rules_emit in scripts/lib/prompt-rules.sh writes straight to $ONLOOKER_EVENTS_LOG via jq, bypassing onlooker-event.mjs, so it never reaches the schema-validating emitter this harness observes even though test/bats/prompt-rules.bats exercises it",
117
+ "prompt_rule.matched": "prompt_rules_emit in scripts/lib/prompt-rules.sh writes straight to $ONLOOKER_EVENTS_LOG via jq, bypassing onlooker-event.mjs, so it never reaches the schema-validating emitter this harness observes even though test/bats/prompt-rules.bats exercises it",
118
+ "relay.handoff.captured": "plugin lives in another repo",
119
+ "relay.handoff.injected": "plugin lives in another repo",
120
+ "scribe.capture.complete": "no scribe_emit_event call for this type exists anywhere in plugins/scribe; only scribe.distill.complete and onlooker.artifact.ready are wired up",
121
+ "scribe.distill.complete": "the emit call in scripts/lib/scribe-distill.sh is real, but test/bats/scribe-extract.bats only drives the min_turns skip path, which returns before reaching it",
122
+ "sentinel.allowed": "plugin lives in another repo",
123
+ "sentinel.blocked": "plugin lives in another repo",
124
+ "sentinel.reviewed": "plugin lives in another repo",
125
+ "task.fail": "mapTaskHookInput in scripts/lib/onlooker-event.mjs only derives task.start/task.complete from TaskCreated/TaskCompleted; no branch distinguishes a failed task",
126
+ "tool.agent.complete": "the Agent/PostToolUse branch in onlooker-event.mjs is real, but test/bats/tool-history-tracker.bats only drives Read and failing-Bash tool inputs, never Agent",
127
+ "tool.file.edit": "the Edit branch in onlooker-event.mjs is real, but test/bats/tool-history-tracker.bats never drives an Edit tool input",
128
+ "tool.file.write": "the Write branch in onlooker-event.mjs is real, but test/bats/tool-history-tracker.bats never drives a Write tool input",
129
+ "tool.web.fetch": "the WebFetch branch in onlooker-event.mjs is real, but test/bats/tool-history-tracker.bats never drives a WebFetch tool input"
130
+ }
131
+ }
@@ -68,3 +68,25 @@ load_validate_path() {
68
68
  "$ONLOOKER_COMPACT_TRACKERS_DIR" \
69
69
  "$ONLOOKER_METRICS_DIR"
70
70
  }
71
+
72
+ # Run a command that is expected to fail schema validation, without recording
73
+ # the deliberate rejection in the suite-wide emission report.
74
+ #
75
+ # The report exists so a payload that drifts from the schema turns CI red. A
76
+ # test that deliberately emits an invalid payload would otherwise write a
77
+ # valid:false line indistinguishable from real drift, making the gate
78
+ # permanently red from intentional tests. Unsetting the report directory for
79
+ # the duration keeps the negative test honest — it still asserts the emitter
80
+ # rejects — without polluting the gate.
81
+ #
82
+ # Sets $status and $output exactly as bats' `run` does.
83
+ #
84
+ # Usage: expect_emission_rejected <command> [args...]
85
+ expect_emission_rejected() {
86
+ local saved="${ONLOOKER_TEST_REPORT_DIR:-}"
87
+ unset ONLOOKER_TEST_REPORT_DIR
88
+ run "$@"
89
+ if [ -n "$saved" ]; then
90
+ export ONLOOKER_TEST_REPORT_DIR="$saved"
91
+ fi
92
+ }