switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,198 @@
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, beforeEach } from "vitest";
13
+ import {
14
+ ownedWorktreeCwds,
15
+ __resetIdentityEscalationForTests,
16
+ type WorktreeOwnershipRecord,
17
+ } from "../worktree-watch-cwds.js";
18
+
19
+ const idPath = (p: string) => p; // identity realpath for deterministic tests
20
+
21
+ beforeEach(() => __resetIdentityEscalationForTests());
22
+
23
+ describe("ownedWorktreeCwds", () => {
24
+ const records: WorktreeOwnershipRecord[] = [
25
+ { path: "/wt/mine-1", ownerAgent: "klanker" },
26
+ { path: "/wt/mine-2", ownerAgent: "klanker" },
27
+ { path: "/wt/theirs", ownerAgent: "reggie" },
28
+ { path: "/wt/ownerless" }, // ownerAgent undefined
29
+ ];
30
+
31
+ it("returns NOTHING when the agent identity is unset and no durable fallback given (fail-closed, no #1116 leak)", () => {
32
+ expect(
33
+ ownedWorktreeCwds({ self: undefined, listRecords: () => records, realpath: idPath }),
34
+ ).toEqual([]);
35
+ });
36
+
37
+ it("returns NOTHING when the agent identity is empty string and no durable fallback given", () => {
38
+ expect(
39
+ ownedWorktreeCwds({ self: "", listRecords: () => records, realpath: idPath }),
40
+ ).toEqual([]);
41
+ });
42
+
43
+ // ---- Layer 2: durable, non-env identity fallback (#1116 / #2893) ----
44
+
45
+ it("(a) env SET → ownership resolves exactly as before (fast path unchanged)", () => {
46
+ expect(
47
+ ownedWorktreeCwds({
48
+ self: "klanker",
49
+ agentDir: "/home/x/.switchroom/agents/SHOULD_BE_IGNORED",
50
+ listRecords: () => records,
51
+ realpath: idPath,
52
+ }),
53
+ ).toEqual(["/wt/mine-1", "/wt/mine-2"]);
54
+ });
55
+
56
+ it("(b) env UNSET but agentDir present → identity derived from dir basename, ownership resolves", () => {
57
+ const out = ownedWorktreeCwds({
58
+ self: undefined,
59
+ agentDir: "/home/x/.switchroom/agents/klanker",
60
+ listRecords: () => records,
61
+ realpath: idPath,
62
+ });
63
+ expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
64
+ });
65
+
66
+ it("(b) env EMPTY but agentDir present → same durable derivation", () => {
67
+ const out = ownedWorktreeCwds({
68
+ self: "",
69
+ agentDir: "/home/x/.switchroom/agents/klanker",
70
+ listRecords: () => records,
71
+ realpath: idPath,
72
+ });
73
+ expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
74
+ });
75
+
76
+ it("(b) durable fallback NEVER mis-attributes: a dir for a different agent matches only THAT agent's records, never klanker's", () => {
77
+ // agentDir names 'reggie' → resolves reggie's worktrees, never klanker's
78
+ // or the ownerless ones. A wrong basename fails-closed to [], it can never
79
+ // attribute to the WRONG owner.
80
+ const out = ownedWorktreeCwds({
81
+ self: undefined,
82
+ agentDir: "/home/x/.switchroom/agents/reggie",
83
+ listRecords: () => records,
84
+ realpath: idPath,
85
+ });
86
+ expect(out).toEqual(["/wt/theirs"]);
87
+ expect(out).not.toContain("/wt/ownerless");
88
+ });
89
+
90
+ it("(c) BOTH env and agentDir unavailable → returns [] AND emits an escalated ERROR (no throw, no mis-attribution)", () => {
91
+ const logs: string[] = [];
92
+ const out = ownedWorktreeCwds({
93
+ self: undefined,
94
+ agentDir: undefined,
95
+ listRecords: () => records,
96
+ realpath: idPath,
97
+ log: (m) => logs.push(m),
98
+ });
99
+ expect(out).toEqual([]);
100
+ expect(logs).toHaveLength(1);
101
+ expect(logs[0]).toContain("ERROR");
102
+ expect(logs[0]).toContain("identity resolution FAILED");
103
+ });
104
+
105
+ it("(c) blank agentDir (whitespace) is treated as unavailable → [] + escalated error", () => {
106
+ const logs: string[] = [];
107
+ const out = ownedWorktreeCwds({
108
+ self: "",
109
+ agentDir: " ",
110
+ listRecords: () => records,
111
+ realpath: idPath,
112
+ log: (m) => logs.push(m),
113
+ });
114
+ expect(out).toEqual([]);
115
+ expect(logs).toHaveLength(1);
116
+ expect(logs[0]).toContain("identity resolution FAILED");
117
+ });
118
+
119
+ it("(c) the escalated error is one-shot per process (not re-logged every rescan tick)", () => {
120
+ const logs: string[] = [];
121
+ const call = () =>
122
+ ownedWorktreeCwds({
123
+ self: undefined,
124
+ agentDir: undefined,
125
+ listRecords: () => records,
126
+ realpath: idPath,
127
+ log: (m) => logs.push(m),
128
+ });
129
+ call();
130
+ call();
131
+ call();
132
+ expect(logs).toHaveLength(1);
133
+ });
134
+
135
+ it("does NOT match ownerless records even when identity is set", () => {
136
+ const out = ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath });
137
+ expect(out).not.toContain("/wt/ownerless");
138
+ expect(out).not.toContain("/wt/theirs");
139
+ });
140
+
141
+ it("includes exactly the records this agent owns", () => {
142
+ expect(
143
+ ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath }),
144
+ ).toEqual(["/wt/mine-1", "/wt/mine-2"]);
145
+ });
146
+
147
+ it("returns [] when the registry read throws (best-effort; base watch undisturbed)", () => {
148
+ expect(
149
+ ownedWorktreeCwds({
150
+ self: "klanker",
151
+ listRecords: () => {
152
+ throw new Error("registry unavailable");
153
+ },
154
+ realpath: idPath,
155
+ }),
156
+ ).toEqual([]);
157
+ });
158
+
159
+ it("realpaths owned paths (symlinked-base slug fix), falling back on failure", () => {
160
+ const out = ownedWorktreeCwds({
161
+ self: "klanker",
162
+ listRecords: () => [
163
+ { path: "/tmp/mine", ownerAgent: "klanker" },
164
+ { path: "/gone/mine", ownerAgent: "klanker" },
165
+ ],
166
+ realpath: (p) => {
167
+ if (p === "/tmp/mine") return "/private/tmp/mine";
168
+ throw new Error("ENOENT");
169
+ },
170
+ });
171
+ expect(out).toEqual(["/private/tmp/mine", "/gone/mine"]);
172
+ });
173
+
174
+ it("two-tick mutation: a fresh claim/release is picked up without restart", () => {
175
+ // The provider is re-invoked every rescan tick, so a mutating registry
176
+ // (claim adds an owned record; release removes it) must be reflected
177
+ // tick-over-tick with no process restart.
178
+ let live: WorktreeOwnershipRecord[] = [{ path: "/wt/a", ownerAgent: "klanker" }];
179
+ const provider = () =>
180
+ ownedWorktreeCwds({ self: "klanker", listRecords: () => live, realpath: idPath });
181
+
182
+ // Tick 1: one owned worktree.
183
+ expect(provider()).toEqual(["/wt/a"]);
184
+
185
+ // Claim a second (as the ambient owner would default via SWITCHROOM_AGENT_NAME).
186
+ live = [
187
+ { path: "/wt/a", ownerAgent: "klanker" },
188
+ { path: "/wt/b", ownerAgent: "klanker" },
189
+ ];
190
+ // Tick 2: both picked up, no restart.
191
+ expect(provider()).toEqual(["/wt/a", "/wt/b"]);
192
+
193
+ // Release the first.
194
+ live = [{ path: "/wt/b", ownerAgent: "klanker" }];
195
+ // Tick 3: reflects the release.
196
+ expect(provider()).toEqual(["/wt/b"]);
197
+ });
198
+ });
@@ -227,13 +227,47 @@ export interface PostAnswerLivenessInput {
227
227
  now: number
228
228
  /** Staleness cap in ms; `<= 0` disables the cap. */
229
229
  staleCapMs: number
230
+ /**
231
+ * A POSITIVE, independent signal that a sub-agent dispatch is still known to
232
+ * be outstanding (e.g. `turn.foregroundSubAgents.size > 0` — a foreground
233
+ * `Task`/`Agent` this turn dispatched and has not yet reported finished).
234
+ *
235
+ * ## The gap this closes
236
+ *
237
+ * The staleness cap above was built to stop the card climbing FOREVER once
238
+ * a worker's `onFinish` froze `subagentActivityAt` and nothing further would
239
+ * ever arrive — but the ONLY signal it read (`now - subagentActivityAt`) is
240
+ * identical whether the worker (a) actually finished, or (b) is still
241
+ * genuinely running a SINGLE long silent step (one long Bash call, a slow
242
+ * fetch) that simply hasn't produced a NEW distinguishable watcher tick.
243
+ * Case (b) is exactly the scenario `feed-heartbeat-climb.ts`'s 0-label climb
244
+ * and `worker-activity-feed.ts`'s own heartbeat both exist to handle
245
+ * deterministically elsewhere — this post-answer branch alone lacked that
246
+ * fallback, so it froze the card mid-delegation (the confirmed operator
247
+ * symptom: "Running a command" stuck for 41s / 3m12s while a sub-agent ran
248
+ * underneath).
249
+ *
250
+ * When `stillDispatched` is `true` we have POSITIVE evidence the worker has
251
+ * not reported completion, so the staleness cap is bypassed entirely and the
252
+ * verdict stays `'emit'` — the caller keeps climbing the card deterministically
253
+ * off wall-clock elapsed exactly like the sibling 0-label/worker-feed paths.
254
+ * When `false` (no such tracking available — e.g. a purely-background worker
255
+ * with no foreground registration), the ORIGINAL cap behaviour is preserved
256
+ * unchanged, so the runaway-climb-after-completion protection this cap was
257
+ * built for still applies wherever we have no better signal.
258
+ */
259
+ stillDispatched: boolean
230
260
  }
