switchroom 0.19.22 → 0.19.24

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 (51) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +95 -2
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/switchroom.js +749 -357
  5. package/dist/host-control/main.js +96 -3
  6. package/dist/vault/approvals/kernel-server.js +98 -5
  7. package/dist/vault/broker/server.js +98 -5
  8. package/package.json +5 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +12 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +97 -107
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +267 -52
  20. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  22. package/telegram-plugin/gateway/gateway.ts +43 -42
  23. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  24. package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
  25. package/telegram-plugin/gateway/stream-render.ts +6 -0
  26. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  27. package/telegram-plugin/gateway/turn-end.ts +1 -1
  28. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  29. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  30. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  31. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  32. package/telegram-plugin/send-gate.ts +185 -24
  33. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  34. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  35. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  36. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  37. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  38. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  39. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  40. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  41. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  42. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  43. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  44. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  45. package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
  46. package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
  47. package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
  49. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
  50. package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
  51. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -271,6 +271,110 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
271
271
  expect(oblLedger.isOpen('#t1')).toBe(false)
272
272
  })
273
273
 
274
+ // #3702 L3 — the close condition is LANDED, not confirmed. A snapshot whose
275
+ // chunks all landed but were NEVER corroborated (the read-back is 100% shed in
276
+ // production, #3703) is resolved on HYDRATION alone: the resume re-probes,
277
+ // resolves nothing again, sends zero messages, and closes.
278
+ //
279
+ // This is the case the durable outbound-text oracle CANNOT reach — history
280
+ // disabled here, so `hasOutboundWithText` never fires and the old
281
+ // confirmation-gated verdict would have kept the obligation OPEN, re-running
282
+ // the same shed probe on every sweep with no send available to it, until the
283
+ // represent cap escalated a false "never answered you" to the operator.
284
+ it('fully-landed-but-UNCONFIRMED snapshot, no oracle ⇒ ZERO sends and the obligation CLOSES', async () => {
285
+ const chunks = ['c0', 'c1']
286
+ const snapshot = {
287
+ chunks,
288
+ chunkStates: [
289
+ { index: 0, messageIds: [5000], confirmed: false },
290
+ { index: 1, messageIds: [5001], confirmed: false },
291
+ ],
292
+ }
293
+ const resumeLedger = new BackstopDeliveryLedger()
294
+ const sentIdx: number[] = []
295
+ const oblLedger = new ObligationLedger(2)
296
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
297
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
298
+
299
+ // No durable corroboration is available from EITHER source: the snapshot
300
+ // flags are false, history is off, and the re-probe sheds (ambiguous).
301
+ mockOracle = () => false
302
+
303
+ const lines: string[] = []
304
+ const dispatcher = createCapturedResumeDispatcher({
305
+ deliverAnswer: async (a) => {
306
+ a.resume.hydrate(resumeLedger, a.turnId)
307
+ const res = await runBackstopDelivery(
308
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
309
+ {
310
+ sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
311
+ readBack: async (): Promise<ReadBackResult> => 'ambiguous',
312
+ },
313
+ 3,
314
+ )
315
+ expect(res.confirmed).toBe(false) // nothing corroborated it, at any point
316
+ expect(res.landedUnconfirmedIds).toEqual([5000, 5001]) // ...and it is counted
317
+ return { delivered: res.delivered, sentIds: res.sentIds }
318
+ },
319
+ obligationLedger: oblLedger,
320
+ backstopDeliveryLedger: resumeLedger,
321
+ flushedTurnSupersede: { record: () => {} },
322
+ historyEnabled: false,
323
+ stderr: (s) => lines.push(s),
324
+ })
325
+ dispatcher.dispatch(oblLedger.list()[0])
326
+ await flush(dispatcher)
327
+
328
+ expect(sentIdx).toEqual([]) // nothing re-posted — the user never sees a duplicate
329
+ expect(oblLedger.isOpen('#t1')).toBe(false) // closed on the landed-id evidence
330
+ // ...and the log must say what actually happened. These ids are LANDED, not
331
+ // confirmed: calling them "confirmed" is the overstatement #3702 removed from
332
+ // the verdict, and a log that reintroduces it re-lies to the next debugger.
333
+ const closed = lines.find((l) => l.includes('resume delivered'))
334
+ expect(closed).toBeDefined()
335
+ expect(closed).toContain('2 message id(s) landed')
336
+ expect(closed).not.toContain('confirmed')
337
+ })
338
+
339
+ // The other half of that decision: a positive ABSENCE still re-opens the send
340
+ // path, so "landed" is not a rubber stamp. (Inert in production until #3703
341
+ // wakes the probe, but the mechanism must remain correct.)
342
+ it('a landed chunk the re-probe finds ABSENT is demoted and RE-SENT, not closed on the stale id', async () => {
343
+ const chunks = ['c0']
344
+ const snapshot = { chunks, chunkStates: [{ index: 0, messageIds: [5000], confirmed: false }] }
345
+ const resumeLedger = new BackstopDeliveryLedger()
346
+ const sentIdx: number[] = []
347
+ const oblLedger = new ObligationLedger(2)
348
+ oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
349
+ oblLedger.noteCapturedDelivery('#t1', snapshot)
350
+ mockOracle = () => false
351
+
352
+ let probes = 0
353
+ const dispatcher = createCapturedResumeDispatcher({
354
+ deliverAnswer: async (a) => {
355
+ a.resume.hydrate(resumeLedger, a.turnId)
356
+ const res = await runBackstopDelivery(
357
+ resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
358
+ {
359
+ sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
360
+ readBack: async (): Promise<ReadBackResult> => (++probes === 1 ? 'absent' : 'exists'),
361
+ },
362
+ 3,
363
+ )
364
+ return { delivered: res.delivered, sentIds: res.sentIds }
365
+ },
366
+ obligationLedger: oblLedger,
367
+ backstopDeliveryLedger: resumeLedger,
368
+ flushedTurnSupersede: { record: () => {} },
369
+ historyEnabled: false,
370
+ })
371
+ dispatcher.dispatch(oblLedger.list()[0])
372
+ await flush(dispatcher)
373
+
374
+ expect(sentIdx).toEqual([0]) // the silently-dropped chunk IS re-sent
375
+ expect(oblLedger.isOpen('#t1')).toBe(false)
376
+ })
377
+
274
378
  it('a partial RESUME (tail still fails) leaves the obligation OPEN + consumes represent budget', async () => {
275
379
  const chunks = ['c0', 'c1']
276
380
  const snapshot = {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * #3725 — the "latest-ended" supersede anchor was the most-recently-STARTED turn.
3
+ *
4
+ * `recentTurnsById` is populated at turn START (`rememberRecentTurn` fires from
5
+ * the `enqueue` lifecycle event in `stream-render.ts`, and the atom is built with
6
+ * `endedAt: null`; `endedAt` is stamped later in `turn-end.ts`). The gateway's
7
+ * lookup had NO `endedAt` filter, so `findLatestEndedTurnForChat` returned the
8
+ * chat's tail entry even when that turn was still RUNNING — its own docstring
9
+ * admitted it ("the most-recently-STARTED turn") while the name and every caller
10
+ * said otherwise. Because the registry is chat-wide and thread-agnostic, a turn
11
+ * running in ANOTHER topic of the same chat was a live route into that state.
12
+ *
13
+ * These tests pin the OUTCOME of the scan (which turn comes back), not the
14
+ * shape of the code.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest'
18
+ import { latestTurnForChat, type LatestTurnLookupAtom } from '../gateway/latest-turn-lookup.js'
19
+
20
+ interface Turn extends LatestTurnLookupAtom {
21
+ turnId: string
22
+ sessionThreadId?: number
23
+ }
24
+
25
+ const CHAT = '12345'
26
+ const OTHER_CHAT = '99999'
27
+
28
+ /** Registry insertion order = turn-START order (what `rememberRecentTurn` does). */
29
+ const registry = (...turns: Turn[]): Map<string, Turn> =>
30
+ new Map(turns.map((t) => [t.turnId, t]))
31
+
32
+ describe('latestTurnForChat — endedOnly (#3725)', () => {
33
+ it('does NOT return a still-running turn as the latest ENDED turn — it falls ' +
34
+ 'back to the most recent turn that actually ended', () => {
35
+ // Topic A ended at t=1000; topic B started after and is STILL RUNNING, so it
36
+ // sits at the registry tail. This is the exact incident shape.
37
+ const ended = { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000, sessionThreadId: 11 }
38
+ const running = { turnId: 'B#2', sessionChatId: CHAT, endedAt: null, sessionThreadId: 22 }
39
+ const turns = registry(ended, running)
40
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBe(ended)
41
+ // The routing consumer still wants the tail entry (running or not) — it only
42
+ // picks a topic to deliver into and deletes nothing.
43
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).toBe(running)
44
+ })
45
+
46
+ it('returns NULL when the chat has only a running turn — the destructive tier ' +
47
+ 'gets no anchor at all rather than an unbounded one (fail closed)', () => {
48
+ const turns = registry({ turnId: 'B#2', sessionChatId: CHAT, endedAt: null })
49
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBeNull()
50
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).not.toBeNull()
51
+ })
52
+
53
+ it('picks the LAST ended turn in insertion order when several have ended', () => {
54
+ const turns = registry(
55
+ { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
56
+ { turnId: 'A#2', sessionChatId: CHAT, endedAt: 2_000 },
57
+ { turnId: 'B#3', sessionChatId: CHAT, endedAt: null },
58
+ )
59
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#2')
60
+ })
61
+
62
+ it('never crosses chats (the scan is chat-scoped in BOTH modes)', () => {
63
+ const turns = registry(
64
+ { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
65
+ { turnId: 'X#1', sessionChatId: OTHER_CHAT, endedAt: 2_000 },
66
+ { turnId: 'X#2', sessionChatId: OTHER_CHAT, endedAt: null },
67
+ )
68
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
69
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })?.turnId).toBe('A#1')
70
+ expect(latestTurnForChat(turns.values(), 'nobody', { endedOnly: false })).toBeNull()
71
+ })
72
+
73
+ it('an endedAt of 0 counts as ENDED (null is the only "still running" marker)', () => {
74
+ const turns = registry({ turnId: 'A#1', sessionChatId: CHAT, endedAt: 0 })
75
+ expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
76
+ })
77
+ })
@@ -35,6 +35,28 @@ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
35
35
  import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
