switchroom 0.17.6 → 0.17.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/agent-scheduler/index.js +38 -4
  2. package/dist/auth-broker/index.js +302 -203
  3. package/dist/cli/notion-write-pretool.mjs +35 -2
  4. package/dist/cli/switchroom.js +1178 -576
  5. package/dist/host-control/main.js +148 -14
  6. package/dist/vault/approvals/kernel-server.js +140 -55
  7. package/dist/vault/broker/server.js +142 -57
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/bridge/bridge.ts +80 -1
  13. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  14. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  15. package/telegram-plugin/consolidation-legibility.ts +279 -0
  16. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  17. package/telegram-plugin/dist/gateway/gateway.js +2565 -610
  18. package/telegram-plugin/dist/server.js +86 -2
  19. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  20. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  21. package/telegram-plugin/gateway/gateway.ts +1376 -82
  22. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  23. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  24. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  25. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  26. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  27. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  28. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  29. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  30. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  31. package/telegram-plugin/memory-legibility.ts +217 -0
  32. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  33. package/telegram-plugin/scoped-approval.ts +59 -0
  34. package/telegram-plugin/silent-end.ts +78 -0
  35. package/telegram-plugin/subagent-watcher.ts +60 -6
  36. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  38. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  39. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  40. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  41. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  42. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  43. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  44. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  45. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  46. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  47. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  48. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  49. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  50. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  51. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  52. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  53. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  54. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  55. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  56. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  57. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  58. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  59. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  60. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  61. package/telegram-plugin/uat/assertions.ts +88 -4
  62. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  63. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  64. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  65. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  66. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  71. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  72. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  73. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  74. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  75. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  77. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  81. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  94. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  95. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  96. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  97. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  98. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  126. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  127. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  129. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,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', () => {
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
43
43
  // window to ~5KB so we don't pick up the unrelated
44
44
  // `removeTurnActiveMarker` call in the onTurnComplete handler
45
45
  // way down at the bottom of the file.
46
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 5000)
46
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
47
47
  const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
48
48
  expect(clearMatches.length).toBe(1)
49
49
  })
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
53
53
  // must not prevent the gateway from coming up. Pin the try-wrapping
54
54
  // to make sure a future refactor doesn't drop it.
55
55
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
56
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 5000)
56
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
57
57
  expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
58
58
  })
59
59
 
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
62
62
  // Clearing the marker first means the watchdog sees a clean slate
63
63
  // immediately, even if pin sweep takes 30s+ to finish.
64
64
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
65
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 5000)
65
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 7000)
66
66
  const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
67
67
  const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
68
68
  expect(clearIdx).toBeGreaterThan(0)
@@ -99,6 +99,48 @@ describe('spoolId — stable dedup key', () => {
99
99
  )
100
100
  expect(a).not.toBe(b)
101
101
  })
102
+ // #2793 part B: a cron BOOT-REPLAY carries meta.replay_fire_ms (the
103
+ // minute-aligned fire being replayed). spoolId() keys on it + the
104
+ // schedule index so a re-replay of the SAME missed fire across a gateway
105
+ // restart — which mints a fresh synthetic messageId=ts each boot —
106
+ // collapses to one live entry instead of stacking a fresh one each boot.
107
+ it('cron boot-replay → s:cron-replay:chat:idx:fireMs, stable across ts/messageId', () => {
108
+ const a = spoolId(
109
+ msg({
110
+ messageId: 1700_000_000_000,
111
+ ts: 1700_000_000_000,
112
+ meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '1699999980000' },
113
+ }),
114
+ )
115
+ const b = spoolId(
116
+ msg({
117
+ messageId: 1700_000_999_999,
118
+ ts: 1700_000_999_999,
119
+ meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '1699999980000' },
120
+ }),
121
+ )
122
+ expect(a).toBe('s:cron-replay:c1:3:1699999980000')
123
+ expect(b).toBe(a) // stable across the fresh per-boot messageId/ts
124
+ })
125
+ it('cron boot-replays for distinct missed fires stay distinct', () => {
126
+ const a = spoolId(
127
+ msg({ messageId: 0, meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '100' } }),
128
+ )
129
+ const b = spoolId(
130
+ msg({ messageId: 0, meta: { source: 'cron', schedule_index: '3', replay_fire_ms: '200' } }),
131
+ )
132
+ const c = spoolId(
133
+ msg({ messageId: 0, meta: { source: 'cron', schedule_index: '4', replay_fire_ms: '100' } }),
134
+ )
135
+ expect(a).not.toBe(b) // different fire minute
136
+ expect(a).not.toBe(c) // different schedule index
137
+ })
138
+ it('a LIVE cron tick (no replay_fire_ms) keeps its per-fire identity', () => {
139
+ // Live ticks are not routed through the spool, but assert the id path
140
+ // is unchanged so live fires never collapse into a replay bucket.
141
+ const live = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 500 }))
142
+ expect(live).toBe('s:c1:cron:500')
143
+ })
102
144
  it('subagent_handback without jsonl id falls back to legacy id (back-compat)', () => {
103
145
  const a = spoolId(
104
146
  msg({ messageId: 555, meta: { source: 'subagent_handback' }, ts: 100 }),
@@ -578,3 +620,66 @@ describe('inbound-spool — #2789 B: spool write failure surfaces a health signa
578
620
  expect(events).toEqual([true, false]) // degraded → recovered
579
621
  })
580
622
  })