231
261
 
232
262
  export function evaluatePostAnswerLiveness(input: PostAnswerLivenessInput): PostAnswerLivenessVerdict {
233
- const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs } = input
263
+ const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs, stillDispatched } = input
234
264
  const answeredAt = finalAnswerDeliveredAt ?? 0
235
265
  // idle-gap: nothing surfaced after the answer → silent (reply-is-last preserved).
236
266
  if (subagentActivityAt == null || subagentActivityAt <= answeredAt) return 'idle'
267
+ // A positive "still dispatched" signal overrides the staleness cap — we KNOW
268
+ // the worker hasn't reported done, so a quiet stretch is a long silent step,
269
+ // not completion. Never freeze the card while that's true.
270
+ if (stillDispatched) return 'emit'
237
271
  // staleness cap: the worker's last advance is older than the cap → stop emitting.
238
272
  if (staleCapMs > 0 && now - subagentActivityAt >= staleCapMs) return 'stale'
239
273
  return 'emit'
@@ -46,16 +46,80 @@ export function isWorkerFeedMessage(m: ObservedMessage): boolean {
46
46
  */
47
47
  const ACTIVITY_FEED_LINE_RE = /^[→✓]\s/u;
48
48
 
49
+ /**
50
+ * A body line of the activity card: an in-progress `→`, a done `✓`, a nested
51
+ * child `↳`, or the rolling `✓ +N earlier…` / `↳ +N earlier…` overflow
52
+ * headers (both covered by the leading glyph). Distinct from
53
+ * {@link ACTIVITY_FEED_LINE_RE} in that it does NOT require the trailing space
54
+ * — `↳→ …` nested-in-progress lines render glyph-adjacent.
55
+ */
56
+ const ACTIVITY_BODY_LINE_RE = /^[→✓↳]/u;
57
+
58
+ /**
59
+ * The two-line header `renderActivityHeader`
60
+ * (telegram-plugin/tool-activity-summary.ts) prepends to the session
61
+ * activity / liveness card — the shape the pure-arrow
62
+ * {@link ACTIVITY_FEED_LINE_RE} predicate could never match, which is why the
63
+ * Phase-1 climb card (`silentTurnClimbRender`) was mis-classified as the
64
+ * answer and the whole climb test wall passed vacuously
65
+ * (`deterministic-turn-liveness.md` Phase 4a). Telegram strips the bold/italic
66
+ * entities, so the OBSERVED lines are:
67
+ *
68
+ * line 1: `<emoji> <label>` e.g. `🤖 Agent` (optionally ` · <description>`)
69
+ * line 2 running: `<elapsed> · <N> tool(s)` e.g. `12s · 0 tools`, `2m05s · 3 tools`
70
+ * line 2 done: `<state> · <N> tools · <elapsed>` e.g. `done · 3 tools · 41s`
71
+ *
72
+ * Elapsed is `formatFeedElapsed`: `<N>s` under a minute, else `<M>m<SS>s`.
73
+ */
74
+ const LIVENESS_HEADER_L1_RE = /^(?:🤖|🛠[️]?|⚙[️]?)\s+\S/u;
75
+ const LIVENESS_ELAPSED = String.raw`(?:\d+m)?\d+s`;
76
+ const LIVENESS_HEADER_L2_RE = new RegExp(
77
+ `^(?:${LIVENESS_ELAPSED}\\s*·\\s*\\d+\\s+tools?` +
78
+ `|(?:done|failed)\\s*·\\s*\\d+\\s+tools?\\s*·\\s*${LIVENESS_ELAPSED})$`,
79
+ "iu",
80
+ );
81
+
82
+ /**
83
+ * True when `m` is the session activity / liveness card that carries the
84
+ * two-line `renderActivityHeader` (emoji + label, then the climbing
85
+ * `<elapsed> · <N> tools` status), followed only by `→`/`✓`/`↳` body lines.
86
+ *
87
+ * This is the card the Phase-1 climb (`feed-heartbeat-climb.ts`) and every
88
+ * headered activity feed render. The predicate stays strict — it requires
89
+ * BOTH header lines to match their exact shape — so a real reply that merely
90
+ * opens with an emoji or contains an arrow is never misclassified (the same
91
+ * documented reason the pure-arrow predicate demands every line be an activity
92
+ * line).
93
+ */
94
+ export function isLivenessCardMessage(m: ObservedMessage): boolean {
95
+ const lines = m.text
96
+ .split("\n")
97
+ .map((l) => l.trim())
98
+ .filter((l) => l.length > 0);
99
+ if (lines.length < 2) return false;
100
+ if (!LIVENESS_HEADER_L1_RE.test(lines[0])) return false;
101
+ if (!LIVENESS_HEADER_L2_RE.test(lines[1])) return false;
102
+ // Any remaining line must be an activity body line — the moment prose
103
+ // appears below the header, this is no longer the card (guards against a
104
+ // reply that happens to lead with a header-shaped emoji).
105
+ return lines.slice(2).every((l) => ACTIVITY_BODY_LINE_RE.test(l));
106
+ }
107
+
49
108
  /**
50
109
  * True when `m` is the live tool-activity feed (the one-message list of
51
110
  * "what the agent is doing this turn") rather than the agent's reply. A
52
- * message qualifies only when EVERY non-empty line is an activity line
53
- * so a real reply that merely contains an arrow is never misclassified.
111
+ * message qualifies when EITHER every non-empty line is a pure activity line
112
+ * (`→`/`✓`, the header-less feed) OR it carries the two-line liveness header
113
+ * (see {@link isLivenessCardMessage}) — so a real reply that merely contains
114
+ * an arrow is never misclassified.
54
115
  *
55
116
  * Recall/reply scenarios must skip this in addition to
56
117
  * {@link isWorkerFeedMessage}: on a turn that uses tools, the feed paints
57
118
  * `→ Finding the right tool` as its own bot message before the real answer
58
- * lands, and an `expectMessage(/\S/)` would otherwise latch onto it.
119
+ * lands, and an `expectMessage(/\S/)` would otherwise latch onto it. Before
120
+ * this predicate learned the header shape, the Phase-1 climb card (which
121
+ * ALWAYS carries the header) slipped through as an "answer" and silently
122
+ * broke the entire liveness-climb test wall.
59
123
  */
60
124
  export function isActivityFeedMessage(m: ObservedMessage): boolean {
61
125
  const lines = m.text
@@ -63,7 +127,8 @@ export function isActivityFeedMessage(m: ObservedMessage): boolean {
63
127
  .map((l) => l.trim())
64
128
  .filter((l) => l.length > 0);
65
129
  if (lines.length === 0) return false;
66
- return lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l));
130
+ if (lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l))) return true;
131
+ return isLivenessCardMessage(m);
67
132
  }
