switchroom 0.15.45 → 0.16.5

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Feed-OPEN gate (deterministic activity-card OPEN gating).
3
+ *
4
+ * Pure decision: the activity-card drain (`drainActivitySummary`) is about to
5
+ * OPEN a brand-new card (`activityMessageId == null` → a fresh `sendMessage`).
6
+ * Should it be allowed to? This module encodes the two OPEN-suppression levers
7
+ * from `docs/message-emission-determinism.md` §9, so the gateway has ONE place
8
+ * to reason about WHEN a card may first appear on screen.
9
+ *
10
+ * Only the OPEN (first `sendMessage`) is gated. An EDIT of an already-open card
11
+ * (`activityMessageId != null`) is never blocked — once a card exists it must
12
+ * keep rendering, and an edit never reorders on screen.
13
+ *
14
+ * ## Lever 1 — no card OPEN after a substantive final (§9 lever 1, race A/B/E)
15
+ *
16
+ * Once a *substantive* final answer has been delivered this turn, no new card
17
+ * may open — it would land below the reply (higher message_id) and break the
18
+ * scoped "reply is last" invariant (§6). This keys on the STICKY
19
+ * `finalAnswerEverDelivered` latch, NOT the mutable `finalAnswerDelivered`
20
+ * (which the ack-reopen path clears mid-turn, `feed-reopen-gate.ts:157`). Keying
21
+ * on the mutable flag would be a no-op on exactly the ack-first turn where the
22
+ * reorder originates (design §9 preamble / R0). The sticky latch is set once a
23
+ * substantive final lands and is never cleared by reopen — so an "On it…" ack
24
+ * (non-substantive) does NOT trip it and the #2141 ack-then-work feed still
25
+ * opens.
26
+ *
27
+ * ## Lever 5 — INERT (pre-answer narrative OPEN now permitted, #2588)
28
+ *
29
+ * Lever 5 was added in #2557 to prevent a "triplication" reorder: a 0-tool
30
+ * conversational turn would open a card (message_id N), then the reply would
31
+ * send (message_id N+1) ABOVE the card, and then a Stop-hook re-prompt caused
32
+ * a second card below. The fix over-suppressed: it killed the card entirely
33
+ * rather than fixing the G2/G3 half of the triplication.
34
+ *
35
+ * **Lever 2 (`clearActivitySummary`) already owns reply-is-last ordering:** it
36
+ * edits the narrative card IN-PLACE before the reply chunks send, keeping its
37
+ * lower message_id and guaranteeing the reply lands above it — no reorder.
38
+ * Lever 5's open-suppression is therefore redundant and harms visibility:
39
+ * a pre-answer narrative on a 0-tool turn (the agent thinking aloud before
40
+ * dispatching tools) is exactly the kind of step operators want to see.
41
+ *
42
+ * Lever 5 is now INERT for the pre-answer case. Post-answer is already blocked
43
+ * by Lever 1 (`finalAnswerEverDelivered`), so Lever 5 was unreachable there
44
+ * anyway. The `labeledToolCount` field remains in `FeedOpenInput` — it is still
45
+ * used by R4 (a turn that starts conversational then dispatches a tool opens on
46
+ * the first label) and by tests.
47
+ *
48
+ * ## Lever 4 — no card OPEN below an EARLIER turn's answer (§9 lever 4, race C/D)
49
+ *
50
+ * The CROSS-turn case. Lever 1's `finalAnswerEverDelivered` latch is PER-TURN —
51
+ * it is reset to `false` at every turn ctor (mirroring `activityEverOpened`). So
52
+ * a SYNTHETIC turn born from a cross-turn surface — the `obligation_represent`
53
+ * re-delivery (`obligationSweep`, race D) or the liveness/heartbeat timer firing
54
+ * on it — starts with a cleared latch EVEN WHEN a substantive answer already
55
+ * reached the user in an EARLIER turn of this exchange. Its first drain would
56
+ * then OPEN a "thinking…" card BELOW that already-delivered reply (higher
57
+ * message_id → reorders, breaking the scoped "reply is last" invariant §6 across
58
+ * the turn boundary). This is the cousin of the §5 represent-duplicate bug, at
59
+ * the card layer instead of the message layer.
60
+ *
61
+ * Lever 4 closes it with a cross-turn signal the CALLER computes and passes in:
62
+ * `crossTurnAnswerDelivered` is true iff (a) this is a cross-turn synthetic
63
+ * surface (a represent / owed-reply turn — NOT the foreground turn's own card)
64
+ * AND (b) a SUBSTANTIVE (≥`FINAL_ANSWER_MIN_CHARS`) outbound reply has already
65
+ * been delivered to this chat SINCE THE OBLIGATION/TIMER WAS RAISED — checked via
66
+ * the SAME `hasOutboundDeliveredSince` history predicate the represent guard uses
67
+ * (`represent-guard.ts`), not a parallel mechanism. When true, NO producer may
68
+ * OPEN a card; edits of an already-open id are still allowed. Checked FIRST.
69
+ *
70
+ * Three deliberate scoping properties keep it from over-firing:
71
+ * - SCOPED to synthetic cross-turn surfaces by the caller (a normal foreground
72
+ * turn passes `false`), so it can never suppress the foreground turn's card.
73
+ * - Keys on SUBSTANTIVE delivery (the 200-char proxy), never on an ack — so it
74
+ * does NOT regress #2141: an ack-then-work turn has no substantive row and
75
+ * its feed still opens.
76
+ * - Does NOT touch the represent SEND itself — only the decorative card. A
77
+ * genuinely-unanswered obligation is STILL re-asked (the represent guard, not
78
+ * this gate, owns suppressing an already-satisfied represent). This gate only
79
+ * suppresses the card that would otherwise narrate beneath an answer the user
80
+ * already received.
81
+ */
82
+
83
+ /** Which producer triggered this drain — determines lever-5 OPEN eligibility. */
84
+ export type FeedOpenProducer =
85
+ /** Narrative SHOW (producer A): plain assistant text, no tool, no time
86
+ * threshold. May only EDIT, never OPEN, while the turn has 0 tool labels. */
87
+ | 'narrative'
88
+ /** Tool label (producer B): the model dispatched a tool. OPEN-eligible unless a
89
+ * substantive final already landed (lever 1).
90
+ * Foreground sub-agent renders (a Task tool) are tool work too → 'tool'. */
91
+ | 'tool'
92
+ /** Liveness timer (producer C): a genuine ≥12s thinking-gap open, or the
93
+ * labelled-feed heartbeat maintaining an open card. OPEN-eligible unless a
94
+ * substantive final already landed (lever 1). */
95
+ | 'liveness'
96
+
97
+ export interface FeedOpenInput {
98
+ /** Which producer triggered the drain (see FeedOpenProducer). */
99
+ producer: FeedOpenProducer
100
+ /** Sticky latch: has a *substantive* final answer ever been delivered this
101
+ * turn? Set once and never cleared by reopen (lever 1 / R0). */
102
+ finalAnswerEverDelivered: boolean
103
+ /** Count of surfaced tool steps this turn (`turn.labeledToolCount`). 0 means
104
+ * no tool has ever produced a label — pure conversation / thinking. */
105
+ labeledToolCount: number
106
+ /** Lever 4 (cross-turn / race C/D). True iff this is a cross-turn SYNTHETIC
107
+ * surface (an `obligation_represent` / owed-reply turn, or the liveness timer
108
+ * firing on it) AND a SUBSTANTIVE reply has already been delivered to this
109
+ * chat since the obligation/timer was raised — computed by the caller via the
110
+ * reused `hasOutboundDeliveredSince` history predicate with the obligation's
111
+ * `openedAt` cutoff. When true, no producer may OPEN a card (it would land
112
+ * below the earlier reply). A normal foreground turn passes `false` (or omits
113
+ * it), so lever 4 is inert there. Defaults to `false`. */
114
+ crossTurnAnswerDelivered?: boolean
115
+ /**
116
+ * Fix 2 (post-answer background-agent liveness, #2587 supersede): true when
117
+ * the sub-agent/workflow watcher has produced a NEW activity step AFTER the
118
+ * substantive final answer was delivered. This signal is updated by the watcher
119
+ * callback INDEPENDENTLY of the tool_label path, so the drop-guard
120
+ * (`shouldReopenFeedAfterAck` / `turn.finalAnswerSubstantive`) does NOT gate it.
121
+ *
122
+ * When true AND `producer === 'tool'`, Lever 1's blanket post-answer block is
123
+ * lifted: a liveness card surfaces below the reply to show "background agent
124
+ * still working". Idle producers (`liveness`, `narrative`) remain blocked after
125
+ * the final answer — the reply-is-last invariant is preserved for idle gaps.
126
+ * Only the `feedHeartbeatTick` post-answer branch sets this; it reads
127
+ * `turn.subagentActivityAt > (turn.finalAnswerDeliveredAt ?? 0)` (a tool-label
128
+ * rendered genuinely after the answer) rather than the frozen `lastToolLabelAt`.
129
+ *
130
+ * Defaults to `false` (Lever 1 stays fully active) — callers that don't pass
131
+ * this see no behaviour change.
132
+ */
133
+ postAnswerSubagentActivity?: boolean
134
+ }
135
+
136
+ /**
137
+ * Pure. Given the drain is about to OPEN a fresh card, returns true when the
138
+ * OPEN is allowed. An EDIT (caller has a non-null `activityMessageId`) does NOT
139
+ * consult this — only the OPEN branch does.
140
+ *
141
+ * - crossTurnAnswerDelivered → false: lever 4. A cross-turn synthetic surface
142
+ * whose exchange already delivered a substantive answer in an EARLIER turn;
143
+ * no card may open below it (any producer). Checked FIRST.
144
+ * - finalAnswerEverDelivered && !postAnswerSubagentActivity → false: lever 1.
145
+ * A substantive final already landed THIS turn; no card may open below it.
146
+ * Exception: when `postAnswerSubagentActivity === true` AND `producer ===
147
+ * 'tool'`, Lever 1 is lifted so the background-agent liveness heartbeat can
148
+ * surface a card below the reply showing the watcher's real new activity.
149
+ * Idle producers ('liveness', 'narrative') stay blocked — no card opens from
150
+ * wall-clock alone after the final answer.
151
+ * - producer 'narrative': always allowed when pre-answer (lever 5 is INERT —
152
+ * Lever 2 / clearActivitySummary guarantees reply-is-last ordering instead).
153
+ * - producer 'tool' or 'liveness' → true (unless lever 1/4).
154
+ */
155
+ export function mayOpenActivityCard(input: FeedOpenInput): boolean {
156
+ // Lever 4 — cross-turn: nothing opens on a synthetic represent/owed-reply
157
+ // surface whose exchange already delivered a substantive answer in an earlier
158
+ // turn (race C/D). Checked FIRST, above lever 1, and scoped to cross-turn
159
+ // synthetic surfaces by the caller so it can never fire on a foreground turn.
160
+ if (input.crossTurnAnswerDelivered) return false
161
+ // Lever 1 — sticky: nothing opens after a substantive final answer, EXCEPT
162
+ // when genuine post-answer sub-agent/watcher activity warrants a liveness card
163
+ // (Fix 2 / #2587 supersede). Only 'tool' is exempted so idle liveness and
164
+ // narrative producers remain blocked after the final answer.
165
+ if (input.finalAnswerEverDelivered) {
166
+ if (input.postAnswerSubagentActivity && input.producer === 'tool') return true
167
+ return false
168
+ }
169
+ // Lever 5 — INERT (see module comment above). Pre-answer narrative may now
170
+ // open a card; Lever 2 (clearActivitySummary) handles reply-is-last ordering.
171
+ return true
172
+ }
173
+
174
+ /**
175
+ * Injected dependencies for the lever-4 cross-turn history check (PR-4b).
176
+ *
177
+ * Passed in EXPLICITLY so `feed-open-gate.ts` stays sqlite-free — it does NOT
178
+ * import `history.js`, so the module (and its vitest suite) never transitively
179
+ * pulls in `bun:sqlite`. The gateway centralizes the wiring (the real
180
+ * `hasOutboundDeliveredSince` predicate + `HISTORY_ENABLED` + the substantive
181
+ * `FINAL_ANSWER_MIN_CHARS` floor) in one place (`emissionAuthorityFor`); tests
182
+ * inject the real history harness (`cross-turn-card-gate.test.ts`) or a stub.
183
+ */
184
+ export interface FeedOpenGateDeps {
185
+ /** The represent-guard's delivered-since predicate (`history.ts`). Returns
186
+ * true iff a ≥`minChars` outbound landed in `chatId`/`threadId` since
187
+ * `sinceMs`. Injected so this module never imports `history.js`. */
188
+ hasOutboundDeliveredSince: (
189
+ chatId: string,
190
+ sinceMs: number,
191
+ threadId: number | null | undefined,
192
+ minChars: number,
193
+ ) => boolean
194
+ /** Whether history is enabled (the gateway's `HISTORY_ENABLED`). When false,
195
+ * the cross-turn check short-circuits to false — exactly as the drain does. */
196
+ historyEnabled: boolean
197
+ /** The substantive 200-char floor (`FINAL_ANSWER_MIN_CHARS`) — an ack never
198
+ * trips lever 4 (keeps #2141 green). */
199
+ finalAnswerMinChars: number
200
+ }
201
+
202
+ /**
203
+ * The per-turn surface the OPEN verdict reads. A structural subset of
204
+ * `CurrentTurn` — kept SEPARATE from the façade's minimal `EmissionTurnView`
205
+ * (which is scoped to the single-flight `activityInFlight` read) so neither
206
+ * leaks the other's concern. Mirrors the exact fields the drain's inline
207
+ * cross-turn computation + `mayOpenActivityCard` consult.
208
+ */
209
+ export interface FeedOpenGateView {
210
+ /** Single in-place card transport id. `null` ⇒ the next drain would OPEN a
211
+ * fresh card; non-null ⇒ an EDIT (never gated). */
212
+ activityMessageId: number | null
213
+ /** Sticky lever-1 latch (per-turn). */
214
+ finalAnswerEverDelivered: boolean
215
+ /** Surfaced tool steps this turn (lever-5 base case). */
216
+ labeledToolCount: number
217
+ /** Present ONLY on a cross-turn synthetic surface (represent / owed-reply).
218
+ * Carries the obligation's `openedAt` as the delivered-since cutoff. A
219
+ * foreground turn omits it, so lever 4 is inert there. */
220
+ crossTurnGate?: { sinceMs: number }
221
+ /** Chat the card targets (null ⇒ no history query). */
222
+ sessionChatId: string | null
223
+ /** Forum-topic thread, if any (scopes the delivered-since check). */
224
+ sessionThreadId?: number | null
225
+ }
226
+
227
+ /**
228
+ * Lever-4 cross-turn predicate — PURE, lifted VERBATIM from the inline
229
+ * computation `drainActivitySummary` ran in main (the OPEN branch). True iff
230
+ * this drain is about to OPEN a fresh card (`activityMessageId == null`) on a
231
+ * cross-turn synthetic surface (`crossTurnGate != null`) whose exchange already
232
+ * delivered a SUBSTANTIVE reply since the obligation was raised. Identical
233
+ * inputs ⇒ identical result to the drain's own (now-redundant) gate, so flag-ON
234
+ * and flag-OFF agree by construction.
235
+ */
236
+ export function computeCrossTurnAnswerDelivered(
237
+ view: FeedOpenGateView,
238
+ deps: FeedOpenGateDeps,
239
+ ): boolean {
240
+ return (
241
+ view.activityMessageId == null
242
+ && view.crossTurnGate != null
243
+ && view.sessionChatId != null
244
+ && deps.historyEnabled
245
+ && deps.hasOutboundDeliveredSince(
246
+ view.sessionChatId,
247
+ view.crossTurnGate.sinceMs,
248
+ view.sessionThreadId,
249
+ deps.finalAnswerMinChars,
250
+ )
251
+ )
252
+ }
253
+
254
+ /** The OPEN-gate verdict for a turn view + producer (PR-4b). */
255
+ export interface FeedOpenVerdict {
256
+ /** A card is already open (`activityMessageId != null`) ⇒ this drain EDITs,
257
+ * which is NEVER gated. The façade applies unconditionally when `isOpen`. */
258
+ isOpen: boolean
259
+ /** Whether an OPEN would be allowed — the EXACT `mayOpenActivityCard(...)`
260
+ * result the drain computes (lever 1 + lever 4 + lever 5). When `!isOpen`
261
+ * and `!mayOpen`, the drain `break`s (refuses the OPEN); the façade skips
262
+ * `apply()`. When `isOpen`, this is consulted only by the equivalence test. */
263
+ mayOpen: boolean
264
+ }
265
+
266
+ /**
267
+ * Compute the OPEN-gate verdict for a turn view + producer (PR-4b). PURE: wraps
268
+ * `mayOpenActivityCard` over `computeCrossTurnAnswerDelivered`, with history
269
+ * deps injected. Returns BOTH `isOpen` (is a card already open → EDIT, never
270
+ * gated) and `mayOpen` (the raw `mayOpenActivityCard` verdict).
271
+ *
272
+ * The façade relocates main's drain decision with this: main refuses (and
273
+ * `break`s) iff `activityMessageId == null && !mayOpenActivityCard(...)`, i.e.
274
+ * `!isOpen && !mayOpen`. So the façade calls `apply()` iff `isOpen || mayOpen`
275
+ * — exactly the cases main did NOT `break`. Same pure inputs ⇒ same verdict on
276
+ * flag-ON and flag-OFF, so no emitted message differs in either flag state.
277
+ */
278
+ export function computeFeedOpenVerdict(
279
+ view: FeedOpenGateView,
280
+ producer: FeedOpenProducer,
281
+ deps: FeedOpenGateDeps,
282
+ ): FeedOpenVerdict {
283
+ const isOpen = view.activityMessageId != null
284
+ const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered(view, deps)
285
+ const mayOpen = mayOpenActivityCard({
286
+ producer,
287
+ finalAnswerEverDelivered: view.finalAnswerEverDelivered,
288
+ labeledToolCount: view.labeledToolCount,
289
+ crossTurnAnswerDelivered,
290
+ })
291
+ return { isOpen, mayOpen }
292
+ }