36
36
  import { redact } from '../secret-detect/redact.js'
37
37
  import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
38
+ import type { ReplyOwnerTier } from '../reply-owner-resolve.js'
39
+
40
+ /** The owner-resolution shape `resolveReplyOwnerTurn` returns, including the
41
+ * candidate set the content-gate bypass corroborates against. These fixtures
42
+ * never exercise the supersede path, so the candidates mirror the resolved turn
43
+ * (the corroborated shape) with no override needed. */
44
+ function ownerRes(turn: CurrentTurn | null, tier: ReplyOwnerTier) {
45
+ const id = turn?.turnId ?? null
46
+ return {
47
+ turn,
48
+ tier,
49
+ candidates: {
50
+ liveTurnId: tier === 'live' ? id : null,
51
+ originTurnId: null,
52
+ quotedTurnId: null,
53
+ latestEndedTurnId: id,
54
+ latestEndedAgeMs: 1_000,
55
+ latestEndedTtlMs: 60_000,
56
+ },
57
+ }
58
+ }
59
+
38
60
 
39
61
  const CHAT = '1001'
40
62
 
@@ -371,7 +393,7 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
371
393
  assertSendable: () => {},
372
394
  statusKey: key,
373
395
  streamKey: key,
374
- resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
396
+ resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
375
397
  getLastSubagentHandbackAt: () => null,
376
398
  findTurnByOriginId: () => null,
377
399
  findTurnByQuotedMessageId: () => null,