switchroom 0.20.12 → 0.20.13

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.
@@ -72,6 +72,8 @@ import {
72
72
  import {
73
73
  decideCaptureAudience,
74
74
  resolveOpenObligation,
75
+ isReviewOriginatedSource,
76
+ isSelfImprovementCard,
75
77
  AUDIENCE_INTERNAL,
76
78
  } from './audience-classify.mjs'
77
79
 
@@ -129,6 +131,19 @@ function buildNextState(base, decision, retryCount) {
129
131
  } else {
130
132
  delete next.replyToolThrewThisTurn
131
133
  }
134
+ // #4490: same carry-through for review-turn provenance. The outbox capture
135
+ // path (`writeOutboxRecord` above) stamps `reviewOriginated` from the SAME
136
+ // envelope `source` tag; the ELECTED path ('trailing-text-after-reply') never
137
+ // writes an outbox record, so the captured-prose bridge (the delivering
138
+ // machine on this path) has no other way to learn it. Explicitly deleted
139
+ // when this turn's scan saw no review-source envelope, so a spread of a
140
+ // prior turn's file can never mislabel a normal turn's prose as a review
141
+ // turn's (or vice versa).
142
+ if (isReviewOriginatedSource(decision.source)) {
143
+ next.reviewOriginated = true
144
+ } else {
145
+ delete next.reviewOriginated
146
+ }
132
147
  return next
133
148
  }
134
149
 
@@ -181,7 +196,7 @@ function outboxAlreadyDelivered(outboxDir, nonce) {
181
196
  * `'unknown'` → the record delivers exactly as it does today.
182
197
  *
183
198
  * @param {string} stateDir
184
- * @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean }} capture
199
+ * @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean, source?: string|null }} capture
185
200
  * @returns {'user' | 'internal'}
186
201
  */
