switchroom 0.17.6 → 0.17.10

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 (155) hide show
  1. package/dist/agent-scheduler/index.js +38 -4
  2. package/dist/auth-broker/index.js +302 -203
  3. package/dist/cli/notion-write-pretool.mjs +35 -2
  4. package/dist/cli/switchroom.js +1178 -576
  5. package/dist/host-control/main.js +148 -14
  6. package/dist/vault/approvals/kernel-server.js +140 -55
  7. package/dist/vault/broker/server.js +142 -57
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/bridge/bridge.ts +80 -1
  13. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  14. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  15. package/telegram-plugin/consolidation-legibility.ts +279 -0
  16. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  17. package/telegram-plugin/dist/gateway/gateway.js +2565 -610
  18. package/telegram-plugin/dist/server.js +86 -2
  19. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  20. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  21. package/telegram-plugin/gateway/gateway.ts +1376 -82
  22. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  23. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  24. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  25. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  26. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  27. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  28. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  29. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  30. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  31. package/telegram-plugin/memory-legibility.ts +217 -0
  32. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  33. package/telegram-plugin/scoped-approval.ts +59 -0
  34. package/telegram-plugin/silent-end.ts +78 -0
  35. package/telegram-plugin/subagent-watcher.ts +60 -6
  36. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  38. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  39. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  40. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  41. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  42. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  43. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  44. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  45. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  46. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  47. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  48. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  49. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  50. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  51. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  52. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  53. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  54. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  55. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  56. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  57. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  58. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  59. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  60. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  61. package/telegram-plugin/uat/assertions.ts +88 -4
  62. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  63. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  64. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  65. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  66. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  71. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  72. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  73. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  74. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  75. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  77. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  81. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  94. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  95. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  96. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  97. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  98. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  126. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  127. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  129. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Outcome-based regression test for the deterministic silent-turn card climb.
