@bongos/core 1.19.837 → 1.19.839

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,523 @@
1
+ // tests/agents_event_router.mjs — the event-router poller (task 1002494).
2
+ //
3
+ // Everything is dependency-injected — a fake pool, a fake matcher, a fake sink,
4
+ // a fake runner. No database, no model call, no server. What is proven here are
5
+ // the four properties the router's header claims, and each test is written so it
6
+ // REDS if the property is removed:
7
+ //
8
+ // 1. the touches[] pre-filter runs BEFORE any spawn, and a miss costs nothing
9
+ // 2. the depth guard refuses a ship attributed to an agent run, fail-closed
10
+ // 3. an unwired/unavailable sink refuses BEFORE the spawn, not after
11
+ // 4. nothing the router does can block, slow or fail the ship it reacted to
12
+ //
13
+ // Run: node tests/agents_event_router.mjs
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const router = require('../modules/agents/pollers/event-router.js');
20
+ const {
21
+ fanOut, onShipped, matchesTouches, selectFiring, depthRefsFor,
22
+ sinkNameFor, resolveSink, declaredGlobs, EVENT, SINK_CATALOG, DEFAULT_SINK,
23
+ } = router;
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+ async function test(name, fn) {
28
+ try { await fn(); passed++; console.log(` ok ${name}`); }
29
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.stack || err.message}`); }
30
+ }
31
+
32
+ // A stand-in for permissionPathCheck.compileGlobMatcher. It splits on `**`
33
+ // rather than substituting a sentinel character: the sentinel this originally
34
+ // used was a literal NUL, which made the whole test file a binary blob git
35
+ // would not render as a diff — invisible locally, and caught only in review.
36
+ const compile = (globs) => (file) => globs.some((g) => {
37
+ if (g.endsWith('/')) return file.startsWith(g);
38
+ const src = g.split('**')
39
+ .map((seg) => seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'))
40
+ .join('.*');
41
+ return new RegExp('^' + src + '$').test(file);
42
+ });
43
+
44
+ function def(over = {}) {
45
+ return {
46
+ id: 1, name: 'review-lens', title: null, persona: 'observe and report',
47
+ trigger_type: 'event', trigger_spec: { event: EVENT }, model_tier: 'default',
48
+ scope_violation: null, enabled: true, ...over,
49
+ };
50
+ }
51
+
52
+ // A pool that answers the router's three reads by SHAPE and records every
53
+ // statement, so the ledger can be asserted on directly (tests/agents_spawn.mjs
54
+ // idiom). `defs`, `task` and `attributedRuns` are what the reads return.
55
+ function fakePool({ defs = [], task = null, attributedRuns = [], spent = '0', throwOn = null } = {}) {
56
+ const calls = [];
57
+ let nextRunId = 100;
58
+ return {
59
+ calls,
60
+ async query(sql, params) {
61
+ calls.push({ sql, params });
62
+ if (throwOn && throwOn.test(sql)) throw new Error('boom');
63
+ if (/FROM agents_definitions/.test(sql)) return { rows: defs };
64
+ if (/FROM tasks/.test(sql)) return { rows: task ? [task] : [] };
65
+ if (/FROM agents_runs\n\s*WHERE output_ref/.test(sql) || /output_ref = ANY/.test(sql)) return { rows: attributedRuns };
66
+ if (/SUM\(cost_usd\)/.test(sql)) return { rows: [{ spent }] };
67
+ if (/^INSERT INTO agents_runs/.test(sql)) return { rows: [{ id: nextRunId++ }] };
68
+ return { rows: [] };
69
+ },
70
+ ledgered() {
71
+ return calls.filter((c) => /^INSERT INTO agents_runs/.test(c.sql)).map((c) => {
72
+ const cols = c.sql.match(/\(([^)]+)\) VALUES/)[1].split(',').map((s) => s.trim());
73
+ return Object.fromEntries(cols.map((k, i) => [k, c.params[i]]));
74
+ });
75
+ },
76
+ };
77
+ }
78
+
79
+ const SINK_OK = { resolveOptional: (port) => (port === 'ideas.capture' ? async () => ({ id: 777 }) : null) };
80
+
81
+ // ===========================================================================
82
+ // PROPERTY 1 — the cheap filter, first
83
+ // ===========================================================================
84
+
85
+ await test('NO declared filter fires on every ship — an absent filter is not an empty one', () => {
86
+ assert.deepEqual(declaredGlobs(def()), []);
87
+ assert.equal(matchesTouches(def(), ['anything/at/all.js'], compile).match, true);
88
+ // ...and a task with no touches[] at all still matches an unfiltered agent.
89
+ assert.equal(matchesTouches(def(), [], compile).match, true);
90
+ });
91
+
92
+ await test('a declared filter matches only an overlapping touches[]', () => {
93
+ const d = def({ trigger_spec: { event: EVENT, touches: ['docs/**', 'src/bongos/'] } });
94
+ assert.equal(matchesTouches(d, ['docs/adr/0001.md'], compile).match, true, '** crosses separators');
95
+ assert.equal(matchesTouches(d, ['src/bongos/db.js'], compile).match, true, 'a trailing / is a subtree');
96
+ assert.equal(matchesTouches(d, ['modules/agents/spawn.js'], compile).match, false);
97
+ // One hit anywhere in touches[] is enough — a ship is relevant if ANY of what
98
+ // it touched is in scope.
99
+ assert.equal(matchesTouches(d, ['modules/x.js', 'docs/readme.md'], compile).match, true);
100
+ });
101
+
102
+ await test('a DECLARED filter against an EMPTY touches[] does NOT fire — no evidence must not read as go', () => {
103
+ const d = def({ trigger_spec: { event: EVENT, touches: ['docs/**'] } });
104
+ const v = matchesTouches(d, [], compile);
105
+ assert.equal(v.match, false);
106
+ assert.equal(v.reason, 'task_declared_no_touches');
107
+ assert.equal(matchesTouches(d, null, compile).match, false, 'a null touches[] is the same absence');
108
+ });
109
+
110
+ await test('a MALFORMED filter widens to "no filter", never narrows to "matches nothing"', () => {
111
+ // Narrowing would disarm an armed agent with nothing recording why.
112
+ for (const bad of ['docs/**', 42, {}, null, [], ['', ' ']]) {
113
+ assert.equal(matchesTouches(def({ trigger_spec: { event: EVENT, touches: bad } }), ['x.js'], compile).match,
114
+ true, `touches: ${JSON.stringify(bad)} must not silently disarm the agent`);
115
+ }
116
+ });
117
+
118
+ await test('an UNCOMPILABLE filter refuses the fire rather than matching everything', () => {
119
+ const d = def({ trigger_spec: { event: EVENT, touches: ['docs/**'] } });
120
+ const v = matchesTouches(d, ['docs/x.md'], () => { throw new Error('bad glob'); });
121
+ assert.equal(v.match, false);
122
+ assert.equal(v.reason, 'filter_uncompilable');
123
+ });
124
+
125
+ await test('selectFiring splits the roster and never loses a definition', () => {
126
+ const defs = [
127
+ def({ id: 1, name: 'a', trigger_spec: { event: EVENT, touches: ['docs/**'] } }),
128
+ def({ id: 2, name: 'b', trigger_spec: { event: EVENT, touches: ['infra/**'] } }),
129
+ def({ id: 3, name: 'c' }),
130
+ ];
131
+ const { firing, filtered } = selectFiring(defs, ['docs/a.md'], compile);
132
+ assert.deepEqual(firing.map((f) => f.definition.name), ['a', 'c']);
133
+ assert.deepEqual(filtered.map((f) => f.definition.name), ['b']);
134
+ assert.equal(firing.length + filtered.length, defs.length);
135
+ });
136
+
137
+ await test('a PRE-FILTER MISS spawns nothing AND ledgers nothing — the fire never happened', async () => {
138
+ let spawned = 0;
139
+ const pool = fakePool({
140
+ defs: [def({ trigger_spec: { event: EVENT, touches: ['infra/**'] } })],
141
+ task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
142
+ });
143
+ const report = await fanOut({ id: '9' }, {
144
+ pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
145
+ runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
146
+ });
147
+ assert.equal(spawned, 0, 'the cheap filter must run BEFORE any spawn');
148
+ assert.equal(report.fired, 0);
149
+ assert.equal(report.filtered, 1);
150
+ assert.equal(report.reason, 'no_agent_matched_touches');
151
+ assert.deepEqual(pool.ledgered(), [], 'selection is not refusal — a miss writes no ledger row');
152
+ });
153
+
154
+ await test('with NOTHING armed the router reads no task row and writes nothing', async () => {
155
+ const pool = fakePool({ defs: [] });
156
+ const report = await fanOut({ id: '9' }, { pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK });
157
+ assert.equal(report.reason, 'no_armed_event_agents');
158
+ assert.equal(pool.calls.filter((c) => /FROM tasks/.test(c.sql)).length, 0,
159
+ 'the definition read is first and cheapest; a quiet instance must not pay for a task read');
160
+ });
161
+
162
+ // ===========================================================================
163
+ // PROPERTY 2 — the depth guard
164
+ // ===========================================================================
165
+
166
+ await test('depthRefsFor covers BOTH hops: a direct task pointer and a promoted idea', () => {
167
+ assert.deepEqual(depthRefsFor({ id: '55', source: null }), ['task:55']);
168
+ assert.deepEqual(depthRefsFor({ id: '55', source: 'idea-1000527' }), ['task:55', 'idea:1000527']);
169
+ // A source that is not an idea promotion contributes no idea ref.
170
+ assert.deepEqual(depthRefsFor({ id: '55', source: 'discord' }), ['task:55']);
171
+ assert.deepEqual(depthRefsFor({}), [], 'an unidentifiable task yields no refs');
172
+ });
173
+
174
+ await test('a ship ATTRIBUTED to an agent run fires nothing, and the refusal IS ledgered', async () => {
175
+ let spawned = 0;
176
+ const pool = fakePool({
177
+ defs: [def()],
178
+ task: { id: '9', title: 'T', touches: ['docs/a.md'], source: 'idea-1000527' },
179
+ attributedRuns: [{ id: '42', agent_name: 'review-lens' }],
180
+ });
181
+ const report = await fanOut({ id: '9' }, {
182
+ pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
183
+ runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
184
+ });
185
+ assert.equal(spawned, 0, 'agents must not trigger each other forever');
186
+ assert.equal(report.reason, 'depth_guard');
187
+ const rows = pool.ledgered();
188
+ assert.equal(rows.length, 1, 'a loop guard that leaves no trace is invisible');
189
+ assert.equal(rows[0].gate_decision, 'no-go');
190
+ assert.equal(rows[0].status, 'skipped');
191
+ assert.match(String(rows[0].gate_reason), /attributed to agent run 42/);
192
+ });
193
+
194
+ await test('the depth guard FAILS CLOSED on an unreadable ledger', async () => {
195
+ const thrower = { query: async () => { throw new Error('db down'); } };
196
+ const v = await router.isAgentAttributed(thrower, { id: '9' });
197
+ assert.equal(v.attributed, true, '"I could not check for a loop" must never read as "no loop"');
198
+ assert.match(v.reason, /threw/);
199
+
200
+ assert.equal((await router.isAgentAttributed(null, { id: '9' })).attributed, true, 'no pool ⇒ no fire');
201
+ assert.equal((await router.isAgentAttributed({ query: async () => ({ rows: [] }) }, {})).attributed, true,
202
+ 'an unidentifiable task ⇒ no fire');
203
+ });
204
+
205
+ await test('an UNREADABLE task row refuses every armed agent rather than firing blind', async () => {
206
+ let spawned = 0;
207
+ const pool = fakePool({ defs: [def({ id: 1, name: 'a' }), def({ id: 2, name: 'b' })], task: null });
208
+ const report = await fanOut({ id: '9' }, {
209
+ pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
210
+ runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
211
+ });
212
+ assert.equal(spawned, 0, 'with no touches[] the pre-filter cannot run, so nothing may fire');
213
+ assert.equal(report.reason, 'task_unreadable');
214
+ assert.equal(pool.ledgered().length, 2, 'these fires WOULD have happened — refusing them is a refusal');
215
+ });
216
+
217
+ // ===========================================================================
218
+ // PROPERTY 3 — the sink, checked before the spawn
219
+ // ===========================================================================
220
+
221
+ await test('the sink catalog is the WIRED set — every entry has a port behind it', () => {
222
+ assert.deepEqual(Object.keys(SINK_CATALOG).sort(), ['discord', 'ideas.capture']);
223
+ assert.equal(DEFAULT_SINK, 'ideas.capture');
224
+ assert.equal(sinkNameFor(def()), 'ideas.capture', 'an absent sink defaults rather than leaving a finding homeless');
225
+ assert.equal(sinkNameFor(def({ trigger_spec: { event: EVENT, sink: 'discord' } })), 'discord');
226
+ // A name nothing delivers to is refused, not stored — the EVENT_CATALOG rule.
227
+ const v = resolveSink(def({ trigger_spec: { event: EVENT, sink: 'carrier-pigeon' } }), SINK_OK);
228
+ assert.equal(v.ok, false);
229
+ assert.match(v.reason, /not wired/);
230
+ });
231
+
232
+ await test('an UNAVAILABLE port refuses too — wired is not the same as present', () => {
233
+ const v = resolveSink(def(), { resolveOptional: () => null });
234
+ assert.equal(v.ok, false);
235
+ assert.match(v.reason, /port is not available/);
236
+ });
237
+
238
+ await test('an unwired sink refuses BEFORE the model runs, and is ledgered', async () => {
239
+ let spawned = 0;
240
+ const pool = fakePool({
241
+ defs: [def({ trigger_spec: { event: EVENT, sink: 'carrier-pigeon' } })],
242
+ task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
243
+ });
244
+ const report = await fanOut({ id: '9' }, {
245
+ pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
246
+ runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
247
+ });
248
+ assert.equal(spawned, 0, 'a finding nobody receives is money spent for nothing');
249
+ assert.equal(report.reason, 'no_deliverable_sink');
250
+ assert.match(String(pool.ledgered()[0].gate_reason), /not wired/);
251
+ });
252
+
253
+ // ===========================================================================
254
+ // THE HAPPY PATH, AND WHAT IT WRITES
255
+ // ===========================================================================
256
+
257
+ await test('a matching ship fires, files the finding, and records the POINTER on the run', async () => {
258
+ const filed = [];
259
+ const pool = fakePool({
260
+ defs: [def({ trigger_spec: { event: EVENT, touches: ['docs/**'] } })],
261
+ task: { id: '9', title: 'Ship the docs', touches: ['docs/a.md'], source: null },
262
+ });
263
+ const report = await fanOut({ id: '9' }, {
264
+ pool,
265
+ compileGlobMatcher: compile,
266
+ spendPayer: null,
267
+ resolveOptional: (port) => (port === 'ideas.capture'
268
+ ? async (arg) => { filed.push(arg); return { id: 777 }; }
269
+ : null),
270
+ runSubagentCached: async () => ({ stdout: 'the copy on line 3 reads oddly', cost_usd: 0.9 }),
271
+ });
272
+ assert.equal(report.fired, 1);
273
+ assert.equal(filed.length, 1, 'the finding reaches its declared sink');
274
+ assert.match(filed[0].title, /^review-lens: Ship the docs/);
275
+ assert.match(filed[0].bodyMd, /the copy on line 3 reads oddly/);
276
+ assert.equal(filed[0].capturedBy, null);
277
+ assert.equal(filed[0].vector, undefined,
278
+ 'NO vector: an agent may report, and may not manufacture claimable work');
279
+
280
+ const insert = pool.ledgered()[0];
281
+ assert.equal(insert.gate_decision, 'go');
282
+ assert.equal(insert.trigger_type, 'event');
283
+ assert.equal(insert.trigger_ref, `${EVENT}:9`);
284
+ assert.equal(insert.requested_by_builder_id, null);
285
+ assert.equal(insert.payer_kind, 'project', 'an event fire has no caller, so the project pays');
286
+
287
+ const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
288
+ assert.ok(update, 'the run is finished, not left pending');
289
+ assert.ok(update.params.includes('idea:777'),
290
+ 'the output_ref pointer must land on the SAME update that records the cost');
291
+ });
292
+
293
+ await test('a fire with NOTHING to say is a success with no pointer, not a failure', async () => {
294
+ let filedCount = 0;
295
+ const pool = fakePool({
296
+ defs: [def()],
297
+ task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
298
+ });
299
+ const report = await fanOut({ id: '9' }, {
300
+ pool,
301
+ compileGlobMatcher: compile,
302
+ spendPayer: null,
303
+ resolveOptional: (port) => (port === 'ideas.capture' ? async () => { filedCount++; return { id: 1 }; } : null),
304
+ runSubagentCached: async () => ({ stdout: ' \n ' }),
305
+ });
306
+ assert.equal(report.fired, 1);
307
+ assert.equal(filedCount, 0, 'an empty finding must not become an empty inbox row');
308
+ const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
309
+ assert.ok(update.params.includes('ok'), 'reporting "nothing here" is the outcome most fires should have');
310
+ });
311
+
312
+ await test('a fire whose finding could NOT be delivered is an ERROR row, never a quiet ok', async () => {
313
+ const pool = fakePool({
314
+ defs: [def()],
315
+ task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null },
316
+ });
317
+ await fanOut({ id: '9' }, {
318
+ pool,
319
+ compileGlobMatcher: compile,
320
+ spendPayer: null,
321
+ resolveOptional: (port) => (port === 'ideas.capture' ? async () => { throw new Error('inbox down'); } : null),
322
+ runSubagentCached: async () => ({ stdout: 'a real finding', cost_usd: 0.9 }),
323
+ });
324
+ const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
325
+ assert.ok(update.params.includes('error'),
326
+ 'a finding that evaporated must never look like an agent that found nothing');
327
+ assert.ok(update.params.includes('sink_delivery_failed'));
328
+ assert.ok(update.params.includes(0.9), 'the fire still cost money and the ledger still says so');
329
+ });
330
+
331
+ // ===========================================================================
332
+ // THE SPEND BOUNDS
333
+ // ===========================================================================
334
+
335
+ await test('the FAN-OUT CAP bounds one event, because no per-builder ceiling can', async () => {
336
+ const many = Array.from({ length: router.FANOUT_MAX + 2 }, (_, i) => def({ id: i + 1, name: `a${i}` }));
337
+ const pool = fakePool({ defs: many, task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null } });
338
+ let spawned = 0;
339
+ const report = await fanOut({ id: '9' }, {
340
+ pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK,
341
+ runSubagentCached: async () => { spawned++; return { stdout: '' }; },
342
+ });
343
+ assert.equal(spawned, router.FANOUT_MAX, 'an event fire has no builder, so fire-budget cannot bound it');
344
+ assert.equal(report.fired, router.FANOUT_MAX);
345
+ assert.equal(report.skipped, 2);
346
+ const overflow = pool.ledgered().filter((r) => /at most/.test(String(r.gate_reason)));
347
+ assert.equal(overflow.length, 2, 'the agents the cap dropped are on the record');
348
+ });
349
+
350
+ await test('an EXHAUSTED or UNREADABLE spend ceiling refuses every fire, ledgered', async () => {
351
+ for (const [label, ceiling] of [
352
+ ['exhausted', { check: async () => ({ ok: false, reason: 'spend_cap_reached', capUsd: 25, spentUsd: 25 }) }],
353
+ ['unreadable', { check: async () => ({ ok: false, reason: 'ledger_unreadable', capUsd: 25, spentUsd: null }) }],
354
+ ]) {
355
+ let spawned = 0;
356
+ const pool = fakePool({ defs: [def()], task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null } });
357
+ const report = await fanOut({ id: '9' }, {
358
+ pool, compileGlobMatcher: compile, spendPayer: null, spendCeiling: ceiling, ...SINK_OK,
359
+ runSubagentCached: async () => { spawned++; return { stdout: 'x' }; },
360
+ });
361
+ assert.equal(spawned, 0, `${label}: a ceiling that cannot be read must not be assumed`);
362
+ assert.equal(report.reason, 'spend_ceiling');
363
+ assert.equal(pool.ledgered().length, 1, `${label}: the refusal is on the record`);
364
+ }
365
+ });
366
+
367
+ // ===========================================================================
368
+ // PROPERTY 4 — it can never block, slow or fail a ship
369
+ // ===========================================================================
370
+
371
+ await test('the subscribed handler is SYNCHRONOUS — emitAsync must not await a model call', () => {
372
+ // emitAsync awaits what a listener returns and the ship route awaits the emit,
373
+ // so a returned promise would hold a shipping builder's request open for the
374
+ // length of a fire. This is the assertion that keeps that from regressing.
375
+ const started = Date.now();
376
+ const r = onShipped({ id: 'no-such-task' });
377
+ assert.equal(r, undefined, 'the handler must return undefined, never a promise');
378
+ assert.ok(Date.now() - started < 50, 'the handler must return immediately');
379
+ });
380
+
381
+ await test('a throwing fan-out never reaches the emitter', async () => {
382
+ // Every read path is broken at once: the pool throws on everything.
383
+ const pool = { query: async () => { throw new Error('everything is down'); } };
384
+ const report = await fanOut({ id: '9' }, { pool, compileGlobMatcher: compile, spendPayer: null, ...SINK_OK });
385
+ assert.equal(report.reason, 'definitions_unreadable', 'a reactor must never break the actor');
386
+ assert.equal(report.fired, 0);
387
+ // ...and the synchronous entry point swallows it too.
388
+ assert.equal(onShipped({ id: '9' }), undefined);
389
+ });
390
+
391
+ await test('a payload with no id is a no-op, not a crash', async () => {
392
+ for (const bad of [null, undefined, {}, { id: null }]) {
393
+ const report = await fanOut(bad, { pool: fakePool(), compileGlobMatcher: compile, spendPayer: null });
394
+ assert.equal(report.reason, 'no_task_id');
395
+ }
396
+ });
397
+
398
+ await test('start()/stop() subscribe exactly once and unsubscribe cleanly', () => {
399
+ const seams = require('../src/module-seams.js');
400
+ const before = seams.listEvents().includes(EVENT);
401
+ router.start();
402
+ router.start(); // idempotent — a duplicate listener would double every bill
403
+ assert.ok(seams.listEvents().includes(EVENT));
404
+ router.stop();
405
+ assert.equal(seams.listEvents().includes(EVENT), before,
406
+ 'stop() must leave the seam registry exactly as it found it');
407
+ });
408
+
409
+ // ===========================================================================
410
+ // THE DISCORD SINK — announce, never file
411
+ // ===========================================================================
412
+
413
+ const DISCORD_OK = (posts) => ({
414
+ resolveOptional: (port) => (port === 'discord.post'
415
+ ? async (arg) => { posts.push(arg); return { posted: true, channel: arg.channel }; }
416
+ : null),
417
+ });
418
+
419
+ await test('a discord-sinked finding is ANNOUNCED on #ship-feed and files nothing', async () => {
420
+ const posts = [];
421
+ const pool = fakePool({
422
+ defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
423
+ task: { id: '9', title: 'Ship the docs', touches: ['docs/a.md'], source: null, security_sensitive: false },
424
+ });
425
+ const report = await fanOut({ id: '9' }, {
426
+ pool, compileGlobMatcher: compile, spendPayer: null, ...DISCORD_OK(posts),
427
+ runSubagentCached: async () => ({ stdout: 'the copy on line 3 reads oddly', cost_usd: 0.9 }),
428
+ });
429
+ assert.equal(report.fired, 1);
430
+ assert.equal(posts.length, 1);
431
+ assert.equal(posts[0].channel, 'ship_feed');
432
+ assert.match(posts[0].text, /review-lens: Ship the docs/);
433
+ assert.match(posts[0].text, /the copy on line 3 reads oddly/);
434
+ const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
435
+ assert.ok(update.params.includes('discord:ship_feed:9'),
436
+ 'the pointer names where it landed, so the ledger can be walked back');
437
+ });
438
+
439
+ await test('a finding about a SECURITY-SENSITIVE ship never reaches a public channel', async () => {
440
+ const posts = [];
441
+ const pool = fakePool({
442
+ defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
443
+ task: { id: '9', title: 'Patch the hole', touches: ['docs/a.md'], source: null, security_sensitive: true },
444
+ });
445
+ await fanOut({ id: '9' }, {
446
+ pool, compileGlobMatcher: compile, spendPayer: null, ...DISCORD_OK(posts),
447
+ runSubagentCached: async () => ({ stdout: 'here is how the hole worked' }),
448
+ });
449
+ assert.equal(posts[0].channel, 'security_ship_feed',
450
+ 'advertising the shape of an exploit on a channel the guild reads is the one thing this must not do');
451
+ });
452
+
453
+ await test('an INDETERMINATE security flag SUPPRESSES the post rather than guessing', async () => {
454
+ for (const flag of [null, undefined, 'yes', 1]) {
455
+ const posts = [];
456
+ const pool = fakePool({
457
+ defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
458
+ task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null, security_sensitive: flag },
459
+ });
460
+ await fanOut({ id: '9' }, {
461
+ pool, compileGlobMatcher: compile, spendPayer: null, ...DISCORD_OK(posts),
462
+ runSubagentCached: async () => ({ stdout: 'a finding' }),
463
+ });
464
+ assert.equal(posts.length, 0, `security_sensitive=${flag}: "we could not tell" must not resolve to "post it publicly"`);
465
+ }
466
+ });
467
+
468
+ await test('a post the operator never configured is NOT recorded as a pointer', async () => {
469
+ const pool = fakePool({
470
+ defs: [def({ trigger_spec: { event: EVENT, sink: 'discord' } })],
471
+ task: { id: '9', title: 'T', touches: ['docs/a.md'], source: null, security_sensitive: false },
472
+ });
473
+ await fanOut({ id: '9' }, {
474
+ pool,
475
+ compileGlobMatcher: compile,
476
+ spendPayer: null,
477
+ resolveOptional: (port) => (port === 'discord.post'
478
+ ? async () => ({ posted: false, reason: 'no_webhook_for_channel' })
479
+ : null),
480
+ runSubagentCached: async () => ({ stdout: 'a finding' }),
481
+ });
482
+ const update = pool.calls.find((c) => /^UPDATE agents_runs/.test(c.sql));
483
+ assert.ok(!update.params.some((x) => typeof x === 'string' && x.startsWith('discord:')),
484
+ 'a pointer to a message that does not exist is a lie the ledger cannot be talked out of later');
485
+ });
486
+
487
+ // ===========================================================================
488
+ // WHAT REACHES A SINK IS UNTRUSTED TEXT
489
+ // ===========================================================================
490
+
491
+ await test('control characters are stripped and the body is bounded before any sink sees it', async () => {
492
+ const filed = [];
493
+ const hostile = `a finding${String.fromCharCode(0)}with${String.fromCharCode(27)}[31m escapes`;
494
+ const pool = fakePool({
495
+ defs: [def()],
496
+ task: { id: '9', title: `T${String.fromCharCode(7)}itle`, touches: ['docs/a.md'], source: null },
497
+ });
498
+ await fanOut({ id: '9' }, {
499
+ pool,
500
+ compileGlobMatcher: compile,
501
+ spendPayer: null,
502
+ resolveOptional: (port) => (port === 'ideas.capture'
503
+ ? async (arg) => { filed.push(arg); return { id: 1 }; }
504
+ : null),
505
+ runSubagentCached: async () => ({ stdout: hostile + 'x'.repeat(20000) }),
506
+ });
507
+ const sent = JSON.stringify(filed[0]);
508
+ assert.ok(!sent.includes('\\u0000'), 'a NUL must never reach a rendering surface');
509
+ assert.ok(!sent.includes('\\u001b'), 'nor an ANSI escape');
510
+ assert.ok(filed[0].bodyMd.length < 9000, `the body is bounded; got ${filed[0].bodyMd.length}`);
511
+ assert.ok(filed[0].title.length <= 180, 'and so is the title');
512
+ });
513
+
514
+ await test('this test file and the router are TEXT — git must be able to diff them', () => {
515
+ // A literal control byte in the source makes git call the file binary, and a
516
+ // binary file is one nobody can review. Both files shipped that way once.
517
+ const fs = require('node:fs');
518
+ for (const f of ['tests/agents_event_router.mjs', 'modules/agents/pollers/event-router.js']) {
519
+ assert.equal(fs.readFileSync(f).includes(0), false, `${f} contains a NUL byte`);
520
+ }
521
+ });
522
+ console.log(`\nagents_event_router: ${passed} passed, ${failed} failed`);
523
+ process.exit(failed === 0 ? 0 : 1);
package/tests/fitness.mjs CHANGED
@@ -51,6 +51,51 @@ test('CLAUDE.md char/token budget actually fires on within-line bloat (not a no-
51
51
  assert.ok(r.violations.some((v) => /char/i.test(v) && /token/i.test(v)), 'the violation must name the char/token budget');
52
52
  });
53
53
 
54
+ // --- the charter is RELOCATED, not deleted (task 1003687) --------------------
55
+ // The charter body moved to a paths-scoped rule, so the kernel keeps only a heading
56
+ // and a pointer. That makes the heading trivially fakeable: a section with nothing
57
+ // behind it satisfies a substring check while the rules it names are gone. These
58
+ // pin that the check resolves the pointer rather than trusting the heading — a green
59
+ // check over a missing charter would be a false claim of coverage.
60
+ const CHARTER_HEADING = '## Working with this file (content charter)';
61
+ const CHARTER_RULE_REL = '.claude/rules/claudemd-authoring.md';
62
+
63
+ test('the charter rule file exists and is paths-scoped to CLAUDE.md', () => {
64
+ const rule = fs.readFileSync(path.join(ROOT, CHARTER_RULE_REL), 'utf8');
65
+ assert.match(rule, /^---\r?\n[\s\S]*?\bpaths:[\s\S]*?["']\*\*\/CLAUDE\.md["'][\s\S]*?^---$/m,
66
+ 'without `paths:` frontmatter matching **/CLAUDE.md the rule never loads, and the charter is simply gone');
67
+ assert.ok(rule.includes('Per-line test:'), 'the per-line test must survive the move');
68
+ assert.ok(rule.includes('## What goes where'), 'the "what goes where" table must survive the move');
69
+ });
70
+
71
+ test('the live CLAUDE.md delegates the charter to the rule file', () => {
72
+ const kernel = fs.readFileSync(path.join(ROOT, 'CLAUDE.md'), 'utf8');
73
+ assert.ok(kernel.includes(CHARTER_HEADING), 'the heading keeps the rule findable from the file it governs');
74
+ assert.ok(kernel.includes(CHARTER_RULE_REL), 'the heading must point somewhere, or it is decoration');
75
+ });
76
+
77
+ test('a hollow charter heading hard-fails (the heading alone is not the charter)', () => {
78
+ const r = fitness.checkClaudeMdBudget({ text: `${CHARTER_HEADING}\n` });
79
+ assert.equal(r.hardFail, true, 'a heading with neither inline rules nor a pointer must fail');
80
+ assert.ok(r.violations.some((v) => /hollow/i.test(v)), 'the violation must name the hollow section');
81
+ });
82
+
83
+ test('an inline charter still passes (a downstream kernel that never split it)', () => {
84
+ const r = fitness.checkClaudeMdBudget({ text: `${CHARTER_HEADING}\n- **Per-line test:** …\n` });
85
+ assert.equal(r.hardFail, false, 'relocation is the cure, not the requirement — inline is still a real charter');
86
+ });
87
+
88
+ test('a pointer at a missing rule file hard-fails', () => {
89
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-ptr-'));
90
+ fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `${CHARTER_HEADING}\ncharter: ${CHARTER_RULE_REL}\n`);
91
+ const out = require('node:child_process').execFileSync(process.execPath,
92
+ ['-e', `const f=require(${JSON.stringify(path.join(ROOT, 'scripts/gds/fitness.js'))});process.stdout.write(JSON.stringify(f.checkClaudeMdBudget()))`],
93
+ { env: { ...process.env, BONGOS_INSTANCE_ROOT: dir }, encoding: 'utf8' });
94
+ const r = JSON.parse(out);
95
+ assert.equal(r.hardFail, true, 'a pointer to nothing is worse than no pointer');
96
+ assert.ok(r.violations.some((v) => /does not exist/i.test(v)), 'the violation must say the target is missing');
97
+ });
98
+
54
99
  test('CLAUDE.md char budget is ratcheted and ordered (target < ceiling, live file under it)', () => {
55
100
  assert.ok(fitness.ROOT_CHAR_TARGET < fitness.ROOT_CHAR_HARD_MAX, 'target must sit below the hard ceiling');
56
101
  const r = fitness.checkClaudeMdBudget();
@@ -778,6 +823,8 @@ test('identity gate actually CATCHES a re-hardcode (code, URL, HTML) — not a n
778
823
  assert.equal(scanPromptIdentity('x.js', 'const u = "https://amazonprimea.com/x";').length, 1, 'a leak inside a URL must be caught');
779
824
  assert.equal(scanPromptIdentity('x.html', '<title>Amazonprimea</title>').length, 1, 'an HTML title leak must be caught');
780
825
  assert.equal(scanPromptIdentity('x.js', 'const p = "Off the Boats";').length, 1, 'the product name must be caught');
826
+ // task 1002853: the founder's company rode the core status footer because only the product was listed
827
+ assert.equal(scanPromptIdentity('x.html', '<a href="https://hermeslines.com">Hermeslines</a>').length, 1, 'the host company must be caught');
781
828
  });
782
829
 
783
830
  test('identity gate skips comments + instance-injected tokens (no false positives)', () => {
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
- const HOOKS = ['recall-hints', 'rank-change-greeting', 'conductor', 'pull-memory', 'edit-hints'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
23
+ const HOOKS = ['recall-hints', 'rank-change-greeting', 'conductor', 'pull-memory', 'edit-hints', 'stuck-detector'].map((n) => path.join(ROOT, '.claude', 'hooks', `${n}.js`));
24
24
  // The SessionStart/SessionEnd scripts that did network work and then process.exit()ed — 59 recorded
25
25
  // UV_HANDLE_CLOSING aborts in three days (task 1003548). Same contract: exit 0 on their own, no abort.
26
26
  // session-digest requires `pg` at load (via db-kernel), so it can only be exercised where node_modules
@@ -49,6 +49,7 @@ const ALLOWED = {
49
49
  'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
50
50
  session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
51
51
  stop_refcheck: '.claude/hooks/stop-refcheck.js (package-excluded; the task-1002234 precedent)',
52
+ stuck_detector: '.claude/hooks/stuck-strikes.js (package-excluded)',
52
53
  worktree_guard: '.claude/hooks/worktree-guard.js (package-excluded)',
53
54
  };
54
55