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,192 @@
1
+ /**
2
+ * Deterministic-turn-liveness climb — CHANNEL twin of
3
+ * `jtbd-liveness-climb-dm.test.ts` (Phase 4a, `deterministic-turn-liveness.md`).
4
+ *
5
+ * Surface parity is a production-readiness bar for this job (job spec §
6
+ * Production-readiness: "every signal is proven in both DM and forum
7
+ * channel"). Phase 1's climb is keyed on the SAME `feedHeartbeatTick` /
8
+ * `runSilentTurnHeartbeatTick` path regardless of chat type — this scenario
9
+ * proves the card edits land, CLIMB, and stay ping-free in a supergroup too,
10
+ * not just a DM. It is brought to full parity with the DM twin: it parses the
11
+ * elapsed suffix, asserts non-decreasing climb, and HARD-FAILS on a frozen
12
+ * card (zero edits across a clearly-long silent tool) — the exact freeze this
13
+ * fix exists to detect. (Previously it only warned "INCONCLUSIVE" on a freeze,
14
+ * so it could never catch the regression it exists for.)
15
+ *
16
+ * Self-skips green when no test supergroup is wired (`SWITCHROOM_UAT_CHAT_ID`
17
+ * unset / unresolvable) — mirrors the existing channel-twin convention (see
18
+ * `jtbd-foreground-subagent-activity-channel.test.ts`).
19
+ *
20
+ * mtcute has no forum-topic API here, so this uses the General topic — same
21
+ * limitation as every other channel twin in this suite.
22
+ */
23
+
24
+ import { describe, expect, it } from "vitest";
25
+ import { spinUp } from "../harness.js";
26
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
27
+ import type { ObservedMessage } from "../driver.js";
28
+
29
+ const AGENT = "test-harness";
30
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
31
+
32
+ const MIN_CLIMB_EDITS = 4;
33
+ const OVERALL_BUDGET_MS = 160_000;
34
+ const SILENT_TOOL_SECONDS = 40;
35
+
36
+ const SILENT_TOOL_PROMPT =
37
+ "Do exactly this and nothing else: run a single Bash command " +
38
+ `\`sleep ${SILENT_TOOL_SECONDS}\` and wait for it to finish. Do NOT narrate ` +
39
+ "anything before or during the sleep — no commentary, no other tool calls, " +
40
+ "no intermediate messages. Only after the sleep completes, reply with a " +
41
+ "one-line confirmation that you waited.";
42
+
43
+ /** Parse the `Working · Ns` (or `· Nm Ss`) elapsed suffix out of a card body.
44
+ * Returns null when the shape isn't present. Mirrors the DM twin. */
45
+ function parseElapsedSeconds(text: string): number | null {
46
+ const m = text.match(/(?:^|\s)·\s*(?:(\d+)\s*m)?\s*(\d+)\s*s\b/i);
47
+ if (!m) return null;
48
+ const minutes = m[1] ? Number.parseInt(m[1], 10) : 0;
49
+ const seconds = Number.parseInt(m[2], 10);
50
+ return minutes * 60 + seconds;
51
+ }
52
+
53
+ describe("uat: deterministic turn liveness — silent-tool card climb (channel parity)", () => {
54
+ it(
55
+ "climbs the activity card in a supergroup ≥4 times with non-decreasing elapsed, no mid-turn ping",
56
+ async () => {
57
+ if (!Number.isFinite(SUPERGROUP_ID)) {
58
+ console.warn("[liveness-climb-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
59
+ return;
60
+ }
61
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
62
+ try {
63
+ await sc.driver.primeDialogs();
64
+ if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
65
+ console.warn(`[liveness-climb-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
66
+ return;
67
+ }
68
+
69
+ const iter = sc.driver
70
+ .observeMessages(SUPERGROUP_ID)
71
+ [Symbol.asyncIterator]();
72
+
73
+ await sc.driver.sendText(SUPERGROUP_ID, SILENT_TOOL_PROMPT);
74
+ const sentAt = Date.now();
75
+ console.log("[liveness-climb-channel] prompt sent; watching card edits…");
76
+
77
+ let cardMessageId: number | null = null;
78
+ const elapsedSamples: number[] = [];
79
+ let cardEditCount = 0;
80
+ let answer: ObservedMessage | null = null;
81
+ let otherLoudMessage: ObservedMessage | null = null;
82
+
83
+ const deadline = Date.now() + 110_000;
84
+ while (Date.now() < deadline) {
85
+ if (answer && cardEditCount >= MIN_CLIMB_EDITS) break;
86
+ const remaining = deadline - Date.now();
87
+ const next = await Promise.race([
88
+ iter.next(),
89
+ new Promise<{ done: true; value: undefined }>((r) =>
90
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
91
+ ),
92
+ ]);
93
+ if (next.done || next.value == null) break;
94
+ const m = next.value as ObservedMessage;
95
+ if (m.senderUserId === sc.driverUserId) continue;
96
+
97
+ if (isActivityFeedMessage(m)) {
98
+ if (cardMessageId == null) cardMessageId = m.messageId;
99
+ if (m.messageId === cardMessageId && m.edited) {
100
+ cardEditCount++;
101
+ const secs = parseElapsedSeconds(m.text);
102
+ if (secs != null) elapsedSamples.push(secs);
103
+ console.log(
104
+ `[liveness-climb-channel] card edit #${cardEditCount} at +${Date.now() - sentAt}ms: ` +
105
+ JSON.stringify(m.text.slice(0, 100)),
106
+ );
107
+ }
108
+ if (m.edited && m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
109
+ continue;
110
+ }
111
+
112
+ if (m.edited) continue;
113
+ // A framework fallback TEXT send is a mid-turn erosion, not the
114
+ // completion proof — reject it from the answer lane (see DM twin).
115
+ if (isFrameworkFallbackText(m.text)) {
116
+ otherLoudMessage = otherLoudMessage ?? m;
117
+ continue;
118
+ }
119
+ if (!answer && m.text.trim().length > 0) {
120
+ answer = m;
121
+ console.log(`[liveness-climb-channel] answer at +${Date.now() - sentAt}ms.`);
122
+ continue;
123
+ }
124
+ if (m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
125
+ }
126
+ await iter.return?.();
127
+
128
+ // No mid-turn erosion: the climb is edit-only in a supergroup too.
129
+ expect(
130
+ otherLoudMessage,
131
+ `a mid-turn message eroded the ping-free/edit-only guarantee in the ` +
132
+ `supergroup (loud ping or framework fallback text): ` +
133
+ JSON.stringify(otherLoudMessage?.text?.slice(0, 120)),
134
+ ).toBeNull();
135
+
136
+ if (cardEditCount === 0 && cardMessageId == null) {
137
+ console.warn(
138
+ "[liveness-climb-channel] INCONCLUSIVE — no activity card observed at all " +
139
+ "(the model may have narrated, or the turn completed too fast).",
140
+ );
141
+ expect(answer, "even in the inconclusive branch the turn must complete").not.toBeNull();
142
+ return;
143
+ }
144
+
145
+ if (cardEditCount > 0 && cardEditCount < MIN_CLIMB_EDITS) {
146
+ console.warn(
147
+ `[liveness-climb-channel] INCONCLUSIVE — card edited ${cardEditCount} time(s), ` +
148
+ `below the ${MIN_CLIMB_EDITS} target (model may have narrated a tool label ` +
149
+ "partway through, handing to the labelled-feed heartbeat).",
150
+ );
151
+ } else {
152
+ expect(
153
+ cardEditCount,
154
+ `HARD FAIL — the silent tool ran ~${SILENT_TOOL_SECONDS}s in a supergroup but the ` +
155
+ `activity card edited only ${cardEditCount} time(s) (< ${MIN_CLIMB_EDITS}). This is ` +
156
+ "the pre-Phase-1 freeze regression, in the channel surface.",
157
+ ).toBeGreaterThanOrEqual(MIN_CLIMB_EDITS);
158
+ }
159
+
160
+ // Climbing property: elapsed samples must be non-decreasing…
161
+ for (let i = 1; i < elapsedSamples.length; i++) {
162
+ expect(
163
+ elapsedSamples[i],
164
+ `elapsed suffix went backwards/froze across edits: ${JSON.stringify(elapsedSamples)}`,
165
+ ).toBeGreaterThanOrEqual(elapsedSamples[i - 1]);
166
+ }
167
+ // …and must actually climb across the full window (frozen == the freeze
168
+ // this fix closes; backs the job-spec Prove-it claim).
169
+ if (elapsedSamples.length >= MIN_CLIMB_EDITS) {
170
+ expect(
171
+ elapsedSamples[elapsedSamples.length - 1],
172
+ `HARD FAIL — the elapsed suffix never advanced across ${elapsedSamples.length} ` +
173
+ `card edits in the supergroup (frozen at ${JSON.stringify(elapsedSamples)}).`,
174
+ ).toBeGreaterThan(elapsedSamples[0]);
175
+ }
176
+
177
+ // Completion proof: an ANSWER (not the card itself) resumes in the same
178
+ // chat. Guarding on `!isActivityFeedMessage` stops the old
179
+ // `expectMessage(SUPERGROUP_ID, /\S/)` from matching a card edit and
180
+ // declaring the turn done while the agent was still climbing.
181
+ expect(
182
+ answer,
183
+ "FAIL — no answer (distinct from the activity card) arrived within budget; the turn may be wedged.",
184
+ ).not.toBeNull();
185
+ expect(answer?.chatId).toBe(SUPERGROUP_ID);
186
+ } finally {
187
+ await sc.tearDown();
188
+ }
189
+ },
190
+ OVERALL_BUDGET_MS,
191
+ );
192
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Deterministic-turn-liveness climb — DM (Phase 4a, `deterministic-turn-liveness.md`).
3
+ *
4
+ * The RFC's keystone (Phase 1) gives a busy-but-silent foreground turn a
5
+ * climbing activity card instead of the pre-#2143/#2527/#2667 freeze: once
6
+ * the card is open and no tool emits a label, `feedHeartbeatTick`'s 0-label
7
+ * branch re-renders the SAME message with a fresh `Working · Ns` elapsed
8
+ * every `FEED_HEARTBEAT_MIN_STALE_MS` (~6s), edit-only (no device buzz).
9
+ *
10
+ * IMPORTANT product fact this scenario respects: switchroom has NO streaming
11
+ * REPLY path — the final answer is sent whole (chunked over the char cap if
12
+ * needed). The card observed here is the activity/liveness card — a distinct,
13
+ * pre-existing card-edit surface (worker-activity-feed / liveness card), not
14
+ * a streamed reply. This scenario never asserts a streamed reply edit.
15
+ *
16
+ * ## Trigger
17
+ *
18
+ * A single `Bash` call that sleeps ~40s with no intermediate narration and no
19
+ * tool label — the exact "silent tool" shape the Phase-1 climb exists for.
20
+ *
21
+ * ## What it asserts (asymmetric — timing on a live model is not fully
22
+ * forceable)
23
+ *
24
+ * PASS — the SAME activity-card message is edited (non-initial
25
+ * renders, i.e. `edited === true` on repeat observations of the
26
+ * same `messageId`) at least `MIN_CLIMB_EDITS` (4) times across
27
+ * the ~40s window, and the elapsed suffix parsed out of the
28
+ * body is monotonically non-decreasing across those edits
29
+ * (the "climbing" property — hard-fails on a frozen/repeated
30
+ * elapsed value across all edits, which reproduces the pre-
31
+ * Phase-1 freeze).
32
+ * PASS — no bot message OTHER than the one activity-card message id
33
+ * appears during the silent window (no mid-turn notification
34
+ * ping; the climb is edit-only, per Phase 1 + the job spec's
35
+ * "no device buzz" bad-list item).
36
+ * INCONCLUSIVE — the model narrated anyway (a tool label / narrative text
37
+ * landed), so the 0-label silent-tool path was not exercised.
38
+ * Warn + pass — not a regression of this fix.
39
+ * FAIL — no answer at all within budget (wedged), or a mid-turn ping
40
+ * is observed (silent === false on a non-final message).
41
+ * HARD FAIL — the turn ran clearly longer than the climb bound with fewer
42
+ * than `MIN_CLIMB_EDITS` edits on the card and no narration —
43
+ * the exact freeze this fix exists to close.
44
+ */
45
+
46
+ import { describe, expect, it } from "vitest";
47
+ import { spinUp } from "../harness.js";
48
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
49
+ import type { ObservedMessage } from "../driver.js";
50
+
51
+ const MIN_CLIMB_EDITS = 4;
52
+ const OVERALL_BUDGET_MS = 140_000;
53
+ const SILENT_TOOL_SECONDS = 40;
54
+
55
+ const SILENT_TOOL_PROMPT =
56
+ "Do exactly this and nothing else: run a single Bash command " +
57
+ `\`sleep ${SILENT_TOOL_SECONDS}\` and wait for it to finish. Do NOT narrate ` +
58
+ "anything before or during the sleep — no commentary, no other tool calls, " +
59
+ "no intermediate messages. Only after the sleep completes, reply with a " +
60
+ "one-line confirmation that you waited.";
61
+
62
+ /** Parse the `Working · Ns` (or `· Nm Ss`) elapsed suffix out of a card body.
63
+ * Returns null when the shape isn't present (e.g. a labelled feed). */
64
+ function parseElapsedSeconds(text: string): number | null {
65
+ const m = text.match(/(?:^|\s)·\s*(?:(\d+)\s*m)?\s*(\d+)\s*s\b/i);
66
+ if (!m) return null;
67
+ const minutes = m[1] ? Number.parseInt(m[1], 10) : 0;
68
+ const seconds = Number.parseInt(m[2], 10);
69
+ return minutes * 60 + seconds;
70
+ }
71
+
72
+ describe("uat: deterministic turn liveness — silent-tool card climb (DM)", () => {
73
+ it(
74
+ "climbs the SAME activity card ≥4 times with non-decreasing elapsed, no mid-turn ping",
75
+ async () => {
76
+ const sc = await spinUp({ agent: "test-harness" });
77
+ try {
78
+ const iter = sc.driver
79
+ .observeMessages(sc.botUserId)
80
+ [Symbol.asyncIterator]();
81
+
82
+ await sc.sendDM(SILENT_TOOL_PROMPT);
83
+ const sentAt = Date.now();
84
+ console.log("[liveness-climb-dm] prompt sent; watching card edits…");
85
+
86
+ let cardMessageId: number | null = null;
87
+ const elapsedSamples: number[] = [];
88
+ let cardEditCount = 0;
89
+ let answer: ObservedMessage | null = null;
90
+ let otherLoudMessage: ObservedMessage | null = null;
91
+
92
+ const deadline = Date.now() + 100_000;
93
+ while (Date.now() < deadline) {
94
+ if (answer && cardEditCount >= MIN_CLIMB_EDITS) break;
95
+ const remaining = deadline - Date.now();
96
+ const next = await Promise.race([
97
+ iter.next(),
98
+ new Promise<{ done: true; value: undefined }>((r) =>
99
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
100
+ ),
101
+ ]);
102
+ if (next.done || next.value == null) break;
103
+ const m = next.value as ObservedMessage;
104
+ if (m.senderUserId === sc.driverUserId) continue;
105
+
106
+ if (isActivityFeedMessage(m)) {
107
+ if (cardMessageId == null) cardMessageId = m.messageId;
108
+ if (m.messageId === cardMessageId && m.edited) {
109
+ cardEditCount++;
110
+ const secs = parseElapsedSeconds(m.text);
111
+ if (secs != null) elapsedSamples.push(secs);
112
+ console.log(
113
+ `[liveness-climb-dm] card edit #${cardEditCount} at +${Date.now() - sentAt}ms: ` +
114
+ JSON.stringify(m.text.slice(0, 100)),
115
+ );
116
+ }
117
+ // A non-silent card edit would still be a ping; card edits are
118
+ // asserted separately below via `silent`.
119
+ if (m.edited && m.silent === false) {
120
+ otherLoudMessage = otherLoudMessage ?? m;
121
+ }
122
+ continue;
123
+ }
124
+
125
+ if (m.edited) continue;
126
+ // Attributable-answer gate: a framework mid-turn/dark-turn fallback
127
+ // TEXT send (the exact #2667 erosion this wall guards against — a
128
+ // re-added "still working…" ping) must NOT be swallowed as the
129
+ // turn's answer. Classify it as a mid-turn erosion regardless of its
130
+ // silent flag, so the first loud mid-turn message trips the
131
+ // assertion instead of masquerading as the reply.
132
+ if (isFrameworkFallbackText(m.text)) {
133
+ otherLoudMessage = otherLoudMessage ?? m;
134
+ console.log(
135
+ `[liveness-climb-dm] framework fallback text mid-turn at +${Date.now() - sentAt}ms: ` +
136
+ JSON.stringify(m.text.slice(0, 120)),
137
+ );
138
+ continue;
139
+ }
140
+ if (!answer && m.text.trim().length > 0) {
141
+ answer = m;
142
+ console.log(`[liveness-climb-dm] answer at +${Date.now() - sentAt}ms.`);
143
+ continue;
144
+ }
145
+ // Any OTHER non-edit bot message during the window is a candidate
146
+ // mid-turn ping — track it for the loud-message assertion below.
147
+ if (m.silent === false) {
148
+ otherLoudMessage = otherLoudMessage ?? m;
149
+ }
150
+ }
151
+ await iter.return?.();
152
+
153
+ // No mid-turn erosion: neither a device-buzzing ping (silent=false on
154
+ // a non-final message) NOR a framework fallback TEXT send (a
155
+ // "still working…"-class mid-turn message — the climb is an edit-only
156
+ // card, never a text send).
157
+ expect(
158
+ otherLoudMessage,
159
+ `a mid-turn message eroded the ping-free/edit-only guarantee ` +
160
+ `(loud ping or framework fallback text): ` +
161
+ JSON.stringify(otherLoudMessage?.text?.slice(0, 120)),
162
+ ).toBeNull();
163
+
164
+ if (cardEditCount === 0 && cardMessageId == null) {
165
+ console.warn(
166
+ "[liveness-climb-dm] INCONCLUSIVE — no activity card observed at all " +
167
+ "(the model may have narrated before/instead of the silent sleep, " +
168
+ "or the turn completed too fast). Not a regression of this fix.",
169
+ );
170
+ expect(answer, "even in the inconclusive branch the turn must complete").not.toBeNull();
171
+ return;
172
+ }
173
+
174
+ if (cardEditCount > 0 && cardEditCount < MIN_CLIMB_EDITS) {
175
+ console.warn(
176
+ `[liveness-climb-dm] INCONCLUSIVE — card edited ${cardEditCount} time(s), ` +
177
+ `below the ${MIN_CLIMB_EDITS} target (model may have narrated a tool ` +
178
+ "label partway through, handing off to the labelled-feed heartbeat " +
179
+ "with a different cadence). Not a hard failure.",
180
+ );
181
+ } else {
182
+ expect(
183
+ cardEditCount,
184
+ `HARD FAIL — the silent tool ran ~${SILENT_TOOL_SECONDS}s but the activity ` +
185
+ `card edited only ${cardEditCount} time(s) (< ${MIN_CLIMB_EDITS}). This is ` +
186
+ "the pre-Phase-1 freeze regression (#2143/#2527/#2667 archaeology in " +
187
+ "reference/rfcs/deterministic-turn-liveness.md).",
188
+ ).toBeGreaterThanOrEqual(MIN_CLIMB_EDITS);
189
+ }
190
+
191
+ // Climbing property: elapsed samples must be non-decreasing (never
192
+ // rewinds across edits of the same card)…
193
+ for (let i = 1; i < elapsedSamples.length; i++) {
194
+ expect(
195
+ elapsedSamples[i],
196
+ `elapsed suffix went backwards/froze across edits: ${JSON.stringify(elapsedSamples)}`,
197
+ ).toBeGreaterThanOrEqual(elapsedSamples[i - 1]);
198
+ }
199
+ // …and, when we have the full climb (≥MIN_CLIMB_EDITS parsed samples),
200
+ // it must actually CLIMB — a frozen/repeated elapsed value across all
201
+ // edits is the exact pre-Phase-1 freeze and a hard failure (this backs
202
+ // the job-spec Prove-it claim; non-decreasing alone would pass a
203
+ // frozen card).
204
+ if (elapsedSamples.length >= MIN_CLIMB_EDITS) {
205
+ expect(
206
+ elapsedSamples[elapsedSamples.length - 1],
207
+ `HARD FAIL — the elapsed suffix never advanced across ${elapsedSamples.length} ` +
208
+ `card edits (frozen at ${JSON.stringify(elapsedSamples)}). A repeated elapsed ` +
209
+ "value across all edits is the pre-Phase-1 freeze this fix closes.",
210
+ ).toBeGreaterThan(elapsedSamples[0]);
211
+ }
212
+
213
+ expect(answer, "FAIL — no answer arrived within budget; the turn may be wedged.").not.toBeNull();
214
+ } finally {
215
+ await sc.tearDown();
216
+ }
217
+ },
218
+ OVERALL_BUDGET_MS,
219
+ );
220
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Narrated mid-turn work still shows words on the card — CHANNEL twin of
3
+ * `jtbd-liveness-narration-dm.test.ts` (Phase 4a, `deterministic-turn-liveness.md`).
4
+ *
5
+ * Surface parity bar per the job spec. Like the DM twin (and unlike the
6
+ * previous version of this file), it also asserts the no-mid-turn-ping
7
+ * invariant inline — the RFC and job spec claim the ping-free assertion is
8
+ * baked into EVERY liveness scenario, DM and channel alike. Self-skips green
9
+ * when no test supergroup is wired.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import { spinUp } from "../harness.js";
14
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
15
+ import type { ObservedMessage } from "../driver.js";
16
+
17
+ const AGENT = "test-harness";
18
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
19
+ const OVERALL_BUDGET_MS = 150_000;
20
+
21
+ const NARRATED_WORK_PROMPT =
22
+ "Do four short steps, ONE AT A TIME. Before EACH step, write a brief " +
23
+ 'one-sentence narration of what you are about to do (e.g. "Checking the ' +
24
+ 'hostname now.") — this is not a reply, just your normal narration — then ' +
25
+ "run the step via Bash (`sleep 3` is fine as the step body). After all " +
26
+ "four steps, send a one-line final summary reply.";
27
+
28
+ /** Body (narration/tool) lines of a card, minus header + climb placeholder.
29
+ * Mirrors the DM twin's `narratedBodyLines`. */
30
+ function narratedBodyLines(text: string): string[] {
31
+ return text
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter((l) => l.length > 0)
35
+ .map((l) => l.replace(/^[→✓↳]+\s*/u, "").trim())
36
+ .filter(
37
+ (l) =>
38
+ l.length > 0 &&
39
+ !/^(?:🤖|🛠[️]?|⚙[️]?)\s/u.test(l) &&
40
+ !/^(?:(?:\d+m)?\d+s\s*·\s*\d+\s+tools?|(?:done|failed)\s*·)/iu.test(l) &&
41
+ !/^Working(?:\s*[·…]|$)/i.test(l) &&
42
+ !/^\+?\d+\s+earlier/i.test(l),
43
+ );
44
+ }
45
+
46
+ describe("uat: deterministic turn liveness — narration lands mid-turn (channel parity)", () => {
47
+ it(
48
+ "the model's own words appear on the activity card mid-turn in a supergroup, with no mid-turn ping",
49
+ async () => {
50
+ if (!Number.isFinite(SUPERGROUP_ID)) {
51
+ console.warn("[liveness-narration-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
52
+ return;
53
+ }
54
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
55
+ try {
56
+ await sc.driver.primeDialogs();
57
+ if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
58
+ console.warn(`[liveness-narration-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
59
+ return;
60
+ }
61
+
62
+ const iter = sc.driver
63
+ .observeMessages(SUPERGROUP_ID)
64
+ [Symbol.asyncIterator]();
65
+
66
+ await sc.driver.sendText(SUPERGROUP_ID, NARRATED_WORK_PROMPT);
67
+ const sentAt = Date.now();
68
+
69
+ const narrationSamples: string[] = [];
70
+ let answer: ObservedMessage | null = null;
71
+ let otherLoudMessage: ObservedMessage | null = null;
72
+
73
+ const deadline = Date.now() + 100_000;
74
+ while (Date.now() < deadline) {
75
+ if (answer) break;
76
+ const remaining = deadline - Date.now();
77
+ const next = await Promise.race([
78
+ iter.next(),
79
+ new Promise<{ done: true; value: undefined }>((r) =>
80
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
81
+ ),
82
+ ]);
83
+ if (next.done || next.value == null) break;
84
+ const m = next.value as ObservedMessage;
85
+ if (m.senderUserId === sc.driverUserId) continue;
86
+
87
+ if (isActivityFeedMessage(m)) {
88
+ const narrated = narratedBodyLines(m.text);
89
+ if (narrated.length > 0) {
90
+ narrationSamples.push(narrated.join(" | "));
91
+ console.log(
92
+ `[liveness-narration-channel] narrated card content at +${Date.now() - sentAt}ms: ` +
93
+ JSON.stringify(narrated.join(" | ").slice(0, 140)),
94
+ );
95
+ }
96
+ if (m.edited && m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
97
+ continue;
98
+ }
99
+ if (m.edited) continue;
100
+ if (isFrameworkFallbackText(m.text)) {
101
+ otherLoudMessage = otherLoudMessage ?? m;
102
+ continue;
103
+ }
104
+ if (!answer && m.text.trim().length > 0) {
105
+ answer = m;
106
+ continue;
107
+ }
108
+ if (m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
109
+ }
110
+ await iter.return?.();
111
+
112
+ // No-mid-turn-ping invariant, inline in the channel twin too.
113
+ expect(
114
+ otherLoudMessage,
115
+ `a mid-turn message eroded the ping-free/edit-only guarantee in the supergroup: ` +
116
+ JSON.stringify(otherLoudMessage?.text?.slice(0, 120)),
117
+ ).toBeNull();
118
+
119
+ if (narrationSamples.length === 0) {
120
+ console.warn(
121
+ "[liveness-narration-channel] INCONCLUSIVE — no narrated content observed on the " +
122
+ "card (model may have batched steps or the turn was too fast).",
123
+ );
124
+ }
125
+
126
+ expect(
127
+ answer,
128
+ "FAIL — no answer (distinct from the activity card) arrived within budget; the turn may be wedged.",
129
+ ).not.toBeNull();
130
+ expect(answer?.chatId).toBe(SUPERGROUP_ID);
131
+ } finally {
132
+ await sc.tearDown();
133
+ }
134
+ },
135
+ OVERALL_BUDGET_MS,
136
+ );
137
+ });