623
+
624
+ // #2793 part B — cron boot-replay accept-vs-consume durability. A missed
625
+ // scheduled fire re-injected at boot is routed through this spool by the
626
+ // gateway's onInjectInbound handler (put on accept, ack on confirmed
627
+ // delivery). These tests pin the two windows the ledger closes.
628
+ describe('#2793 — cron boot-replay accept vs consume', () => {
629
+ function replayMsg(fireMs: number, boot: number): InboundMessage {
630
+ // A fresh synthetic messageId/ts is minted every replay attempt; the
631
+ // stable identity is (schedule_index, replay_fire_ms).
632
+ return msg({
633
+ messageId: boot,
634
+ ts: boot,
635
+ user: 'cron',
636
+ userId: 0,
637
+ meta: { source: 'cron', schedule_index: '3', replay_fire_ms: String(fireMs) },
638
+ })
639
+ }
640
+
641
+ it('accepted-but-NOT-consumed replay survives a restart and re-fires', () => {
642
+ const fs = fakeFs()
643
+ // Boot 1: the gateway accepts the replay (put) but the session never
644
+ // came up, so it is never delivered → never acked.
645
+ createInboundSpool({ path: PATH, fs, log: () => {} }).put('a', replayMsg(1000, 111))
646
+ // Boot 2: a fresh gateway rehydrates from the durable log. The un-acked
647
+ // replay is STILL live, so the boot-replay path re-delivers it — the
648
+ // silent-loss window (accepted, never consumed) is closed.
649
+ const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
650
+ const live = s2.liveEntries()
651
+ expect(live).toHaveLength(1)
652
+ expect(live[0]!.msg.meta?.replay_fire_ms).toBe('1000')
653
+ })
654
+
655
+ it('a CONSUMED replay (acked on delivery) does NOT re-fire on restart', () => {
656
+ const fs = fakeFs()
657
+ const s1 = createInboundSpool({ path: PATH, fs, log: () => {} })
658
+ const m = replayMsg(1000, 111)
659
+ s1.put('a', m)
660
+ s1.ack(m) // gateway delivered it to a live bridge → tombstone
661
+ // A fresh gateway sees the tombstone and does not re-deliver — the
662
+ // double-fire window is closed for a consumed replay.
663
+ const s2 = createInboundSpool({ path: PATH, fs, log: () => {} })
664
+ expect(s2.liveEntries()).toHaveLength(0)
665
+ })
666
+
667
+ it('a re-replay of the SAME missed fire dedups (at-least-once with dedup)', () => {
668
+ const fs = fakeFs()
669
+ const s = createInboundSpool({ path: PATH, fs, log: () => {} })
670
+ // First accept.
671
+ expect(s.put('a', replayMsg(1000, 111))).toBe(true)
672
+ // The scheduler re-replays the same missed fire on a later boot (fresh
673
+ // messageId/ts) while the first is still un-acked → collapses to one.
674
+ expect(s.put('a', replayMsg(1000, 222))).toBe(false)
675
+ expect(s.liveCount()).toBe(1)
676
+ })
677
+
678
+ it('distinct missed fires are each retained (no cross-fire collapse)', () => {
679
+ const fs = fakeFs()
680
+ const s = createInboundSpool({ path: PATH, fs, log: () => {} })
681
+ expect(s.put('a', replayMsg(1000, 111))).toBe(true)
682
+ expect(s.put('a', replayMsg(2000, 222))).toBe(true)
683
+ expect(s.liveCount()).toBe(2)
684
+ })
685
+ })