switchroom 0.20.11 → 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.
Files changed (50) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +32 -25
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/self-improve-stop.mjs +13 -1
  5. package/dist/cli/switchroom.js +3069 -1146
  6. package/dist/host-control/main.js +33 -26
  7. package/dist/vault/approvals/kernel-server.js +32 -25
  8. package/dist/vault/broker/server.js +32 -25
  9. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  10. package/package.json +7 -4
  11. package/skills/switchroom-architecture/telegram.md +0 -1
  12. package/skills/switchroom-cli/SKILL.md +0 -1
  13. package/skills/switchroom-release/SKILL.md +3 -2
  14. package/telegram-plugin/README.md +2 -11
  15. package/telegram-plugin/bridge/bridge.ts +0 -12
  16. package/telegram-plugin/bunfig.toml +9 -5
  17. package/telegram-plugin/chat-lock.ts +1 -1
  18. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  19. package/telegram-plugin/dist/gateway/gateway.js +219 -127
  20. package/telegram-plugin/dist/server.js +0 -12
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
  22. package/telegram-plugin/gateway/gateway.ts +25 -59
  23. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  24. package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
  25. package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
  26. package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
  27. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  28. package/telegram-plugin/gateway/stream-render.ts +8 -4
  29. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  30. package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
  31. package/telegram-plugin/hooks/audience-classify.mjs +193 -0
  32. package/telegram-plugin/hooks/hooks.json +13 -12
  33. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  34. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
  35. package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
  36. package/telegram-plugin/outbox.ts +69 -3
  37. package/telegram-plugin/silent-end.ts +48 -5
  38. package/telegram-plugin/status-pin.ts +2 -5
  39. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  40. package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
  41. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  42. package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
  43. package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
  44. package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
  45. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  46. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  48. package/telegram-plugin/worker-activity-feed.ts +1 -1
  49. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