68
133
 
69
134
  /**
@@ -83,6 +148,25 @@ export function isAnswer(m: ObservedMessage, driverUserId: number): boolean {
83
148
  );
84
149
  }
85
150
 
151
+ /**
152
+ * Wording of the framework's own mid-turn / dark-turn fallback sends — the
153
+ * exact erosion class the liveness test wall exists to catch. A re-added
154
+ * cadence "still working…" text ping (the #2667 shape the RFC bans) or a
155
+ * dark-turn fallback would carry one of these phrases. Scenarios use this to
156
+ * REJECT such a message from the "answer" lane: without it, the first loud
157
+ * mid-turn framework send is swallowed as the turn's answer and the ping-free
158
+ * guarantee passes vacuously. Keep in sync with `SILENT_END_FALLBACK_TEXT`
159
+ * (gateway.ts) and `formatFrameworkFallbackText` (silence-poke.ts).
160
+ */
161
+ export const FRAMEWORK_FALLBACK_RE =
162
+ /still working|no update from agent|didn't send a reply|finished working but|waiting for your approval/i;
163
+
164
+ /** True when `text` reads like a framework mid-turn/dark-turn fallback send
165
+ * (see {@link FRAMEWORK_FALLBACK_RE}) rather than a model-authored answer. */
166
+ export function isFrameworkFallbackText(text: string): boolean {
167
+ return FRAMEWORK_FALLBACK_RE.test(text);
168
+ }
169
+
86
170
  export interface ReplyIsLastOptions {
87
171
  /**
88
172
  * The answer message that must be last in its foreground turn. The turn is
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  isActivityFeedMessage,
4
+ isFrameworkFallbackText,
5
+ isLivenessCardMessage,
4
6
  isWorkerFeedMessage,
5
7
  WORKER_FEED_RE,
6
8
  } from "./assertions.js";
@@ -83,4 +85,71 @@ describe("isActivityFeedMessage", () => {
83
85
  it("does NOT match an empty message", () => {
84
86
  expect(isActivityFeedMessage(feed(" "))).toBe(false);
85
87
  });
88
+
89
+ // The regression that silently broke the whole liveness-climb test wall
90
+ // (deterministic-turn-liveness.md Phase 4a): the climb card carries the
91
+ // two-line `renderActivityHeader`, which the pure-arrow predicate could
92
+ // never match — so it was classified as the answer and every climb test
93
+ // exited vacuously. isActivityFeedMessage must now recognise the header.
94
+ it("matches the Phase-1 climb card (two-line header + Working… body)", () => {
95
+ expect(
96
+ isActivityFeedMessage(feed("🤖 Agent\n12s · 0 tools\n→ Working…")),
97
+ ).toBe(true);
98
+ expect(
99
+ isActivityFeedMessage(feed("🤖 Agent\n2m05s · 0 tools\n→ Working…")),
100
+ ).toBe(true);
101
+ });
102
+
103
+ it("matches a headered narration card (header + narrated → step)", () => {
104
+ expect(
105
+ isActivityFeedMessage(feed("🤖 Agent\n18s · 2 tools\n✓ Checking the hostname\n→ Writing the file")),
106
+ ).toBe(true);
107
+ });
108
+ });
109
+
110
+ describe("isLivenessCardMessage", () => {
111
+ it("matches the climbing Working… card (running header)", () => {
112
+ expect(isLivenessCardMessage(feed("🤖 Agent\n12s · 0 tools\n→ Working…"))).toBe(true);
113
+ expect(isLivenessCardMessage(feed("🤖 Agent\n1m41s · 3 tools\n→ Working…"))).toBe(true);
114
+ });
115
+
116
+ it("matches the done header shape", () => {
117
+ expect(isLivenessCardMessage(feed("🤖 Agent\ndone · 3 tools · 41s\n✓ Ran the check"))).toBe(true);
118
+ });
119
+
120
+ it("matches a header with a description on line 1", () => {
121
+ expect(isLivenessCardMessage(feed("🤖 Agent · summarising the logs\n8s · 1 tool\n→ Working…"))).toBe(true);
122
+ });
123
+
124
+ it("does NOT match a plain reply", () => {
125
+ expect(isLivenessCardMessage(feed("done! I created the file and listed it."))).toBe(false);
126
+ });
127
+
128
+ it("does NOT match a reply that opens with an emoji but is prose", () => {
129
+ expect(
130
+ isLivenessCardMessage(feed("🤖 Agent here — I finished the task.\nAll four steps done.")),
131
+ ).toBe(false);
132
+ });
133
+
134
+ it("does NOT match when prose follows the header", () => {
135
+ expect(
136
+ isLivenessCardMessage(feed("🤖 Agent\n12s · 0 tools\nHere is your answer.")),
137
+ ).toBe(false);
138
+ });
139
+
140
+ it("does NOT match a bare single header line", () => {
141
+ expect(isLivenessCardMessage(feed("🤖 Agent"))).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe("isFrameworkFallbackText", () => {
146
+ it("flags the mid-turn / dark-turn fallback wording", () => {
147
+ expect(isFrameworkFallbackText("⚠️ still working… (no update from agent in 5 min)")).toBe(true);
148
+ expect(isFrameworkFallbackText("The agent finished working but didn't send a reply.")).toBe(true);
149
+ expect(isFrameworkFallbackText("I'm blocked — waiting for your approval to proceed.")).toBe(true);
150
+ });
151
+
152
+ it("does NOT flag an ordinary answer", () => {
153
+ expect(isFrameworkFallbackText("Done — I created /tmp/foo and wrote a file in it.")).toBe(false);
154
+ });
86
155
  });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Fuzz: dead-air-between-visible-updates bound across turn shapes (Phase 4c,
3
+ * `deterministic-turn-liveness.md`). Non-required (`uat-fuzz`,
4
+ * `workflow_dispatch` + scheduled — see `ci-uat.yml`), scoped to `fuzz-*`.
5
+ *
6
+ * SCOPE HONESTY (read before trusting this as "the fuzz invariant" wholesale):
7
+ * the RFC's Phase 4c corpus is message-timing × turn-length × tool-churn ×
8
+ * sub-agent-fan-out × surface × role — a genuinely randomized property-fuzz.
9
+ * That full corpus already exists at the DECISION layer
10
+ * (`telegram-plugin/tests/turn-liveness-invariant.test.ts`, 2000 random
11
+ * shapes × both surfaces, fast/local/every-CI-run). What does NOT exist yet
12
+ * is a live-transport fuzz of the same breadth — each live turn burns real
13
+ * subscription quota and ~30-90s wall-clock, so a 2000-shape live corpus is
14
+ * not realistic to author or run from this sandbox (or CI, at any cadence
15
+ * short of a dedicated long-running canary). This file is the SCAFFOLD:
16
+ * a handful of FIXED, hand-picked turn shapes run on the real surface,
17
+ * checked against the SAME dead-air bound the decision-layer fuzz proves in
18
+ * the abstract. It is not a substitute for a true randomized live corpus —
19
+ * see the RFC's Known gaps / follow-up list, where this limitation is named
20
+ * explicitly rather than left implicit.
21
+ *
22
+ * Each case fires a turn shape and watches the two Phase-1 (keystone)
23
+ * invariants on the real surface — this is a climb/dead-air fuzz, NOT a
24
+ * dark-turn fuzz (the dark-turn at-most-once latch is proven in the
25
+ * `silent-end-recovery-{dm,channel}` scenarios, not here):
26
+ *
27
+ * - dead air between VISIBLE updates (card edits, narration, or the final
28
+ * answer) never exceeds `MAX_DEAD_AIR_MS` — generous slack over the
29
+ * ~6-12s Phase-1 bound to absorb live Bot API + model latency jitter;
30
+ * - zero mid-turn erosion: no non-final message with `silent===false`, and
31
+ * no framework fallback TEXT send masquerading as the answer.
32
+ */
33
+
34
+ import { describe, expect, it } from "vitest";
35
+ import { spinUp } from "../harness.js";
36
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
37
+ import type { ObservedMessage } from "../driver.js";
38
+
39
+ const MAX_DEAD_AIR_MS = 25_000; // generous slack over the ~6-12s Phase-1 bound
40
+ const CASE_BUDGET_MS = 130_000;
41
+
42
+ interface FuzzCase {
43
+ name: string;
44
+ prompt: string;
45
+ windowMs: number;
46
+ }
47
+
48
+ const CASES: FuzzCase[] = [
49
+ {
50
+ name: "single-silent-tool-short",
51
+ prompt:
52
+ "Run exactly one Bash command `sleep 20` with NO narration before or " +
53
+ "during it, then reply with a one-line confirmation.",
54
+ windowMs: 30_000,
55
+ },
56
+ {
57
+ name: "single-silent-tool-long",
58
+ prompt:
59
+ "Run exactly one Bash command `sleep 50` with NO narration before or " +
60
+ "during it, then reply with a one-line confirmation.",
61
+ windowMs: 60_000,
62
+ },
63
+ {
64
+ name: "two-silent-tools-back-to-back",
65
+ prompt:
66
+ "Run Bash `sleep 20`, then immediately (no narration in between) run " +
67
+ "Bash `sleep 20` again, then reply with a one-line confirmation.",
68
+ windowMs: 50_000,
69
+ },
70
+ ];
71
+
72
+ describe("uat-fuzz: liveness dead-air bound across a handful of turn shapes (Phase 4c scaffold)", () => {
73
+ for (const fc of CASES) {
74
+ it(
75
+ `[${fc.name}] dead air between visible updates never exceeds ${MAX_DEAD_AIR_MS}ms; no mid-turn ping`,
76
+ async () => {
77
+ const sc = await spinUp({ agent: "test-harness" });
78
+ try {
79
+ const iter = sc.driver
80
+ .observeMessages(sc.botUserId)
81
+ [Symbol.asyncIterator]();
82
+
83
+ await sc.sendDM(fc.prompt);
84
+ const sentAt = Date.now();
85
+ let lastVisibleAt = sentAt;
86
+ let maxGap = 0;
87
+ let answer: ObservedMessage | null = null;
88
+ let loudMidTurn: ObservedMessage | null = null;
89
+
90
+ const deadline = Date.now() + fc.windowMs + 40_000;
91
+ while (Date.now() < deadline) {
92
+ if (answer) break;
93
+ const remaining = deadline - Date.now();
94
+ const next = await Promise.race([
95
+ iter.next(),
96
+ new Promise<{ done: true; value: undefined }>((r) =>
97
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
98
+ ),
99
+ ]);
100
+ if (next.done || next.value == null) break;
101
+ const m = next.value as ObservedMessage;
102
+ if (m.senderUserId === sc.driverUserId) continue;
103
+
104
+ const now = Date.now();
105
+ if (isActivityFeedMessage(m)) {
106
+ const gap = now - lastVisibleAt;
107
+ maxGap = Math.max(maxGap, gap);
108
+ lastVisibleAt = now;
109
+ if (m.edited && m.silent === false) loudMidTurn = loudMidTurn ?? m;
110
+ continue;
111
+ }
112
+ if (m.edited) continue;
113
+ // A framework fallback TEXT send is a mid-turn erosion, not the
114
+ // answer — never let it be swallowed as the reply (else a re-added
115
+ // "still working…" ping would pass this fuzz vacuously).
116
+ if (isFrameworkFallbackText(m.text)) {
117
+ loudMidTurn = loudMidTurn ?? m;
118
+ continue;
119
+ }
120
+ if (!answer && m.text.trim().length > 0) {
121
+ answer = m;
122
+ const gap = now - lastVisibleAt;
123
+ maxGap = Math.max(maxGap, gap);
124
+ continue;
125
+ }
126
+ if (m.silent === false) loudMidTurn = loudMidTurn ?? m;
127
+ }
128
+ await iter.return?.();
129
+
130
+ console.log(
131
+ `[fuzz-liveness-climb][${fc.name}] maxGap=${maxGap}ms answer=${answer != null}`,
132
+ );
133
+
134
+ expect(
135
+ loudMidTurn,
136
+ `[${fc.name}] a mid-turn message pinged the device: ` +
137
+ JSON.stringify(loudMidTurn?.text?.slice(0, 120)),
138
+ ).toBeNull();
139
+
140
+ expect(answer, `[${fc.name}] FAIL — no answer within budget; turn may be wedged.`).not.toBeNull();
141
+
142
+ expect(
143
+ maxGap,
144
+ `[${fc.name}] dead air of ${maxGap}ms between visible updates exceeds the ` +
145
+ `${MAX_DEAD_AIR_MS}ms bound — the Phase-1 climb should have kept the card ` +
146
+ "moving throughout the silent tool stretch.",
147
+ ).toBeLessThanOrEqual(MAX_DEAD_AIR_MS);
148
+ } finally {
149
+ await sc.tearDown();
150
+ }
151
+ },
152
+ CASE_BUDGET_MS,
153
+ );
154
+ }
155
+ });