3
+ *
4
+ * Phase 4(b) of `reference/rfcs/deterministic-turn-liveness.md`; serves
5
+ * `reference/jobs/know-what-my-agent-is-doing.md` (a busy-but-silent turn must
6
+ * visibly escalate, never freeze).
7
+ *
8
+ * ## What is REAL here, and what is a spy (no overclaim)
9
+ *
10
+ * The gateway is a start-on-import IIFE that cannot be instantiated in-process
11
+ * (the documented constraint behind every `*-wiring` structural test in this
12
+ * suite). So the shipped 0-label tick body was EXTRACTED into
13
+ * `feed-heartbeat-climb.ts` (`runSilentTurnHeartbeatTick`) and the gateway's
14
+ * `feedHeartbeatTick` calls it with its real deps — meaning the function under
15
+ * test IS the production decision logic, byte-for-byte, not a hand-copied
16
+ * mirror. What this test drives:
17
+ *
18
+ * - REAL: `runSilentTurnHeartbeatTick` + `silentTurnClimbRender` — the shipped
19
+ * guard placement (0-label check, open-vs-climb split, stale-guard, null
20
+ * render), the gate SEQUENCE (cardDrainGate → mayDrain → openOrEditCard →
21
+ * drain), and the rendered card bodies.
22
+ * - SPY: the injected deps. The `drain` seam is where the gateway's
23
+ * `drainActivitySummary` → `robustApiCall(bot.api.sendMessage /
24
+ * bot.api.editMessageText)` lives in production; the spy implements the
25
+ * drain's transport contract (send when no card id, edit once set) and
26
+ * records every call with a timestamp. The real `drainActivitySummary` /
27
+ * grammY transport is NOT executed here — the gateway wiring of the real
28
+ * deps (and the real drain call) is pinned structurally by
29
+ * `tests/feed-heartbeat-liveness-open.test.ts`, and end-to-end by the UAT
30
+ * liveness scenarios.
31
+ *
32
+ * ## What it asserts
33
+ *
34
+ * 1. A ~40s silent tool call produces >=4 card EDITS with a CLIMBING elapsed —
35
+ * the card never freezes. (Acceptance criterion of the RFC.)
36
+ * 2. Every climb mutation is an EDIT of the already-open card — never a new
37
+ * send — so no mid-turn mutation can push-notify the device.
38
+ * 3. Each climb edit passes through the full gate sequence in order:
39
+ * setPendingRender → cardDrainGate → mayDrain → openOrEditCard → drain
40
+ * (mis-wiring the guards inside the tick body fails this).
41
+ * 4. `mayDrain === false` (drain in flight) suppresses the edit — the
42
+ * single-flight guard is honoured.
43
+ * 5. The climb yields to model narration: a surfaced tool label
44
+ * (`mirrorLineCount > 0`) returns `false` (labelled branch owns the tick)
45
+ * and emits nothing.
46
+ * 6. The stale-guard: a turn younger than `minStaleMs` produces no edit (same
47
+ * symmetry the labelled branch has).
48
+ *
49
+ * ## Why this FAILS on pre-fix code (its acceptance criterion)
50
+ *
51
+ * Pre-fix, the 0-label branch delegated solely to `openLivenessFeedIfDue`,
52
+ * whose WHEN-gate returns false the moment a card is open — an open 0-label
53
+ * card received ZERO edits during a silent tool. Reverting
54
+ * `runSilentTurnHeartbeatTick` to that behaviour (the open-path delegate with
55
+ * no climb) drives this test's edit count to 0, failing assertion 1 — through
56
+ * the same shipped function the gateway calls. Removing the gateway call site
57
+ * instead is caught by the structural pin in
58
+ * `tests/feed-heartbeat-liveness-open.test.ts`.
59
+ */
60
+ import { describe, it, expect } from 'vitest'
61
+ import {
62
+ runSilentTurnHeartbeatTick,
63
+ silentTurnClimbRender,
64
+ type SilentTurnTickDeps,
65
+ } from '../feed-heartbeat-climb.js'
66
+
67
+ // The gateway constants that drive the tick cadence (gateway.ts).
68
+ const FEED_HEARTBEAT_TICK_MS = 6_000
69
+ const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
70
+ const FEED_LIVENESS_OPEN_MS = 1_200
71
+
72
+ /** A recorded transport call with the simulated wall-clock time it happened at. */
73
+ interface TransportCall {
74
+ kind: 'sendMessage' | 'editMessageText'
75
+ at: number
76
+ text: string
77
+ }
78
+
79
+ /** Parse the climbing elapsed (in seconds) out of a rendered card header line
80
+ * (`_6s · 0 tools_`, `_1m05s · 0 tools_`). Returns null if not present. */
81
+ function parseHeaderElapsedSeconds(text: string): number | null {
82
+ const m = text.match(/_((?:(\d+)m)?(\d+)s) · \d+ tools?_/)
83
+ if (m == null) return null
84
+ const minutes = m[2] ? Number(m[2]) : 0
85
+ const seconds = Number(m[3])
86
+ return minutes * 60 + seconds
87
+ }
88
+
89
+ /**
90
+ * Test double for the turn's card surface + injected deps. The `drain` spy
91
+ * implements the drain's transport contract — `sendMessage` (an OPEN, the only
92
+ * mutation that push-notifies) when no card id exists, `editMessageText` (a
93
+ * silent card EDIT) once it does — standing in for `drainActivitySummary` →
94
+ * `robustApiCall(bot.api.*)`. Also records the gate-call ORDER per tick so the
95
+ * shipped sequence is pinned, and simulates the shared OPEN path
96
+ * (`openLivenessFeedIfDue`) opening the placeholder card.
97
+ */
98
+ class TurnHarness {
99
+ calls: TransportCall[] = []
100
+ order: string[] = []
101
+ activityMessageId: number | null = null
102
+ pendingRender: string | null = null
103
+ mayDrainResult = true
104
+ private nextId = 100
105
+ now = 0
106
+
107
+ /** The real gateway wires these exact seams; each spy records its call. */
108
+ deps(openRender: () => string | null): SilentTurnTickDeps {
109
+ return {
110
+ openLivenessFeedIfDue: () => {
111
+ this.order.push('openLivenessFeedIfDue')
112
+ // Mirror of the shared OPEN path's contract: opens once, past its
113
+ // threshold, via a fresh sendMessage.
114
+ if (this.activityMessageId != null) return
115
+ const rendered = openRender()
116
+ if (rendered == null) return
117
+ this.activityMessageId = this.nextId++
118
+ this.calls.push({ kind: 'sendMessage', at: this.now, text: rendered })
119
+ },
120
+ setPendingRender: (rendered) => {
121
+ this.order.push('setPendingRender')
122
+ this.pendingRender = rendered
123
+ },
124
+ cardDrainGate: (run) => {
125
+ this.order.push('cardDrainGate')
126
+ run()
127
+ },
128
+ mayDrain: () => {
129
+ this.order.push('mayDrain')
130
+ return this.mayDrainResult
131
+ },
132
+ openOrEditCard: (apply) => {
133
+ this.order.push('openOrEditCard')
134
+ apply()
135
+ },
136
+ drain: () => {
137
+ this.order.push('drain')
138
+ // drainActivitySummary's transport contract: OPEN when no card id,
139
+ // EDIT in place once set. Here the card is always open (the tick only
140
+ // reaches drain via the climb), so this is always an edit.
141
+ const text = this.pendingRender ?? ''
142
+ if (this.activityMessageId == null) {
143
+ this.activityMessageId = this.nextId++
144
+ this.calls.push({ kind: 'sendMessage', at: this.now, text })
145
+ } else {
146
+ this.calls.push({ kind: 'editMessageText', at: this.now, text })
147
+ }
148
+ },
149
+ }
150
+ }
151
+
152
+ get sends(): TransportCall[] {
153
+ return this.calls.filter((c) => c.kind === 'sendMessage')
154
+ }
155
+ get edits(): TransportCall[] {
156
+ return this.calls.filter((c) => c.kind === 'editMessageText')
157
+ }
158
+ }
159
+
160
+ /** Drive the REAL tick across a silent window, the way the gateway's 6s
161
+ * setInterval does: same view fields, real deps thunks. */
162
+ function runSilentWindow(harness: TurnHarness, turnStart: number, windowMs: number): void {
163
+ const deps = harness.deps(() => 'placeholder')
164
+ for (let now = turnStart; now <= turnStart + windowMs; now += FEED_HEARTBEAT_TICK_MS) {
165
+ harness.now = now
166
+ const handled = runSilentTurnHeartbeatTick(
167
+ {
168
+ mirrorLineCount: 0,
169
+ activityMessageId: harness.activityMessageId,
170
+ labeledToolCount: 0,
171
+ ageMs: now - turnStart,
172
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
173
+ },
174
+ deps,
175
+ )
176
+ expect(handled).toBe(true) // 0-label turn: this branch owns every tick
177
+ }
178
+ }
179
+
180
+ describe('deterministic silent-turn card climb — real tick against a spy transport', () => {
181
+ it('a ~40s silent tool call yields >=4 card edits with climbing elapsed (never freezes)', () => {
182
+ const harness = new TurnHarness()
183
+ const turnStart = 1_000_000 // arbitrary simulated epoch
184
+ // Pre-open the card the way production does (early open ~1.2s in via the
185
+ // shared OPEN path) — the freeze reproduced specifically on an OPEN card.
186
+ harness.now = turnStart + FEED_LIVENESS_OPEN_MS
187
+ harness.deps(() => 'placeholder').openLivenessFeedIfDue()
188
+ expect(harness.activityMessageId).not.toBeNull()
189
+
190
+ runSilentWindow(harness, turnStart, 40_000)
191
+
192
+ // Exactly one OPEN (the pre-opened card) — the climb never sends.
193
+ expect(harness.sends.length).toBe(1)
194
+
195
+ // The acceptance criterion: the card is EDITED at least 4 times across the gap.
196
+ expect(
197
+ harness.edits.length,
198
+ `expected >=4 climbing card edits across a 40s silent tool, got ${harness.edits.length} ` +
199
+ `(pre-fix code freezes the 0-label card and produces 0 edits)`,
200
+ ).toBeGreaterThanOrEqual(4)
201
+
202
+ // The elapsed on each successive edit strictly climbs — no frozen counter.
203
+ const elapsedSeries = harness.edits
204
+ .map((c) => parseHeaderElapsedSeconds(c.text))
205
+ .filter((n): n is number => n != null)
206
+ expect(elapsedSeries.length).toBe(harness.edits.length)
207
+ for (let i = 1; i < elapsedSeries.length; i++) {
208
+ expect(
209
+ elapsedSeries[i],
210
+ `card elapsed must climb: edit[${i}]=${elapsedSeries[i]}s not > edit[${i - 1}]=${elapsedSeries[i - 1]}s`,
211
+ ).toBeGreaterThan(elapsedSeries[i - 1])
212
+ }
213
+
214
+ // Worst-case visible-update gap stays within ~two tick windows (RFC bound).
215
+ const editTimes = [harness.sends[0].at, ...harness.edits.map((c) => c.at)]
216
+ for (let i = 1; i < editTimes.length; i++) {
217
+ expect(editTimes[i] - editTimes[i - 1]).toBeLessThanOrEqual(2 * FEED_HEARTBEAT_TICK_MS)
218
+ }
219
+ })
220
+
221
+ it('every climb mutation is an editMessageText, never a new sendMessage (edits do not push-notify)', () => {
222
+ const harness = new TurnHarness()
223
+ const turnStart = 2_000_000
224
+ harness.now = turnStart + FEED_LIVENESS_OPEN_MS
225
+ harness.deps(() => 'placeholder').openLivenessFeedIfDue()
226
+ runSilentWindow(harness, turnStart, 40_000)
227
+
228
+ expect(harness.calls[0].kind).toBe('sendMessage')
229
+ for (const call of harness.calls.slice(1)) {
230
+ expect(call.kind).toBe('editMessageText')
231
+ }
232
+ })
233
+
234
+ it('each climb edit passes the full shipped gate sequence in order (pendingRender → cardDrainGate → mayDrain → openOrEditCard → drain)', () => {
235
+ const harness = new TurnHarness()
236
+ harness.activityMessageId = 55 // card already open
237
+ harness.now = 12_000
238
+ runSilentTurnHeartbeatTick(
239
+ {
240
+ mirrorLineCount: 0,
241
+ activityMessageId: harness.activityMessageId,
242
+ labeledToolCount: 0,
243
+ ageMs: 12_000,
244
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
245
+ },
246
+ harness.deps(() => null),
247
+ )
248
+ expect(harness.order).toEqual([
249
+ 'setPendingRender',
250
+ 'cardDrainGate',
251
+ 'mayDrain',
252
+ 'openOrEditCard',
253
+ 'drain',
254
+ ])
255
+ })
256
+
257
+ it('a drain already in flight (mayDrain=false) suppresses the edit — single-flight honoured', () => {
258
+ const harness = new TurnHarness()
259
+ harness.activityMessageId = 55
260
+ harness.mayDrainResult = false
261
+ harness.now = 12_000
262
+ runSilentTurnHeartbeatTick(
263
+ {
264
+ mirrorLineCount: 0,
265
+ activityMessageId: 55,
266
+ labeledToolCount: 0,
267
+ ageMs: 12_000,
268
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
269
+ },
270
+ harness.deps(() => null),
271
+ )
272
+ expect(harness.edits.length).toBe(0)
273
+ // The gate was consulted, then the drain was NOT reached.
274
+ expect(harness.order).toContain('mayDrain')
275
+ expect(harness.order).not.toContain('drain')
276
+ })
277
+
278
+ it('the climb yields to model narration (a surfaced tool label hands the tick to the labelled branch)', () => {
279
+ const harness = new TurnHarness()
280
+ harness.activityMessageId = 55
281
+ const handled = runSilentTurnHeartbeatTick(
282
+ {
283
+ mirrorLineCount: 3, // narration / tool labels surfaced
284
+ activityMessageId: 55,
285
+ labeledToolCount: 2,
286
+ ageMs: 30_000,
287
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
288
+ },
289
+ harness.deps(() => null),
290
+ )
291
+ expect(handled).toBe(false) // labelled branch owns the tick
292
+ expect(harness.calls.length).toBe(0)
293
+ expect(harness.order.length).toBe(0)
294
+ })
295
+
296
+ it('the stale-guard: a turn younger than minStaleMs produces no climb edit (labelled-branch symmetry)', () => {
297
+ const harness = new TurnHarness()
298
+ harness.activityMessageId = 55
299
+ const handled = runSilentTurnHeartbeatTick(
300
+ {
301
+ mirrorLineCount: 0,
302
+ activityMessageId: 55,
303
+ labeledToolCount: 0,
304
+ ageMs: FEED_HEARTBEAT_MIN_STALE_MS - 1,
305
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
306
+ },
307
+ harness.deps(() => null),
308
+ )
309
+ expect(handled).toBe(true) // still the 0-label branch's tick
310
+ expect(harness.calls.length).toBe(0)
311
+ })
312
+
313
+ it('the climb does not OPEN a card — no card yet delegates to the one shared OPEN path', () => {
314
+ const harness = new TurnHarness()
315
+ harness.now = 20_000
316
+ runSilentTurnHeartbeatTick(
317
+ {
318
+ mirrorLineCount: 0,
319
+ activityMessageId: null,
320
+ labeledToolCount: 0,
321
+ ageMs: 20_000,
322
+ minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
323
+ },
324
+ harness.deps(() => 'open-render'),
325
+ )
326
+ // Delegated to openLivenessFeedIfDue; nothing else ran.
327
+ expect(harness.order).toEqual(['openLivenessFeedIfDue'])
328
+ expect(harness.sends.length).toBe(1) // the OPEN path opened it
329
+ expect(harness.edits.length).toBe(0)
330
+ })
331
+
332
+ it('silentTurnClimbRender refuses when no card is open (opening is reply-is-last gated elsewhere)', () => {
333
+ expect(
334
+ silentTurnClimbRender({ mirrorLineCount: 0, activityMessageId: null, labeledToolCount: 0, ageMs: 30_000 }),
335
+ ).toBeNull()
336
+ })
337
+ })
@@ -1296,6 +1296,145 @@ describe('startSubagentWatcher', () => {
1296
1296
  })