@@ -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
@@ -210,12 +210,9 @@ export function isUnpinTerminalError(err: unknown): boolean {
210
210
  * Deliberately IN-MEMORY / per-boot only: pin rights may be granted later, and
211
211
  * Telegram surfaces the new permission to the bot only on a fresh chat-member
212
212
  * fetch — a gateway restart. Clearing the cache on restart is therefore the
213
- * correct re-enable trigger. An explicit `pin_message` tool success in a chat
214
- * also clears its entry (rights were granted mid-session).
213
+ * correct re-enable trigger.
215
214
  *
216
- * Scope: the AUTO status-pin path only. The explicit `pin_message` MCP tool
217
- * never consults this cache — it always attempts and surfaces the error to the
218
- * agent as a normal tool error.
215
+ * Scope: the AUTO status-pin path only (`pin_status_while_working`).
219
216
  */
220
217
  export class PinRightsCache {
221
218
  private readonly blocked = new Set<string>()
@@ -121,9 +121,9 @@ describe('selectBackstopDelivery — deterministic backstop coalescer (#3513 fol
121
121
  // ── isEphemeralTool — MF4 exact set ─────────────────────────────────────────
122
122
 
123
123
  describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
124
- it('recognises exactly {react, send_typing, pin_message, delete_message, edit_message}, MCP-prefixed or bare', () => {
124
+ it('recognises exactly {react, send_typing, delete_message, edit_message}, MCP-prefixed or bare', () => {
125
125
  expect([...EPHEMERAL_TOOLS].sort()).toEqual(
126
- ['delete_message', 'edit_message', 'pin_message', 'react', 'send_typing'].sort(),
126
+ ['delete_message', 'edit_message', 'react', 'send_typing'].sort(),
127
127
  )
128
128
  for (const t of EPHEMERAL_TOOLS) {
129
129
  expect(isEphemeralTool(t)).toBe(true)
@@ -132,6 +132,12 @@ describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
132
132
  }
133
133
  })
134
134
 
135
+ it('pin_message is NOT ephemeral — the pin_message MCP tool was retired (#4452)', () => {
136
+ expect(EPHEMERAL_TOOLS.has('pin_message')).toBe(false)
137
+ expect(isEphemeralTool('pin_message')).toBe(false)
138
+ expect(isEphemeralTool('mcp__switchroom-telegram__pin_message')).toBe(false)
139
+ })
140
+
135
141
  it('progress_update is NOT ephemeral (MF4 — dropped from the set)', () => {
136
142
  expect(isEphemeralTool('progress_update')).toBe(false)
137
143
  expect(isEphemeralTool('mcp__switchroom-telegram__progress_update')).toBe(false)
@@ -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,125 @@
1
+ /**
2
+ * M0 metric-corruption regression — the framework_fallback `turn_ended` emitter
3
+ * must never write an absolute Unix-epoch value as `duration_ms`.
4
+ *
5
+ * ROOT CAUSE (fixed by this change): the 300 s framework-fallback unwedge in
6
+ * `liveness-wiring.ts` read the turn's start from `activeTurnStartedAt.get(key)`
7
+ * and, guarded only by a `!= null` presence check, computed
8
+ * `Date.now() - turnStartedAt` inline. When the map held `0` (a bogus / not-yet
9
+ * stamped start — reproduced here exactly as the parked-turn fixture does with
10
+ * `activeTurnStartedAt.set(KEY, 0)`), it emitted `duration_ms = Date.now() - 0`,
11
+ * i.e. the current epoch-ms — a ~56,000-year "duration". The analysed dataset
12
+ * carried 110 such rows (`duration_ms === ts`, `ended_via: "framework_fallback"`),
13
+ * making every latency aggregate unusable.
14
+ *
15
+ * The two `stream-render.ts` turn_ended paths already guarded with
16
+ * `startedAt > 0 ? … : 0`; this path had drifted. The fix routes all emitters
17
+ * through the shared `computeTurnDurationMs`, which returns 0 for a non-positive
18
+ * start.
19
+ *
20
+ * This test drives the REAL `onFrameworkFallback` (via `buildSilencePokeOptions`,
21
+ * same standard as the other liveness-wiring tests) with a 0 start and asserts on
22
+ * the ACTUAL emitted `turn_ended` row (captured through the real runtime-metrics
23
+ * JSONL sink, pinned to a temp file). It asserts the OUTCOME — the emitted
24
+ * `duration_ms` — not a code path.
25
+ *
26
+ * Pre-fix: `duration_ms` ≈ `Date.now()` (~1.78e12). Post-fix: `duration_ms === 0`.
27
+ */
28
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
29
+ import { mkdtempSync, readFileSync, existsSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join } from 'node:path'
32
+ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
33
+ import {
34
+ __setRuntimeMetricsPathForTests,
35
+ } from '../runtime-metrics.js'
36
+ import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
37
+
38
+ const CHAT = '-100999'
39
+
40
+ function readTurnEndedRows(path: string): Array<Record<string, unknown>> {
41
+ if (!existsSync(path)) return []
42
+ return readFileSync(path, 'utf-8')
43
+ .split('\n')
44
+ .filter((l) => l.trim() !== '')
45
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
46
+ .filter((r) => r.kind === 'turn_ended')
47
+ }
48
+
49
+ describe('framework_fallback turn_ended never emits an epoch value as duration_ms', () => {
50
+ let metricsPath: string
51
+
52
+ beforeEach(() => {
53
+ const dir = mkdtempSync(join(tmpdir(), 'fallback-duration-'))
54
+ metricsPath = join(dir, 'runtime-metrics.jsonl')
55
+ __setRuntimeMetricsPathForTests(metricsPath)
56
+ })
57
+
58
+ afterEach(() => {
59
+ __setRuntimeMetricsPathForTests(null)
60
+ })
61
+
62
+ it('emits duration_ms === 0 for a zero/bogus start instead of Date.now()', async () => {
63
+ const KEY = statusKeyForTests(CHAT, null)
64
+ const fx = makeLivenessFixture()
65
+
66
+ // The wedged turn's start is 0 — the exact corruption trigger. Before the
67
+ // fix this made the fallback emit `Date.now() - 0`.
68
+ fx.activeTurnStartedAt.set(KEY, 0)
69
+ fx.setCurrentTurn(
70
+ makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }),
71
+ )
72
+
73
+ const opts = buildSilencePokeOptions(fx.deps)
74
+ await opts.onFrameworkFallback({
75
+ key: KEY,
76
+ chatId: CHAT,
77
+ threadId: null,
78
+ fallbackKind: 'working',
79
+ silenceMs: 302_000,
80
+ inFlightTools: [],
81
+ })
82
+
83
+ const rows = readTurnEndedRows(metricsPath)
84
+ const fallbackRow = rows.find((r) => r.ended_via === 'framework_fallback')
85
+ expect(fallbackRow, 'framework_fallback turn_ended row was emitted').toBeDefined()
86
+
87
+ const duration = fallbackRow!.duration_ms as number
88
+ // The falsifying assertion: pre-fix this is ~Date.now() (an epoch-ms value,
89
+ // ~1.78e12), and in particular equals the row's own `ts`. Post-fix it is 0.
90
+ expect(duration).toBe(0)
91
+ // Belt-and-braces: a duration can never be an absolute epoch stamp, and can
92
+ // never equal the emission timestamp.
93
+ expect(duration).not.toBe(fallbackRow!.ts as number)
94
+ expect(duration).toBeLessThan(365 * 24 * 60 * 60 * 1000) // < 1 year, sane bound
95
+ })
96
+
97
+ it('emits a real elapsed duration for a valid positive start', async () => {
98
+ const KEY = statusKeyForTests(CHAT, null)
99
+ const fx = makeLivenessFixture()
100
+
101
+ const startedAt = Date.now() - 302_000 // ~5 min ago
102
+ fx.activeTurnStartedAt.set(KEY, startedAt)
103
+ fx.setCurrentTurn(
104
+ makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#43` }),
105
+ )
106
+
107
+ const opts = buildSilencePokeOptions(fx.deps)
108
+ await opts.onFrameworkFallback({
109
+ key: KEY,
110
+ chatId: CHAT,
111
+ threadId: null,
112
+ fallbackKind: 'working',
113
+ silenceMs: 302_000,
114
+ inFlightTools: [],
115
+ })
116
+
117
+ const fallbackRow = readTurnEndedRows(metricsPath).find(
118
+ (r) => r.ended_via === 'framework_fallback',
119
+ )
120
+ const duration = fallbackRow!.duration_ms as number
121
+ // Around 302 s, with generous slack for test-runtime jitter.
122
+ expect(duration).toBeGreaterThanOrEqual(302_000)
123
+ expect(duration).toBeLessThan(360_000)
124
+ })
125
+ })
@@ -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
+ })