187
202
  function classifyCaptureAudience(stateDir, capture) {
@@ -212,6 +227,14 @@ function classifyCaptureAudience(stateDir, capture) {
212
227
  process.env.TELEGRAM_ACCESS_MODE !== 'static'
213
228
  return decideCaptureAudience({
214
229
  replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
230
+ // A self-improvement review turn has exactly ONE sanctioned operator-facing
231
+ // output: a well-formed self-improvement CARD. Deterministic on the enqueue
232
+ // envelope's `source` tag (the same signal the rest of the self-improve
233
+ // machinery routes on) plus the EXACT card-shape of the captured text. A
234
+ // card delivers; any other trailing prose (the raw-reasoning leak) is
235
+ // suppressed. Independent of the reply-throw / obligation state below.
236
+ reviewOriginated: isReviewOriginatedSource(capture.source),
237
+ reviewTextIsCard: isSelfImprovementCard(capture.text),
215
238
  openInboundObligation: resolveOpenObligation({
216
239
  snapshotRaw,
217
240
  snapshotTrusted,
@@ -265,6 +288,13 @@ function writeOutboxRecord(stateDir, capture, audience) {
265
288
  // not catch (the foreground case). Stamped here because this is the last
266
289
  // point in the pipeline that can still see the transcript.
267
290
  replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
291
+ // Ken 2026-08-07: did this turn originate from a self-improvement review
292
+ // inbound? `audience` already consumed it (with the card-shape signal) to
293
+ // deliver a well-formed card and suppress raw reasoning by default; the
294
+ // sweep consumes this raw flag again, on its own, to prepend the
295
+ // self-improvement TITLE to any review record delivered with the audience
296
+ // gate OFF, so review reasoning can never appear as raw, unlabelled prose.
297
+ reviewOriginated: isReviewOriginatedSource(capture.source),
268
298
  }
269
299
  const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
270
300
  writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
@@ -259,6 +259,13 @@ function buildTurnId(chatId, threadId, messageId) {
259
259
  */
260
260
  function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, replyToolThrewThisTurn) {
261
261
  const block = { decided: 'block', reason }
262
+ // #4490: the enqueue envelope's RAW `source` tag, carried through exactly
263
+ // like `replyToolThrewThisTurn` above — the single-writer election needs it
264
+ // to derive `reviewOriginated` for the captured-prose bridge (the ELECTED
265
+ // path's deliverer, which never writes an outbox record and so has no other
266
+ // way to learn a review turn's provenance). Omitted when the envelope carried
267
+ // none, so a spread of a prior state file is never read as positive evidence.
268
+ if (envelope.source != null) block.source = envelope.source
262
269
  if (replyToolThrewThisTurn === true) block.replyToolThrewThisTurn = true
263
270
  // Single-writer election input (#duplicate-message fix): does ANY
264
271
  // non-empty, non-silent trailing text block exist after the last
@@ -360,7 +367,7 @@ function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, reply
360
367
  * false-positive that burned retry budget on healthy turns.
361
368
  *
362
369
  * @param {string} jsonl
363
- * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string }}
370
+ * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string, source?: string, replyToolThrewThisTurn?: boolean }}
364
371
  */
365
372
  export function scanTurnForFinalReply(jsonl) {
366
373
  const lines = jsonl.split('\n')
@@ -75,6 +75,9 @@ import type { Audience } from './hooks/audience-classify.mjs'
75
75
  import {
76
76
  applyReplyThrowFraming,
77
77
  shouldFrameReplyThrow,
78
+ applySelfImprovementFraming,
79
+ shouldFrameSelfImprovement,
80
+ isSelfImprovementCard,
78
81
  } from './hooks/audience-classify.mjs'
79
82
 
80
83
  export type { Audience }
@@ -162,6 +165,25 @@ export interface OutboxRecord {
162
165
  * exact boolean `true` changes anything — see `shouldFrameReplyThrow`.
163
166
  */
164
167
  replyToolThrewThisTurn?: boolean
168
+ /**
169
+ * Ken 2026-08-07: did the turn that produced this record originate from a
170
+ * self-improvement review inbound (`source="self_improve_review"`)? Stamped at
171
+ * capture by `hooks/silent-end-interrupt-stop.mjs` from the enqueue envelope's
172
+ * source tag.
173
+ *
174
+ * `audience` already consumed it once — a review turn classifies `'internal'`
175
+ * (`decideCaptureAudience`), so by default it is SUPPRESSED at the sweep's
176
+ * entry gate and never delivered. The sweep consumes this raw flag again, on
177
+ * its own, only for the residual: a review record that IS delivered (the
178
+ * audience gate turned off, or a legacy/`user` route) must carry the
179
+ * self-improvement TITLE so it can never appear as raw, unlabelled agent
180
+ * reasoning — the leak this closes.
181
+ *
182
+ * OPTIONAL: absent on every pre-change record and on non-review turns, which
183
+ * deliver unframed. Only an exact boolean `true` changes anything — see
184
+ * `shouldFrameSelfImprovement`.
185
+ */
186
+ reviewOriginated?: boolean
165
187
  }
166
188
 
167
189
  /** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
@@ -204,6 +226,16 @@ export interface DeliveredEntry {
204
226
  * framed delivery is a delivery.
205
227
  */
206
228
  framedProvenance?: 'reply-throw'
229
+ /**
230
+ * Ken 2026-08-07: this delivery carried the self-improvement TITLE header
231
+ * (`SELF_IMPROVEMENT_TITLE`) in front of a review-originated record's prose.
232
+ * Durable and terminal, for the same reason `framedProvenance` is: framing
233
+ * changes what a human sees, so the outcome is recorded as an explicit named
234
+ * state on the journal line rather than being inferable only from a log line
235
+ * that may have rotated away. `tgMessageId` IS present on these lines — a
236
+ * framed delivery is a delivery.
237
+ */
238
+ framedSelfImprovement?: 'self-improve'
207
239
  }
208
240
 
209
241
  export function sha256Hex(s: string): string {
@@ -519,6 +551,13 @@ export interface OutboxSweepDecision {
519
551
  * the rule.
520
552
  */
521
553
  framedProvenance?: 'reply-throw'
554
+ /**
555
+ * Ken 2026-08-07: `text` carries the self-improvement title header. Surfaced
556
+ * on the decision (rather than left implicit in a string compare) so the
557
+ * caller can journal the outcome and emit telemetry without re-deriving the
558
+ * rule — same shape as `framedProvenance`.
559
+ */
560
+ framedSelfImprovement?: 'self-improve'
522
561
  }
523
562
 
524
563
  /**
@@ -538,7 +577,7 @@ export interface OutboxSweepDecision {
538
577
  export function decideOutboxSweep(input: {
539
578
  record: Pick<
540
579
  OutboxRecord,
541
- 'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn'
580
+ 'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn' | 'reviewOriginated'
542
581
  >
543
582
  now: number
544
583
  deliveredNonces: Set<string>
@@ -566,6 +605,13 @@ export function decideOutboxSweep(input: {
566
605
  * revert-check can flip it in-process.
567
606
  */
568
607
  provenanceFraming?: boolean
608
+ /**
609
+ * Ken 2026-08-07 kill switch for the self-improvement TITLE header. `false`
610
+ * restores the pre-change body byte-for-byte; the default is ON. Passed in
611
+ * rather than read from env here so this core stays pure and a revert-check
612
+ * can flip it in-process.
613
+ */
614
+ selfImprovementFraming?: boolean
569
615
  }): OutboxSweepDecision {
570
616
  const {
571
617
  record,
@@ -578,6 +624,7 @@ export function decideOutboxSweep(input: {
578
624
  maxAgeMs = OUTBOX_MAX_AGE_MS,
579
625
  shownLedgerHit = false,
580
626
  provenanceFraming = true,
627
+ selfImprovementFraming = true,
581
628
  } = input
582
629
  if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
583
630
  if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
@@ -592,11 +639,30 @@ export function decideOutboxSweep(input: {
592
639
  // the banner describes the TEXT. It is additive only: it can never turn a
593
640
  // send into a skip, so this branch cannot manufacture silence.
594
641
  const framed = shouldFrameReplyThrow(record, { frameEnabled: provenanceFraming })
595
- const body = framed ? applyReplyThrowFraming(record.text) : record.text
642
+ const provenanceBody = framed ? applyReplyThrowFraming(record.text) : record.text
643
+ // Ken 2026-08-07: the self-improvement title is the OUTERMOST content line, so
644
+ // it is applied AFTER the reply-throw banner — a review record that somehow
645
+ // also threw reads "🔧 Self-improvement" first, then the provenance note, then
646
+ // the prose. Additive only: it can never turn a send into a skip.
647
+ //
648
+ // #4489: idempotency is gated on the RAW `record.text`, NOT on `provenanceBody`.
649
+ // A real card's `record.text` opens with the title; the reply-throw banner
650
+ // above is PREPENDED in front of it, so `provenanceBody` no longer opens with
651
+ // the title even though the underlying card does. `applySelfImprovementFraming`
652
+ // alone can't see that — its idempotency check only looks at what it was
653
+ // handed — so gating the whole decision on the raw text is what keeps a real
654
+ // card from acquiring a second, duplicated title when both framings compose.
655
+ const selfImproveFramed =
656
+ shouldFrameSelfImprovement(record, { frameEnabled: selfImprovementFraming }) &&
657
+ !isSelfImprovementCard(record.text)
658
+ const body = selfImproveFramed ? applySelfImprovementFraming(provenanceBody) : provenanceBody
596
659
  return {
597
660
  action: delayed ? 'send-delayed' : 'send',
598
661
  text: prefix + body,
599
- ...(framed && body !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
662
+ ...(framed && provenanceBody !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
663
+ ...(selfImproveFramed && body !== provenanceBody
664
+ ? { framedSelfImprovement: 'self-improve' as const }
665
+ : {}),
600
666
  }
601
667
  }
602
668
 
@@ -82,6 +82,26 @@ export interface SilentEndState {
82
82
  * throw", which delivers exactly as before. It never suppresses.
83
83
  */
84
84
  replyToolThrewThisTurn?: boolean
85
+ /**
86
+ * #4490 — did the turn that produced `pendingText` originate from a
87
+ * self-improvement review inbound (`source="self_improve_review"`)?
88
+ *
89
+ * Stamped by the Stop hook's single-writer election
90
+ * (`silent-end-interrupt-stop.mjs` → `buildNextState`) from the SAME
91
+ * enqueue-envelope `source` tag `writeOutboxRecord` reads for the outbox
92
+ * capture path; the gateway's own `writeSilentEndState` never sets it.
93
+ * Consumed by `deliverCapturedProse` (`gateway/outbound-send-path.ts`),
94
+ * which is the ELECTED path's deliverer — it never writes an outbox record,
95
+ * so the sweep's audience gate / self-improvement framing can never reach
96
+ * this message, and this is the only way that machine can learn the
97
+ * provenance. Restores the #4141-style symmetry #4485 left one-sided (card
98
+ * gate + title framing applied at the sweep only).
99
+ *
100
+ * Optional and only ever `true`: absent means "no positive evidence of a
101
+ * review-origin turn", which delivers exactly as before. It never widens
102
+ * suppression on a normal user turn.
103
+ */
104
+ reviewOriginated?: boolean
85
105
  }
86
106
 
87
107
  export interface SilentEndDeps {
@@ -346,6 +366,15 @@ export interface CapturedProseDecision {
346
366
  * `true` only on positive evidence; never affects `deliver`.
347
367
  */
348
368
  replyToolThrewThisTurn?: boolean
369
+ /**
370
+ * #4490 — the persisted record says this turn originated from a
371
+ * self-improvement review inbound. Surfaced (not acted on) so
372
+ * `deliverCapturedProse` can apply the SAME card-gate / title-framing rules
373
+ * the outbox sweep applies. `true` only on positive evidence; never affects
374
+ * `deliver` here — the audience decision is made downstream, exactly as
375
+ * `replyToolThrewThisTurn`'s banner decision is.
376
+ */
377
+ reviewOriginated?: boolean
349
378
  /** Machine-readable reason (for logs / tests). */
350
379
  reason:
351
380
  | 'captured-prose'
@@ -426,17 +455,31 @@ export function decideCapturedProseDelivery(
426
455
  // AFTER every `deliver:false` gate above — the banner is a labelling
427
456
  // concern, and must never become an input to whether we deliver at all.
428
457
  ...(state.replyToolThrewThisTurn === true ? { replyToolThrewThisTurn: true } : {}),
458
+ // #4490: same discipline for the review-origin signal — placed after every
459
+ // `deliver:false` gate so it can never become an input to whether the
460
+ // (structurally different) "should we deliver at all" decision above
461
+ // fires. The caller decides suppression/framing from this raw flag.
462
+ ...(state.reviewOriginated === true ? { reviewOriginated: true } : {}),
429
463
  }
430
464
  }
431
465
 
432
466
  /**
433
467
  * Outcome of a captured-prose send attempt (Option A transcript-prose bridge).
434
- * - `sent` — the answer was delivered fresh this call.
435
- * - `skipped-dedup`— the exact answer already went out (dedup hit); nothing
436
- * new was sent, but the answer IS with the user.
437
- * - `failed` — the send threw; the answer did NOT reach the user.
468
+ * - `sent` — the answer was delivered fresh this call.
469
+ * - `skipped-dedup` — the exact answer already went out (dedup hit);
470
+ * nothing new was sent, but the answer IS with the
471
+ * user.
472
+ * - `failed` — the send threw; the answer did NOT reach the
473
+ * user.
474
+ * - `suppressed-internal`— #4490: the audience gate classified this prose
475
+ * as internal (a non-card self-improvement review
476
+ * turn) and it was never sent. Falls through the
477
+ * SAME close-obligation + clear-state bookkeeping
478
+ * as `sent` / `skipped-dedup` below — no operator
479
+ * is waiting on a message that was never meant to
480
+ * reach them, so there is nothing to recover.
438
481
  */
439
- export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed'
482
+ export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed' | 'suppressed-internal'
440
483
 
441
484
  /**
442
485
  * The bookkeeping effects the gateway applies after a captured-prose send
@@ -1,14 +1,4 @@
1
- import { describe, expect, it, vi, beforeEach } from 'vitest'
2
-
3
- // The dispatcher's resume closure reaches for the durable outbound-text oracle
4
- // (history.hasOutboundWithText) to reconcile crash-idempotency. Mock it so these
5
- // stay pure vitest unit tests (no bun:sqlite / real DB) and we can control the
6
- // "did this chunk's text land durably?" answer per scenario.
7
- let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
8
- vi.mock('../history.js', () => ({
9
- hasOutboundWithText: (chatId: string, text: string, threadId: number | null, since: number) =>
10
- mockOracle(chatId, text, threadId, since),
11
- }))
1
+ import { describe, expect, it, beforeEach } from 'vitest'
12
2
 
13
3
  import {
14
4
  BackstopDeliveryLedger,
@@ -36,7 +26,24 @@ import {
36
26
  * (c) a fully-landed answer's represent sends NOTHING.
37
27
  * A test that would still pass if the resume re-sent a confirmed chunk is not a
38
28
  * test — every scenario asserts the exact `sendChunk` index set.
29
+ *
30
+ * The dispatcher's resume closure reaches for the durable outbound-text oracle
31
+ * (history.hasOutboundWithText) to reconcile crash-idempotency. `mockOracle`
32
+ * below is injected per-scenario via `createCapturedResumeDispatcher`'s
33
+ * `hasOutboundWithText` port (see `gateway/captured-answer-resume.ts`) — NOT
34
+ * via `vi.mock('../history.js', ...)`. That used to module-mock `history.js`,
35
+ * which looks file-scoped under vitest but under bun's vitest-compat layer
36
+ * `vi.mock` maps onto the process-global `mock.module`: `bun test` runs the
37
+ * whole `telegram-plugin/` surface in one process
38
+ * (`telegram-plugin/scripts/bun-test-ci.sh`), so the mock retroactively
39
+ * rebound `hasOutboundWithText` for every OTHER file in the same sweep —
40
+ * including `tests/history.test.ts`'s calls to the REAL implementation. That
41
+ * was the actual root cause of #4488/#4491: a row `history.test.ts` had
42
+ * genuinely just written to its own real bun:sqlite DB was reported "not
43
+ * found" because its `hasOutboundWithText` import had been silently rebound
44
+ * to this file's `() => false` default. See check-bun-module-mock-scope.mjs.
39
45
  */
46
+ let mockOracle: (chatId: string, text: string, threadId: number | null, since: number) => boolean = () => false
40
47
 
41
48
  beforeEach(() => {
42
49
  mockOracle = () => false
@@ -192,6 +199,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
192
199
  oblLedger.noteCapturedDelivery('#t1', snapshot)
193
200
 
194
201
  const dispatcher = createCapturedResumeDispatcher({
202
+ hasOutboundWithText: mockOracle,
195
203
  deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
196
204
  obligationLedger: oblLedger,
197
205
  backstopDeliveryLedger: resumeLedger,
@@ -228,6 +236,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
228
236
  mockOracle = (_c, txt) => txt === 'c0'
229
237
 
230
238
  const dispatcher = createCapturedResumeDispatcher({
239
+ hasOutboundWithText: mockOracle,
231
240
  deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
232
241
  obligationLedger: oblLedger,
233
242
  backstopDeliveryLedger: resumeLedger,
@@ -258,6 +267,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
258
267
  oblLedger.noteCapturedDelivery('#t1', snapshot)
259
268
 
260
269
  const dispatcher = createCapturedResumeDispatcher({
270
+ hasOutboundWithText: mockOracle,
261
271
  deliverAnswer: makeResumeDeliverAnswer(resumeLedger, sentIdx),
262
272
  obligationLedger: oblLedger,
263
273
  backstopDeliveryLedger: resumeLedger,
@@ -302,6 +312,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
302
312
 
303
313
  const lines: string[] = []
304
314
  const dispatcher = createCapturedResumeDispatcher({
315
+ hasOutboundWithText: mockOracle,
305
316
  deliverAnswer: async (a) => {
306
317
  a.resume.hydrate(resumeLedger, a.turnId)
307
318
  const res = await runBackstopDelivery(
@@ -351,6 +362,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
351
362
 
352
363
  let probes = 0
353
364
  const dispatcher = createCapturedResumeDispatcher({
365
+ hasOutboundWithText: mockOracle,
354
366
  deliverAnswer: async (a) => {
355
367
  a.resume.hydrate(resumeLedger, a.turnId)
356
368
  const res = await runBackstopDelivery(
@@ -388,6 +400,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
388
400
 
389
401
  // A resume whose tail send keeps throwing ⇒ not delivered ⇒ obligation stays OPEN.
390
402
  const dispatcher = createCapturedResumeDispatcher({
403
+ hasOutboundWithText: mockOracle,
391
404
  deliverAnswer: async (a) => {
392
405
  a.resume.hydrate(resumeLedger, a.turnId)
393
406
  const res = await runBackstopDelivery(
@@ -423,6 +436,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
423
436
  oblLedger.noteCapturedDelivery('#t1', snapshot)
424
437
 
425
438
  const dispatcher = createCapturedResumeDispatcher({
439
+ hasOutboundWithText: mockOracle,
426
440
  deliverAnswer: async (a) => {
427
441
  deliverCalls++
428
442
  a.resume.hydrate(resumeLedger, a.turnId)
@@ -448,6 +462,7 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
448
462
  it('no captured snapshot ⇒ dispatch is a no-op (caller falls through to fresh generation)', async () => {
449
463
  let delivered = false
450
464
  const dispatcher = createCapturedResumeDispatcher({
465
+ hasOutboundWithText: mockOracle,
451
466
  deliverAnswer: async () => { delivered = true; return { delivered: true, sentIds: [] } },
452
467
  obligationLedger: new ObligationLedger(2),
453
468
  backstopDeliveryLedger: new BackstopDeliveryLedger(),
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ HINDSIGHT_BANK_GUARD_MARKER,
5
+ hindsightBankGuardTrips,
6
+ resetHindsightBankGuardTrips,
7
+ } from '../../tests/vitest-setup/hindsight-bank-guard-core.mjs'
8
+
9
+ /**
10
+ * Runtime alarm for the BUN half of the Hindsight bank hermeticity guard.
11
+ *
12
+ * vitest loads `tests/vitest-setup/hindsight-bank-guard.mjs` via
13
+ * `test.setupFiles`; `bun test` loads the same file via `[test] preload` in
14
+ * bunfig.toml (repo root) and telegram-plugin/bunfig.toml (CI's bun-test-run
15
+ * has `working-directory: telegram-plugin`, and bun reads the bunfig in its CWD
16
+ * only). Without the bun half, every bun-run test file can still reach the
17
+ * FLEET's Hindsight — which auto-creates a bank on miss, so one stray request
18
+ * mints a bank in the live instance. That is how eleven throwaway banks
19
+ * appeared there on 2026-07-30, one of them named `clerk`, colliding with a
20
+ * live agent and erasing the annotation that documented where that agent's
21
+ * memory actually lives.
22
+ *
23
+ * `npm run lint:hindsight-bank-hermeticity` pins the WIRING statically; this
24
+ * pins the EFFECT, so a bunfig that is present but no longer loading the guard
25
+ * (wrong relative path, bun config-discovery change) fails a test rather than
26
+ * silently un-protecting the runner.
27
+ *
28
+ * Imports the CORE, never the setup file — importing the setup file would
29
+ * INSTALL the guard and let this alarm heal itself. The replay target is
30
+ * 192.0.2.1 (TEST-NET-1, RFC 5737): unroutable, so an unwired run fails on the
31
+ * assertion rather than doing the thing the guard exists to prevent.
32
+ */
33
+ describe('bun test runs with the fleet Hindsight blocked', () => {
34
+ it('rejects a bank request to a fleet Hindsight origin', async () => {
35
+ resetHindsightBankGuardTrips()
36
+ let err: unknown
37
+ try {
38
+ await fetch('http://192.0.2.1:18888/v1/default/banks/clerk/config', {
39
+ signal: AbortSignal.timeout(250),
40
+ })
41
+ } catch (e) {
42
+ err = e
43
+ }
44
+ expect(
45
+ String((err as Error | undefined)?.message ?? ''),
46
+ 'bunfig.toml `[test] preload` did not install the Hindsight bank guard',
47
+ ).toContain(HINDSIGHT_BANK_GUARD_MARKER)
48
+ expect(hindsightBankGuardTrips()).toBe(1)
49
+ })
50
+ })