1297
1297
  })
1298
1298
 
1299
+ // ─── deterministic-turn-liveness.md Known-Gap 2 — worktree-isolated sub-agents ──
1300
+
1301
+ describe('worktree-isolated sub-agent visibility (turn-liveness Known Gap 2)', () => {
1302
+ /**
1303
+ * A sub-agent dispatched with `switchroom worktree claim` runs under a
1304
+ * different cwd than the parent agent, so Claude Code mints a
1305
+ * *different* `.claude/projects/<slug>/` tree for it. Pre-fix, the
1306
+ * #1116 foreign-slug filter above skips that slug forever — no
1307
+ * activity stamp, no `🛠 Worker` feed, for the whole worktree worker's
1308
+ * run. `extraWatchCwdsProvider` closes that gap: any cwd it returns is
1309
+ * treated as a first-class watched slug, same as `agentCwd`.
1310
+ */
1311
+
1312
+ let tmpRoot = ''
1313
+ const startedWatchers: Array<{ stop(): void }> = []
1314
+
1315
+ beforeEach(() => {
1316
+ tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-worktree-gap2-'))
1317
+ })
1318
+
1319
+ afterEach(() => {
1320
+ while (startedWatchers.length) {
1321
+ try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
1322
+ }
1323
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
1324
+ })
1325
+
1326
+ function startWatcherSync(opts: {
1327
+ agentDir: string
1328
+ agentCwd?: string
1329
+ extraWatchCwdsProvider?: () => string[]
1330
+ }): {
1331
+ logs: string[]
1332
+ poll: () => void
1333
+ watcher: ReturnType<typeof startSubagentWatcher>
1334
+ } {
1335
+ const logs: string[] = []
1336
+ const intervals: Array<{ fn: () => void; ref: number }> = []
1337
+ let nextRef = 1
1338
+ const watcher = startSubagentWatcher({
1339
+ agentDir: opts.agentDir,
1340
+ ...(opts.agentCwd !== undefined ? { agentCwd: opts.agentCwd } : {}),
1341
+ ...(opts.extraWatchCwdsProvider !== undefined
1342
+ ? { extraWatchCwdsProvider: opts.extraWatchCwdsProvider }
1343
+ : {}),
1344
+ stallThresholdMs: 60_000,
1345
+ rescanMs: 500,
1346
+ now: () => Date.now(),
1347
+ setInterval: (fn) => {
1348
+ const ref = nextRef++
1349
+ intervals.push({ fn, ref })
1350
+ return { ref }
1351
+ },
1352
+ clearInterval: () => {},
1353
+ setTimeout: () => ({ ref: 0 }),
1354
+ clearTimeout: () => {},
1355
+ log: (msg) => logs.push(msg),
1356
+ })
1357
+ startedWatchers.push(watcher)
1358
+ return { logs, poll: () => intervals[0]?.fn(), watcher }
1359
+ }
1360
+
1361
+ it('watches a worktree-isolated cwd slug returned by extraWatchCwdsProvider', () => {
1362
+ const agentDir = join(tmpRoot, 'agent')
1363
+ const agentCwd = '/home/test/agent-own'
1364
+ const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
1365
+ const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
1366
+ const worktreeSlug = join(
1367
+ agentDir,
1368
+ '.claude',
1369
+ 'projects',
1370
+ '-home-test--switchroom-worktrees-wt-abc123',
1371
+ 'sess-B',
1372
+ 'subagents',
1373
+ )
1374
+ mkdirSync(ownSubagents, { recursive: true })
1375
+ mkdirSync(worktreeSlug, { recursive: true })
1376
+ writeFileSync(
1377
+ join(ownSubagents, 'agent-ownworker.jsonl'),
1378
+ buildJSONL(subAgentUserMsg('legit work')),
1379
+ )
1380
+ writeFileSync(
1381
+ join(worktreeSlug, 'agent-worktreeworker.jsonl'),
1382
+ buildJSONL(subAgentUserMsg('worktree-isolated work')),
1383
+ )
1384
+
1385
+ // Pre-fix acceptance: without extraWatchCwdsProvider, the worktree
1386
+ // slug is a "foreign" slug per #1116 and is skipped — this is the
1387
+ // regression this test guards against.
1388
+ const preFix = startWatcherSync({ agentDir, agentCwd })
1389
+ preFix.poll()
1390
+ expect(preFix.watcher.getRegistry().has('worktreeworker')).toBe(false)
1391
+ preFix.watcher.stop()
1392
+
1393
+ const h = startWatcherSync({
1394
+ agentDir,
1395
+ agentCwd,
1396
+ extraWatchCwdsProvider: () => [worktreeCwd],
1397
+ })
1398
+ h.poll()
1399
+
1400
+ const reg = h.watcher.getRegistry()
1401
+ expect(reg.has('ownworker')).toBe(true)
1402
+ expect(reg.has('worktreeworker')).toBe(true)
1403
+ })
1404
+
1405
+ it('still skips a genuinely foreign project dir not returned by extraWatchCwdsProvider', () => {
1406
+ const agentDir = join(tmpRoot, 'agent')
1407
+ const agentCwd = '/home/test/agent-own'
1408
+ const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
1409
+ const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
1410
+ const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-C', 'subagents')
1411
+ mkdirSync(ownSubagents, { recursive: true })
1412
+ mkdirSync(foreignSubagents, { recursive: true })
1413
+ writeFileSync(
1414
+ join(ownSubagents, 'agent-ownworker.jsonl'),
1415
+ buildJSONL(subAgentUserMsg('legit work')),
1416
+ )
1417
+ writeFileSync(
1418
+ join(foreignSubagents, 'agent-foreignworker.jsonl'),
1419
+ buildJSONL(subAgentUserMsg('a sibling agent, not a registered worktree')),
1420
+ )
1421
+
1422
+ const h = startWatcherSync({
1423
+ agentDir,
1424
+ agentCwd,
1425
+ // Registers a worktree cwd that is NOT the foreign dir above —
1426
+ // proves extraWatchCwdsProvider adds only what it explicitly
1427
+ // names, never a wildcard.
1428
+ extraWatchCwdsProvider: () => [worktreeCwd],
1429
+ })
1430
+ h.poll()
1431
+
1432
+ const reg = h.watcher.getRegistry()
1433
+ expect(reg.has('ownworker')).toBe(true)
1434
+ expect(reg.has('foreignworker')).toBe(false)
1435
+ })
1436
+ })
1437
+
1299
1438
  describe('issue #1116 — terminal cleanup must not re-fire completion (Bug B)', () => {
1300
1439
  /**
1301
1440
  * Pre-#1116: after `TERMINAL_CLEANUP_GRACE_MS` (30s) elapsed,
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Ownership predicate for the worktree-isolated cwds the subagent-watcher
3
+ * additionally watches (deterministic-turn-liveness.md Known Gap 2 / #2893
4
+ * review fix). Pins the fail-CLOSED behaviour: the review found the closure
5
+ * both FAILED OPEN (unset identity matched every ownerless record — the #1116
6
+ * leak reintroduced) and FAILED CLOSED (a plain `worktree claim` produced an
7
+ * ownerless record filtered out, so the sub-agent stayed invisible — Gap 2).
8
+ *
9
+ * Run with:
10
+ * bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import {
14
+ ownedWorktreeCwds,
15
+ type WorktreeOwnershipRecord,
16
+ } from "../worktree-watch-cwds.js";
17
+
18
+ const idPath = (p: string) => p; // identity realpath for deterministic tests
19
+
20
+ describe("ownedWorktreeCwds", () => {
21
+ const records: WorktreeOwnershipRecord[] = [
22
+ { path: "/wt/mine-1", ownerAgent: "klanker" },
23
+ { path: "/wt/mine-2", ownerAgent: "klanker" },
24
+ { path: "/wt/theirs", ownerAgent: "reggie" },
25
+ { path: "/wt/ownerless" }, // ownerAgent undefined
26
+ ];
27
+
28
+ it("returns NOTHING when the agent identity is unset (fail-closed, no #1116 leak)", () => {
29
+ expect(
30
+ ownedWorktreeCwds({ self: undefined, listRecords: () => records, realpath: idPath }),
31
+ ).toEqual([]);
32
+ });
33
+
34
+ it("returns NOTHING when the agent identity is empty string", () => {
35
+ expect(
36
+ ownedWorktreeCwds({ self: "", listRecords: () => records, realpath: idPath }),
37
+ ).toEqual([]);
38
+ });
39
+
40
+ it("does NOT match ownerless records even when identity is set", () => {
41
+ const out = ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath });
42
+ expect(out).not.toContain("/wt/ownerless");
43
+ expect(out).not.toContain("/wt/theirs");
44
+ });
45
+
46
+ it("includes exactly the records this agent owns", () => {
47
+ expect(
48
+ ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath }),
49
+ ).toEqual(["/wt/mine-1", "/wt/mine-2"]);
50
+ });
51
+
52
+ it("returns [] when the registry read throws (best-effort; base watch undisturbed)", () => {
53
+ expect(
54
+ ownedWorktreeCwds({
55
+ self: "klanker",
56
+ listRecords: () => {
57
+ throw new Error("registry unavailable");
58
+ },
59
+ realpath: idPath,
60
+ }),
61
+ ).toEqual([]);
62
+ });
63
+
64
+ it("realpaths owned paths (symlinked-base slug fix), falling back on failure", () => {
65
+ const out = ownedWorktreeCwds({
66
+ self: "klanker",
67
+ listRecords: () => [
68
+ { path: "/tmp/mine", ownerAgent: "klanker" },
69
+ { path: "/gone/mine", ownerAgent: "klanker" },
70
+ ],
71
+ realpath: (p) => {
72
+ if (p === "/tmp/mine") return "/private/tmp/mine";
73
+ throw new Error("ENOENT");
74
+ },
75
+ });
76
+ expect(out).toEqual(["/private/tmp/mine", "/gone/mine"]);
77
+ });
78
+
79
+ it("two-tick mutation: a fresh claim/release is picked up without restart", () => {
80
+ // The provider is re-invoked every rescan tick, so a mutating registry
81
+ // (claim adds an owned record; release removes it) must be reflected
82
+ // tick-over-tick with no process restart.
83
+ let live: WorktreeOwnershipRecord[] = [{ path: "/wt/a", ownerAgent: "klanker" }];
84
+ const provider = () =>
85
+ ownedWorktreeCwds({ self: "klanker", listRecords: () => live, realpath: idPath });
86
+
87
+ // Tick 1: one owned worktree.
88
+ expect(provider()).toEqual(["/wt/a"]);
89
+
90
+ // Claim a second (as the ambient owner would default via SWITCHROOM_AGENT_NAME).
91
+ live = [
92
+ { path: "/wt/a", ownerAgent: "klanker" },
93
+ { path: "/wt/b", ownerAgent: "klanker" },
94
+ ];
95
+ // Tick 2: both picked up, no restart.
96
+ expect(provider()).toEqual(["/wt/a", "/wt/b"]);
97
+
98
+ // Release the first.
99
+ live = [{ path: "/wt/b", ownerAgent: "klanker" }];
100
+ // Tick 3: reflects the release.
101
+ expect(provider()).toEqual(["/wt/b"]);
102
+ });
103
+ });