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,88 @@
1
+ /**
2
+ * Activity-card durability WIRING (deterministic-turn-liveness.md Known Gap 1 +
3
+ * the #2895 review fix). The 20 behavioural tests in activity-card-store.test.ts
4
+ * drive the pure store/reaper in ISOLATION — they prove the routine is correct
5
+ * but say nothing about whether the GATEWAY actually calls it. That is exactly
6
+ * the decision-vs-delivery gap the whole RFC exists to close, applied to its own
7
+ * follow-up: a refactor that drops the `writeActivityCardRecord` call on card
8
+ * open would keep every store test green while silently un-delivering the
9
+ * guarantee.
10
+ *
11
+ * The gateway IIFE can't be instantiated in-process (same constraint as
12
+ * silence-liveness-wiring.test.ts / multitopic-routing-wiring.test.ts), so these
13
+ * are STRUCTURAL assertions that pin the three load-bearing call sites. They
14
+ * COMPLEMENT (never replace) the behavioural store tests — the RFC's "structural
15
+ * grep may not be the SOLE coverage" bar is met because the outcome is proven at
16
+ * the routine level; this only guards the wiring the routine depends on.
17
+ */
18
+ import { describe, it, expect } from 'vitest'
19
+ import { readFileSync } from 'node:fs'
20
+ import { resolve } from 'node:path'
21
+
22
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
23
+
24
+ function between(src: string, startMarker: string, endMarker: string): string {
25
+ const after = src.split(startMarker)[1] ?? ''
26
+ return after.split(endMarker)[0] ?? ''
27
+ }
28
+
29
+ describe('activity-card durability wiring', () => {
30
+ it('the card-OPEN path persists the durable handle (writeActivityCardRecord)', () => {
31
+ // The open branch runs when activityMessageId transitions null → set.
32
+ const openBranch = between(
33
+ gatewaySrc,
34
+ 'if (turn.activityMessageId == null) {',
35
+ 'turn.activityLastSentRender = target',
36
+ )
37
+ expect(openBranch.length).toBeGreaterThan(100)
38
+ expect(openBranch).toMatch(/writeActivityCardRecord\(/)
39
+ // Persist is gated on the feature flag, not unconditional.
40
+ expect(openBranch).toMatch(/activityCardPersistEnabled/)
41
+ // Pin honesty: the persisted RECORD mirrors the ACTUAL pin decision, never
42
+ // a bare `pinned: true` (the #2895 review fix). Scope the negative check to
43
+ // the writeActivityCardRecord argument block — the reconcileStatusPin call
44
+ // further down legitimately passes `{ pinned: true }` as the desired state.
45
+ const recordBlock = between(openBranch, 'writeActivityCardRecord(', 'void reconcileStatusPin(')
46
+ expect(recordBlock).toMatch(/pinned: PIN_STATUS_WHILE_WORKING/)
47
+ // Strip comment lines (the code comment legitimately quotes the old
48
+ // `pinned: true` shape it's warning against) before checking the CODE.
49
+ const recordCode = recordBlock
50
+ .split('\n')
51
+ .filter((l) => !l.trim().startsWith('//'))
52
+ .join('\n')
53
+ expect(recordCode).not.toMatch(/pinned: true/)
54
+ })
55
+
56
+ it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
57
+ const closeBody = between(
58
+ gatewaySrc,
59
+ 'const id = turn.activityMessageId\n turn.activityMessageId = null',
60
+ 'if (CLEAR_STATUS_ON_COMPLETION)',
61
+ )
62
+ expect(closeBody.length).toBeGreaterThan(50)
63
+ expect(closeBody).toMatch(/clearActivityCardRecord\(/)
64
+ // Must pass the id so it only drops THIS card's row (not a fresher upsert).
65
+ expect(closeBody).toMatch(/\n\s*id,\s*\n/)
66
+ })
67
+
68
+ it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
69
+ // Both invocation sites (mutex-won + mutex-fallback) sit AFTER
70
+ // acquireStartupLock, alongside statusPinBootCleanup.
71
+ const afterLock = between(gatewaySrc, 'await acquireStartupLock({', 'catch (err)')
72
+ expect(afterLock).toMatch(/void activityCardBootReaper\(\)/)
73
+ // And it must NOT be invoked at module import time — the same losing-double-
74
+ // boot hazard the NOTE at statusPinBootCleanup documents.
75
+ const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
76
+ expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
77
+ })
78
+
79
+ it('the reaper wrapper counts a benign-400 as vanished, not finalized (honest boot log)', () => {
80
+ const wrapper = between(
81
+ gatewaySrc,
82
+ 'async function activityCardBootReaper()',
83
+ '\n// NOTE: statusPinBootCleanup()',
84
+ )
85
+ expect(wrapper).toMatch(/vanished/)
86
+ expect(wrapper).toMatch(/at-most-once/)
87
+ })
88
+ })
@@ -94,7 +94,7 @@ describe('renderShowText — Format 2 vs legacy', () => {
94
94
  expect(out).toContain('🔋 **Auth — fleet status**');
95
95
  expect(out).toContain('Recommendation:');
96
96
  // GFM table card (#2700): State emoji per row, not group headers.
97
- expect(out).toContain('| State | Account | 5h | 7d | Status |');
97
+ expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
98
98
  expect(out).toContain('| 🔴 |'); // the blocked account's State cell
99
99
  expect(out).toContain('| 🟢 |'); // a healthy account's State cell
100
100
  expect(out).not.toContain('**BLOCKED**');
@@ -262,11 +262,13 @@ describe('renderAuthSnapshotFormat2', () => {
262
262
  return rows;
263
263
  }
264
264
 
265
- it('renders a GFM table with the State/Account/5h/7d/Status header', () => {
265
+ it('renders a GFM table with the State/Account/5h/5h resets/7d/7d resets header', () => {
266
266
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
267
267
  expect(out).toContain('🔋 **Auth — fleet status**');
268
- expect(out).toContain('| State | Account | 5h | 7d | Status |');
269
- expect(out).toContain('| --- | --- | --- | --- | --- |');
268
+ expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
269
+ expect(out).toContain('| --- | --- | --- | --- | --- | --- |');
270
+ // The single collapsed Status column is gone.
271
+ expect(out).not.toContain('| 7d | Status |');
270
272
  // No legacy group headers / health-section titles remain.
271
273
  expect(out).not.toContain('**BLOCKED**');
272
274
  expect(out).not.toContain('**HEALTHY**');
@@ -307,16 +309,22 @@ describe('renderAuthSnapshotFormat2', () => {
307
309
  expect(bob).toBeLessThan(alice);
308
310
  });
309
311
 
310
- it('Status column shows "back <when>" for a blocked account', () => {
312
+ it('reset columns show a "<time> (in ...)" cell for a blocked account (7d binding window)', () => {
311
313
  const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
312
314
  const bob = rows.find((r) => r[1].includes('bob@example.com'))!;
313
- expect(bob[4]).toMatch(/^back .* \(in .+\)$/);
315
+ // bob is 7d-maxed; its 7d reset (2026-05-17T10:00Z) is ~2 days out and still
316
+ // renders in its own reset cell — no "back" prefix in the new per-window shape.
317
+ expect(bob[5]).toMatch(/^.* \(in .+\)$/);
318
+ expect(bob[5]).not.toContain('back');
314
319
  });
315
320
 
316
- it('Status column shows "refills <when>" for a healthy account', () => {
321
+ it('reset columns show a "<time> (in ...)" cell for each window of a healthy account', () => {
317
322
  const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
318
323
  const you = rows.find((r) => r[1].includes('you@example.com'))!;
319
- expect(you[4]).toMatch(/^refills .* \(in .+\)$/);
324
+ // 5h resets cell [3] and 7d resets cell [5] both carry a relative hint.
325
+ expect(you[3]).toMatch(/^.* \(in .+\)$/);
326
+ expect(you[5]).toMatch(/^.* \(in .+\)$/);
327
+ expect(you[3]).not.toContain('refills');
320
328
  });
321
329
 
322
330
  it('NEVER displays a percentage over 100% even on an over-cap account', () => {
@@ -330,13 +338,15 @@ describe('renderAuthSnapshotFormat2', () => {
330
338
  ];
331
339
  const rows = tableRows(renderAuthSnapshotFormat2(overSnaps, { now: NOW, tz: 'UTC' }));
332
340
  expect(rows[0][2]).toBe('100%'); // 5h
333
- expect(rows[0][3]).toBe('100%'); // 7d
334
- // And the blocked state still surfaces via the emoji (🔴) + Status.
341
+ expect(rows[0][4]).toBe('100%'); // 7d
342
+ // And the blocked state still surfaces via the emoji (🔴). No resets known
343
+ // (fixture has no reset timestamps) → both reset cells degrade to "—".
335
344
  expect(rows[0][0]).toBe('🔴');
336
- expect(rows[0][4]).toMatch(/^back/);
345
+ expect(rows[0][3]).toBe('—'); // 5h resets
346
+ expect(rows[0][5]).toBe('—'); // 7d resets
337
347
  });
338
348
 
339
- it('renders dates in the Status column only when NOT today (user tz)', () => {
349
+ it('renders dates in the reset columns only when NOT today (user tz)', () => {
340
350
  // now = Fri 3:00 PM Melbourne. A reset later the SAME Melbourne day shows
341
351
  // time-only; a reset on the next day shows the weekday.
342
352
  const MEL = 'Australia/Melbourne';
@@ -349,8 +359,9 @@ describe('renderAuthSnapshotFormat2', () => {
349
359
  }) }),
350
360
  ];
351
361
  const todayRows = tableRows(renderAuthSnapshotFormat2(sameDay, { now: NOW_MEL, tz: MEL }));
352
- expect(todayRows[0][4]).toContain('9:00 PM');
353
- expect(todayRows[0][4]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
362
+ // 5h resets cell [3] — same Melbourne day → time only.
363
+ expect(todayRows[0][3]).toContain('9:00 PM');
364
+ expect(todayRows[0][3]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
354
365
 
355
366
  const nextDay = [
356
367
  snap({ label: 'tomorrow@example.com', isActive: true, quota: quota({
@@ -360,7 +371,8 @@ describe('renderAuthSnapshotFormat2', () => {
360
371
  }) }),
361
372
  ];
362
373
  const tomorrowRows = tableRows(renderAuthSnapshotFormat2(nextDay, { now: NOW_MEL, tz: MEL }));
363
- expect(tomorrowRows[0][4]).toContain('Sat 1:00 AM');
374
+ // 5h resets cell [3] next Melbourne day → weekday prefix.
375
+ expect(tomorrowRows[0][3]).toContain('Sat 1:00 AM');
364
376
  });
365
377
 
366
378
  it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
@@ -1120,14 +1132,16 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
1120
1132
  expect(allText).toContain('Switch fleet → carol@example.com');
1121
1133
  });
1122
1134
 
1123
- it('quota-exhausted row shows a 🔴 + "back <when>" Status, not "billing disabled"', () => {
1135
+ it('quota-exhausted row shows a 🔴 + its 5h reset time in the reset cell, not "billing disabled"', () => {
1124
1136
  const futureReset = new Date(NOW.getTime() + 45 * 60_000);
1125
1137
  const out = renderAuthSnapshotFormat2(
1126
1138
  [snap({ label: 'ex@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) })],
1127
1139
  { now: NOW },
1128
1140
  );
1129
1141
  expect(out).toContain('| 🔴 |');
1130
- expect(out).toMatch(/back .* \(in 45m\)/);
1142
+ // The 5h reset (45m out) renders in its own reset cell — no "back" prefix.
1143
+ expect(out).toMatch(/\(in 45m\)/);
1144
+ expect(out).not.toContain('back ');
1131
1145
  expect(out).not.toContain('billing disabled');
1132
1146
  expect(out).not.toContain('overage off');
1133
1147
  });
@@ -137,6 +137,54 @@ describe('Claude Code event-stream contract (canary)', () => {
137
137
  ).toBe(false)
138
138
  })
139
139
 
140
+ it('CANARY (Fix 3 precondition): Task tool_use still projects {id, name, input} — the shape the foreground-sub-agent-tracking / post-answer-liveness-freeze fix keys "still dispatched" off of', () => {
141
+ // The Fix 3 gap-closer (turn-liveness-floor.ts `stillDispatched`) is only
142
+ // as good as `turn.foregroundSubAgents` staying populated for the
143
+ // lifetime of a real Task dispatch. That population is driven by THIS
144
+ // exact tool_use projection (toolName === 'Task') plus the matching
145
+ // tool_result closing it out — if a future Claude Code release renames
146
+ // `name`/`input`/`id` or nests them differently, the fix silently goes
147
+ // inert (stillDispatched always false, freeze regresses) rather than
148
+ // failing loudly. Pin the shape here so drift fails CI instead.
149
+ const line = JSON.stringify({
150
+ type: 'assistant',
151
+ message: {
152
+ id: 'msg_task',
153
+ stop_reason: 'tool_use',
154
+ content: [
155
+ {
156
+ type: 'tool_use',
157
+ id: 'toolu_task_1',
158
+ name: 'Task',
159
+ input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
160
+ },
161
+ ],
162
+ },
163
+ })
164
+ const evs = projectTranscriptLine(line)
165
+ expect(evs).toEqual([
166
+ {
167
+ kind: 'tool_use',
168
+ toolName: 'Task',
169
+ toolUseId: 'toolu_task_1',
170
+ input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
171
+ },
172
+ ])
173
+ // The matching tool_result (Task returning) must still carry tool_use_id
174
+ // so the gateway can close out the foreground-sub-agent tracking entry.
175
+ const resultLine = JSON.stringify({
176
+ type: 'user',
177
+ message: { content: [{ type: 'tool_result', tool_use_id: 'toolu_task_1', is_error: false, content: 'done' }] },
178
+ })
179
+ const resultEvs = projectTranscriptLine(resultLine)
180
+ expect(resultEvs).toHaveLength(1)
181
+ // `is_error: false` projects to `isError: undefined` (only `true` is
182
+ // ever stamped — see session-tail.ts's `c.is_error === true ? true :
183
+ // undefined`), so assert the falsy/absent form, not a literal `false`.
184
+ expect(resultEvs[0]).toMatchObject({ kind: 'tool_result', toolUseId: 'toolu_task_1' })
185
+ expect((resultEvs[0] as { isError?: boolean }).isError).toBeFalsy()
186
+ })
187
+
140
188
  it('sub-agent kickoff: first user message string prompt fires sub_agent_started', () => {
141
189
  const st = { hasEmittedStart: false }
142
190
  const line = JSON.stringify({
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isConsolidationLegibilityEnabled,
4
+ detectConsolidationEvent,
5
+ renderConsolidationLine,
6
+ consolidationSignature,
7
+ ConsolidationRateLimiter,
8
+ HINDSIGHT_WEBHOOK_SOURCE,
9
+ CONSOLIDATION_COMPLETED_EVENT,
10
+ } from '../consolidation-legibility.js'
11
+
12
+ describe('isConsolidationLegibilityEnabled — OPT-IN (default OFF)', () => {
13
+ it('is OFF when unset (opposite of the default-ON tool-observation path)', () => {
14
+ expect(isConsolidationLegibilityEnabled(undefined)).toBe(false)
15
+ })
16
+ it('is OFF for the empty string and for "0"/"false"/"off"', () => {
17
+ expect(isConsolidationLegibilityEnabled('')).toBe(false)
18
+ expect(isConsolidationLegibilityEnabled('0')).toBe(false)
19
+ expect(isConsolidationLegibilityEnabled('false')).toBe(false)
20
+ expect(isConsolidationLegibilityEnabled('off')).toBe(false)
21
+ expect(isConsolidationLegibilityEnabled('no')).toBe(false)
22
+ })
23
+ it('is ON only for an explicit truthy opt-in', () => {
24
+ expect(isConsolidationLegibilityEnabled('1')).toBe(true)
25
+ expect(isConsolidationLegibilityEnabled('true')).toBe(true)
26
+ expect(isConsolidationLegibilityEnabled('on')).toBe(true)
27
+ expect(isConsolidationLegibilityEnabled('yes')).toBe(true)
28
+ expect(isConsolidationLegibilityEnabled(' TRUE ')).toBe(true)
29
+ })
30
+ })
31
+
32
+ describe('wire constants', () => {
33
+ it('names the hindsight consolidation.completed source/event', () => {
34
+ expect(HINDSIGHT_WEBHOOK_SOURCE).toBe('hindsight')
35
+ expect(CONSOLIDATION_COMPLETED_EVENT).toBe('consolidation.completed')
36
+ })
37
+ })
38
+
39
+ describe('detectConsolidationEvent — MATERIAL-only (sparse)', () => {
40
+ it('returns null on a routine no-op consolidation (nothing stored/corrected)', () => {
41
+ expect(detectConsolidationEvent({ stored: 0, corrected: 0 })).toBeNull()
42
+ expect(detectConsolidationEvent({})).toBeNull()
43
+ expect(detectConsolidationEvent(undefined)).toBeNull()
44
+ })
45
+
46
+ it('classifies a pure store as "updated"', () => {
47
+ const ev = detectConsolidationEvent({ stored: 2, subject: 'your deploy preferences' })
48
+ expect(ev).toEqual({ kind: 'updated', topic: 'your deploy preferences' })
49
+ })
50
+
51
+ it('classifies any correction as "revised" (higher signal), even alongside stores', () => {
52
+ expect(detectConsolidationEvent({ corrected: 1, subject: 'old runbook' })).toEqual({
53
+ kind: 'revised',
54
+ topic: 'old runbook',
55
+ })
56
+ expect(detectConsolidationEvent({ stored: 3, corrected: 1, subject: 'X' })).toEqual({
57
+ kind: 'revised',
58
+ topic: 'X',
59
+ })
60
+ })
61
+
62
+ it('tolerates field aliases (created/added/new_observations; invalidated/superseded)', () => {
63
+ expect(detectConsolidationEvent({ created: 1, topic: 'a' })?.kind).toBe('updated')
64
+ expect(detectConsolidationEvent({ new_observations: 5, topic: 'a' })?.kind).toBe('updated')
65
+ expect(detectConsolidationEvent({ invalidated: 2, topic: 'a' })?.kind).toBe('revised')
66
+ expect(detectConsolidationEvent({ superseded: 1, topic: 'a' })?.kind).toBe('revised')
67
+ })
68
+
69
+ it('counts an observations array by length as a store', () => {
70
+ const ev = detectConsolidationEvent({
71
+ observations: [{ statement: 'User prefers TS', subject: 'coding style' }],
72
+ })
73
+ expect(ev).toEqual({ kind: 'updated', topic: 'coding style' })
74
+ })
75
+
76
+ it('accepts numeric-string counts', () => {
77
+ expect(detectConsolidationEvent({ stored: '2', subject: 'x' })?.kind).toBe('updated')
78
+ })
79
+
80
+ it('derives topic from subjects[]/entities[] arrays when no direct subject', () => {
81
+ expect(detectConsolidationEvent({ stored: 1, subjects: ['travel plans'] })?.topic).toBe(
82
+ 'travel plans',
83
+ )
84
+ expect(
85
+ detectConsolidationEvent({ stored: 1, entities: [{ name: 'Acme Corp' }] })?.topic,
86
+ ).toBe('Acme Corp')
87
+ })
88
+
89
+ it('falls back to the first observation statement when no subject', () => {
90
+ const ev = detectConsolidationEvent({
91
+ observations: [{ statement: 'The user lives in Sydney' }],
92
+ })
93
+ expect(ev?.topic).toBe('The user lives in Sydney')
94
+ })
95
+
96
+ it('leaves topic empty when the payload carries no legible subject', () => {
97
+ expect(detectConsolidationEvent({ stored: 1, bank_id: 'agent-x' })?.topic).toBe('')
98
+ })
99
+ })
100
+
101
+ describe('renderConsolidationLine — terse, HTML, notification-free surface', () => {
102
+ it('renders the updated line with a subject', () => {
103
+ expect(renderConsolidationLine({ kind: 'updated', topic: 'your deploy preferences' })).toBe(
104
+ '🧠 <i>updated what I know</i> about "your deploy preferences"',
105
+ )
106
+ })
107
+ it('renders the revised line with a subject', () => {
108
+ expect(renderConsolidationLine({ kind: 'revised', topic: 'the old runbook' })).toBe(
109
+ '🧠 <i>revised what I know</i> about "the old runbook"',
110
+ )
111
+ })
112
+ it('falls back to a bare line when there is no legible subject', () => {
113
+ expect(renderConsolidationLine({ kind: 'updated', topic: '' })).toBe(
114
+ '🧠 <i>updated what I know about you.</i>',
115
+ )
116
+ expect(renderConsolidationLine({ kind: 'revised', topic: ' ' })).toBe(
117
+ '🧠 <i>revised what I know about you.</i>',
118
+ )
119
+ })
120
+ it('HTML-escapes the subject (no entity injection)', () => {
121
+ expect(renderConsolidationLine({ kind: 'updated', topic: 'a <b> & c' })).toBe(
122
+ '🧠 <i>updated what I know</i> about "a &lt;b&gt; &amp; c"',
123
+ )
124
+ })
125
+ it('truncates a very long subject', () => {
126
+ const long = 'x'.repeat(500)
127
+ const out = renderConsolidationLine({ kind: 'updated', topic: long })
128
+ expect(out.length).toBeLessThan(200)
129
+ expect(out).toContain('…')
130
+ })
131
+ })
132
+
133
+ describe('ConsolidationRateLimiter — sparse / non-spammy', () => {
134
+ it('allows the first line, then blocks a second within the min-interval', () => {
135
+ let t = 1_000_000
136
+ const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
137
+ expect(rl.allow('klanker', 'updated:a')).toBe(true)
138
+ t += 60_000 // 1 min later
139
+ expect(rl.allow('klanker', 'updated:b')).toBe(false) // different topic, still capped
140
+ })
141
+
142
+ it('allows again once the min-interval has elapsed', () => {
143
+ let t = 1_000_000
144
+ const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
145
+ expect(rl.allow('klanker', 'updated:a')).toBe(true)
146
+ t += 600_001
147
+ expect(rl.allow('klanker', 'updated:b')).toBe(true)
148
+ })
149
+
150
+ it('suppresses an IDENTICAL signature across the dedup window even after the interval', () => {
151
+ let t = 1_000_000
152
+ const rl = new ConsolidationRateLimiter({
153
+ minIntervalMs: 600_000,
154
+ dedupWindowMs: 3_600_000,
155
+ now: () => t,
156
+ })
157
+ expect(rl.allow('klanker', 'updated:deploy')).toBe(true)
158
+ t += 700_000 // past min-interval…
159
+ expect(rl.allow('klanker', 'updated:deploy')).toBe(false) // …but same topic, still deduped
160
+ t += 3_600_000 // past dedup window
161
+ expect(rl.allow('klanker', 'updated:deploy')).toBe(true)
162
+ })
163
+
164
+ it('rate-limits per agent independently', () => {
165
+ let t = 1_000_000
166
+ const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
167
+ expect(rl.allow('klanker', 'updated:a')).toBe(true)
168
+ expect(rl.allow('reggie', 'updated:a')).toBe(true) // different agent, own budget
169
+ })
170
+
171
+ it('accepts an explicit now override per call', () => {
172
+ const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000 })
173
+ expect(rl.allow('klanker', 'updated:a', 0)).toBe(true)
174
+ expect(rl.allow('klanker', 'updated:a', 100)).toBe(false)
175
+ expect(rl.allow('klanker', 'updated:a', 700_000)).toBe(false) // dedup window
176
+ expect(rl.allow('klanker', 'updated:b', 700_000)).toBe(true) // new topic past interval
177
+ })
178
+
179
+ // Regression: the gateway caller (surfaceConsolidationLegibility) MUST pass
180
+ // Date.now() as the clock, never a payload-derived rec.ts. This test proves
181
+ // WHY: a spoofed/stale timestamp from the webhook payload poisons the
182
+ // per-agent min-interval gate. A far-FUTURE payload ts leaves the stored
183
+ // last-surface time in the future, so a genuine later wall-clock surface
184
+ // reads as "in the past" and the gate stays jammed shut; a far-PAST ts makes
185
+ // every subsequent surface look older than the window and the gate never
186
+ // engages (spam). Feeding wall-clock avoids both.
187
+ it('a payload-derived far-future timestamp would jam the interval gate shut', () => {
188
+ const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
189
+ const wallNow = 1_000_000
190
+ const spoofedFuture = wallNow + 10 * 365 * 24 * 3_600_000 // ~10y ahead
191
+ // First surface stamped with the attacker's future ts.
192
+ expect(rl.allow('klanker', 'updated:a', spoofedFuture)).toBe(true)
193
+ // A genuine later surface (real wall-clock) is now "before" the stored
194
+ // last time → interval never elapses → legitimate surface suppressed.
195
+ expect(rl.allow('klanker', 'updated:b', wallNow + 700_000)).toBe(false)
196
+ })
197
+
198
+ it('a payload-derived timestamp lets a spoofer defeat the interval gate (spam)', () => {
199
+ // Two events arrive ~1ms apart in REALITY, but each carries a payload ts
200
+ // inflated to appear far apart. Keyed on payload ts, the gate sees them as
201
+ // past the interval and lets BOTH through — spam.
202
+ const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
203
+ expect(rl.allow('klanker', 'updated:a', 0)).toBe(true)
204
+ expect(rl.allow('klanker', 'updated:b', 700_000)).toBe(true) // spoofed far-apart ts → not blocked
205
+ // Wall-clock (what the fix passes) blocks the same two events 1ms apart.
206
+ const rl2 = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
207
+ const wallNow = 1_000_000_000
208
+ expect(rl2.allow('klanker', 'updated:a', wallNow)).toBe(true)
209
+ expect(rl2.allow('klanker', 'updated:b', wallNow + 1)).toBe(false)
210
+ })
211
+ })
212
+
213
+ describe('consolidationSignature — stable dedup key', () => {
214
+ it('normalises whitespace + case so trivially-different topics collapse', () => {
215
+ expect(consolidationSignature({ kind: 'updated', topic: ' Deploy Prefs ' })).toBe(
216
+ 'updated:deploy prefs',
217
+ )
218
+ })
219
+ it('separates kind so a store and a correction of the same topic differ', () => {
220
+ expect(consolidationSignature({ kind: 'updated', topic: 'x' })).not.toBe(
221
+ consolidationSignature({ kind: 'revised', topic: 'x' }),
222
+ )
223
+ })
224
+ })
@@ -315,7 +315,7 @@ describe('façade delegates to the existing emission primitives (call-site liter
315
315
  })
316
316
  })
317
317
 
318
- describe('the 7 drain sites route through the façade with producers preserved verbatim', () => {
318
+ describe('the drain sites route through the façade with producers preserved verbatim', () => {
319
319
  it('the narrative SHOW site routes via openOrEditCard("narrative") + the producer-"narrative" drain', () => {
320
320
  const body = fnSrc('showNarrativeStep')
321
321
  expect(body).toMatch(/openOrEditCard\('narrative'/)
@@ -325,26 +325,30 @@ describe('the 7 drain sites route through the façade with producers preserved v
325
325
  expect(body).toMatch(/ea\.mayDrain\(turn\)/)
326
326
  })
327
327
 
328
- it('both liveness sites route via openOrEditCard("liveness") + producer-"liveness" drains (one in openLivenessFeedIfDue, one in feedHeartbeatTick)', () => {
328
+ it('all liveness sites route via openOrEditCard("liveness") + producer-"liveness" drains (one in openLivenessFeedIfDue, two in feedHeartbeatTick)', () => {
329
329
  // The early-card-open refactor extracted the 0-tool liveness OPEN out of
330
330
  // feedHeartbeatTick into the shared `openLivenessFeedIfDue` helper (so the
331
331
  // 6 s heartbeat and the enqueue-time early-open timer reach ONE open path
332
- // and never double-open). So the two liveness sites now live in:
333
- // - openLivenessFeedIfDue: the 0-tool early-open / climb;
334
- // - feedHeartbeatTick: the labelled-feed stale-step maintain.
335
- // Both must still route via the façade with the producer arg verbatim.
332
+ // and never double-open). deterministic-turn-liveness.md Phase 1 then added
333
+ // the 0-label CLIMB of an already-open card back into feedHeartbeatTick (an
334
+ // EDIT, never an OPEN — so it does not double-open). So the liveness sites now
335
+ // live in:
336
+ // - openLivenessFeedIfDue: the 0-tool early-OPEN;
337
+ // - feedHeartbeatTick: the 0-label CLIMB of an already-open card
338
+ // (Phase 1) + the labelled-feed stale-step maintain.
339
+ // All must still route via the façade with the producer arg verbatim.
336
340
  const earlyOpen = fnSrc('openLivenessFeedIfDue')
337
341
  const heartbeat = fnSrc('feedHeartbeatTick')
338
342
  const opens = [
339
343
  ...earlyOpen.matchAll(/openOrEditCard\('liveness'/g),
340
344
  ...heartbeat.matchAll(/openOrEditCard\('liveness'/g),
341
345
  ]
342
- expect(opens).toHaveLength(2)
346
+ expect(opens).toHaveLength(3)
343
347
  const drains = [
344
348
  ...earlyOpen.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
345
349
  ...heartbeat.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
346
350
  ]
347
- expect(drains).toHaveLength(2)
351
+ expect(drains).toHaveLength(3)
348
352
  // The literal the feed-heartbeat oracle greps must still be present in both.
349
353
  expect(earlyOpen).toMatch(/turn\.activityInFlight = drainActivitySummary/)
350
354
  expect(heartbeat).toMatch(/turn\.activityInFlight = drainActivitySummary/)
@@ -366,8 +370,14 @@ describe('the 7 drain sites route through the façade with producers preserved v
366
370
 
367
371
  it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
368
372
  const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
369
- // narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness (Fix 2) = 7.
373
+ // narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness
374
+ // (Fix 2) = 7. The Phase-1 0-label climb (deterministic-turn-liveness.md)
375
+ // consults the SAME guard, but its `if (deps.mayDrain())` lives in the
376
+ // extracted tick body (feed-heartbeat-climb.ts) with `() => ea.mayDrain(turn)`
377
+ // injected at the gateway call site — pinned by
378
+ // feed-heartbeat-liveness-open.test.ts + silent-turn-climb-transport.test.ts.
370
379
  expect(mayDrainGuards).toHaveLength(7)
380
+ expect(gatewaySrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
371
381
  })
372
382
  })
373
383
 
@@ -487,11 +497,16 @@ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds th
487
497
  expect(ctxFn).toMatch(/turnInFlight:\s*turnInFlightForGate\(\)/)
488
498
  })
489
499
 
490
- it('the 7 card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
500
+ it('the card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
491
501
  // Option A: the 7 `if (ea.mayDrain(turn))` guards + drainActivitySummary
492
502
  // thunks stay byte-identical, wrapped by the centralized helper.
493
503
  // 6 original + 1 new post-answer background-agent liveness drain (Fix 2).
494
504
  const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
495
505
  expect(wraps).toHaveLength(7)
506
+ // The Phase-1 0-label climb (deterministic-turn-liveness.md) routes through
507
+ // the SAME helper via an injected thunk — its call lives in the extracted
508
+ // tick body (feed-heartbeat-climb.ts), wired at the gateway call site as
509
+ // `cardDrainGate: (run) => cardDrainGate(turn, ea, run)`.
510
+ expect(gatewaySrc).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
496
511
  })
497
512
  })
@@ -25,9 +25,12 @@
25
25
  * `openLivenessFeedIfDue` BEFORE the drain — a turn that hasn't passed the
26
26
  * threshold, or one whose card is already open, returns early (no
27
27
  * double-open).
28
- * 4. The 0-tool branch of `feedHeartbeatTick` delegates to that one shared
29
- * helper rather than inlining the open so the heartbeat and the
30
- * enqueue-time timer can never double-open.
28
+ * 4. The 0-tool branch of `feedHeartbeatTick` OPENS via that one shared helper
29
+ * (so the heartbeat and the enqueue-time timer can never double-open) AND,
30
+ * once a card is open, CLIMBS it every tick via `silentTurnClimbRender`
31
+ * (deterministic-turn-liveness.md Phase 1) — the freeze this test used to
32
+ * enshrine is inverted. The outcome-based proof is the transport test
33
+ * `silent-turn-climb-transport.test.ts`.
31
34
  *
32
35
  * These are STRUCTURAL (source-read) assertions; the gateway IIFE can't be
33
36
  * instantiated in-process. Pattern matches silence-liveness-wiring.test.ts.
@@ -71,17 +74,38 @@ describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
71
74
  expect(initBlock).toMatch(/1[_]?200/)
72
75
  })
73
76
 
74
- it('the 0-tool liveness branch delegates to the shared openLivenessFeedIfDue helper', () => {
75
- // The open logic lives in ONE place so the heartbeat and the enqueue-time
76
- // early-open timer cannot double-open. The 0-tool branch must call the
77
- // shared helper rather than inline its own open.
77
+ it('the 0-label tick routes through the REAL extracted tick body with the gateway deps wired verbatim', () => {
78
+ // deterministic-turn-liveness.md Phase 1 + Phase 4(d): the prior version of
79
+ // this test enshrined the freeze it asserted only that the branch delegates
80
+ // to `openLivenessFeedIfDue` (which no-ops once a card is open), which is
81
+ // exactly why a busy-but-silent 0-label card used to freeze. The property is
82
+ // now INVERTED: the 0-label tick must route through the extracted
83
+ // `runSilentTurnHeartbeatTick` (feed-heartbeat-climb.ts) — the shipped tick
84
+ // body that CLIMBS an already-open card — with the gateway's REAL deps
85
+ // (the shared OPEN helper, cardDrainGate, ea.mayDrain,
86
+ // ea.openOrEditCard('liveness'), the 'liveness' drain) wired at the call
87
+ // site. Removing or mis-wiring this call site fails HERE; the tick body's
88
+ // behaviour is proven outcome-based in
89
+ // tests/silent-turn-climb-transport.test.ts (>=4 climbing edits across a
90
+ // 40s silent tool, edit-only, gate sequence in order).
78
91
  const body = feedHeartbeatTickSrc()
79
- const start = body.indexOf('if (turn.mirrorLines.length === 0)')
92
+ const start = body.indexOf('runSilentTurnHeartbeatTick(')
80
93
  expect(start).toBeGreaterThan(-1)
81
94
  const branch = body.slice(start)
82
95
  const end = branch.indexOf('// Labelled-feed heartbeat')
83
96
  const scoped = end === -1 ? branch : branch.slice(0, end)
84
- expect(scoped).toMatch(/openLivenessFeedIfDue\(turn\)/)
97
+ // The real deps, wired verbatim at the call site:
98
+ expect(scoped).toMatch(/openLivenessFeedIfDue:\s*\(\)\s*=>\s*openLivenessFeedIfDue\(turn\)/)
99
+ expect(scoped).toMatch(/setPendingRender:\s*\(rendered\)\s*=>\s*\{\s*turn\.activityPendingRender = rendered\s*\}/)
100
+ expect(scoped).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
101
+ expect(scoped).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
102
+ expect(scoped).toMatch(/openOrEditCard:\s*\(apply\)\s*=>\s*ea\.openOrEditCard\('liveness', apply\)/)
103
+ expect(scoped).toMatch(/drain:\s*\(\)\s*=>\s*\{\s*turn\.activityInFlight = drainActivitySummary\(turn, 'liveness'\)\s*\}/)
104
+ // The view carries the wall-clock elapsed + the labelled branch's stale floor.
105
+ expect(scoped).toMatch(/ageMs:\s*Date\.now\(\)\s*-\s*turn\.startedAt/)
106
+ expect(scoped).toMatch(/minStaleMs:\s*FEED_HEARTBEAT_MIN_STALE_MS/)
107
+ // A handled tick (0-label branch) stops before the labelled heartbeat.
108
+ expect(scoped).toMatch(/if \(handled\) return/)
85
109
  })
86
110
 
87
111
  it('the WHEN-gate (shouldEarlyOpenLiveness) precedes the drain call inside openLivenessFeedIfDue', () => {
@@ -129,6 +153,17 @@ describe('H-2: feedHeartbeatTick post-answer background-agent liveness (Fix 2 /
129
153
  expect(postAnswerBlock).toMatch(/livenessVerdict\s*!==\s*'emit'/)
130
154
  })
131
155
 
156
+ it('Fix 3: post-answer branch feeds stillDispatched from turn.foregroundSubAgents so a tracked foreground sub-agent bypasses the staleness cap', () => {
157
+ const body = feedHeartbeatTickSrc()
158
+ const afterPostAnswer = body.split('if (turn.finalAnswerDelivered)')[1] ?? ''
159
+ const postAnswerBlock = afterPostAnswer.split('\n }\n')[0] ?? ''
160
+ // A positive foreground-tracking signal must be computed and threaded
161
+ // into the pure decision — this is what stops the card freezing mid-
162
+ // delegation while a foreground Task/Agent is still outstanding.
163
+ expect(postAnswerBlock).toMatch(/stillDispatched\s*=\s*turn\.foregroundSubAgents\.size\s*>\s*0/)
164
+ expect(postAnswerBlock).toMatch(/stillDispatched,/)
165
+ })
166
+
132
167
  it('staleness cap (concern 3) is parsed default-ON (30s) from SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS', () => {
133
168
  // The cap const must default to 30_000 when the env is unset (the `|| 30_000`
134
169
  // fallback over the positive-or-0 parse) so the post-answer card stops