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,162 @@
1
+ ---
2
+ name: mental-model-curator
3
+ description: >
4
+ Review the agent's OWN Hindsight memory bank with the most capable model and
5
+ PROPOSE well-formed mental models ("knowledge models") to the operator via the
6
+ approve/deny proposal card, instead of blindly creating them. Use to curate,
7
+ seed, or audit the standing models the agent maintains over its specialty.
8
+ Triggers on phrasings like: "curate mental models", "propose knowledge models",
9
+ "review my memory bank", "what standing models should I have", "bootstrap
10
+ mental models", "suggest mental models from my bank", "audit my mental models",
11
+ "what recurring questions do I keep re-deriving", and typos like "propose
12
+ knowlege models". Do NOT use for storing a single fact/preference/decision —
13
+ that is retain, not a mental model. Do NOT use for identity or "who is the
14
+ user" — that lives in profile banks, so never propose an identity model here
15
+ (the operator's review of the card, not any code guard, is the only gate — so
16
+ it's on you to honor).
17
+ Even when the operator directly asks you to CREATE one specific named model
18
+ whose shape you know, PROPOSE it through the approve/deny card — never call
19
+ create_mental_model directly (it is not pre-approved; the propose card is the
20
+ only sanctioned write path, Fix 1.2 / #2903).
21
+ allowed-tools: mcp__hindsight__list_banks mcp__hindsight__get_bank_stats mcp__hindsight__list_mental_models mcp__hindsight__get_mental_model mcp__hindsight__reflect mcp__hindsight__recall mcp__switchroom-telegram__mental_model_propose
22
+ ---
23
+
24
+ # mental-model-curator — Propose standing knowledge models from your own bank
25
+
26
+ A **mental model** in Hindsight is a pre-computed semantic summary backed by
27
+ reflection over the memory bank — a standing answer to a recurring DOMAIN
28
+ question the agent keeps re-deriving (e.g. a coach's "current training plan,
29
+ recent sessions, and open adjustments"). This skill's job is to use your most
30
+ capable model to survey your OWN bank and PROPOSE a few well-earned models to
31
+ the operator through the approve/deny card — never to bulk-create noise.
32
+
33
+ You cannot self-approve. Every proposal renders a card the operator taps. Your
34
+ job is to make each proposal so obviously right that a glance is enough.
35
+
36
+ ## Why propose instead of create
37
+
38
+ You do NOT have direct `create_mental_model` access — it is deliberately not
39
+ pre-approved (Fix 1.2 / #2903). Every mental-model write routes through
40
+ `mcp__switchroom-telegram__mental_model_propose`, because a
41
+ standing model is a persistent, always-in-context artifact with real cost
42
+ (recall/reflect spend, and — if `refresh_after_consolidation` is on — invisible
43
+ post-consolidation spend). A human should decide which of those the agent runs.
44
+ Bulk self-creation is how banks fill with overlapping, stale, or wrong-fact
45
+ models. Propose the few that are earned; let the operator ratify.
46
+
47
+ ## Modes
48
+
49
+ - **Propose mode (default):** run the full workflow and fire proposal cards, one
50
+ at a time, up to the rate limit.
51
+ - **Dry-run / preview mode:** when the user says "preview", "dry run", "just
52
+ show me the candidates", "what would you propose", or you're testing against an
53
+ unfamiliar bank — run steps 1–4, then OUTPUT the ranked candidate list as text
54
+ (name + source_query + one-line reason) and STOP. Do NOT call
55
+ `mental_model_propose` in this mode. This is the safe way to run against any
56
+ bank.
57
+
58
+ ## Workflow
59
+
60
+ ### 1. Identify the bank and check it's worth analyzing
61
+
62
+ Resolve the session's bank (`mcp__hindsight__list_banks`, or the bank bound to
63
+ this agent's session). Then pull `mcp__hindsight__get_bank_stats`.
64
+
65
+ If the bank is **empty or thin**, STOP and report "not enough content to
66
+ synthesize models yet — the bank needs more accumulated memories before standing
67
+ models are meaningful." Synthesizing over an empty or thin bank produces
68
+ confident noise: models with no grounding that read as authoritative. A handful
69
+ of memories is not a domain. Do not propose against a thin bank, even in dry-run.
70
+
71
+ ### 2. Read what already exists (dedupe)
72
+
73
+ Call `mcp__hindsight__list_mental_models`, and `get_mental_model` on each to read
74
+ its `source_query`. Build a dedupe set of the questions already covered.
75
+
76
+ The propose flow hard-rejects an EXACT-name duplicate before a card posts, but
77
+ that is not enough — a near-duplicate under a fresh name is still waste. You must
78
+ **semantically** dedupe: if a candidate's source_query asks substantially the
79
+ same thing as an existing model (even reworded), drop it. Only propose genuinely
80
+ new coverage.
81
+
82
+ ### 3. Survey recurring themes
83
+
84
+ Use `mcp__hindsight__reflect` and `mcp__hindsight__recall` to find the DOMAIN
85
+ questions this agent keeps re-deriving — the standing state of its specialty.
86
+ Good prompts: "What questions do I repeatedly answer from scratch?", "What are
87
+ the recurring themes in this bank?", "What standing state would I want summarized
88
+ before every relevant turn?" Cluster the answers into candidate standing
89
+ questions. Each cluster must be backed by memories the bank ACTUALLY holds — if
90
+ you can't point to the underlying content, it's not a candidate.
91
+
92
+ ### 4. Frame each candidate correctly
93
+
94
+ - **`source_query` must be a DOMAIN question**, never an identity / "who is the
95
+ user" question. Identity lives in dedicated profile banks; never propose an
96
+ identity-based model — the operator, not the platform, is the only gate, so
97
+ this is on you to honor (one previously caused a wrong-fact contradiction bug).
98
+ If a candidate is really "facts about the user", discard it.
99
+ - **`refresh_after_consolidation` defaults OFF.** Only set it true when the model
100
+ genuinely tracks fast-moving state that must be current the moment memory
101
+ consolidates. It adds invisible post-consolidation spend and timeout risk, so
102
+ the default answer is off.
103
+ - **Keep models tight.** recall/reflect tiers cap around ~1024 tokens; a model
104
+ that tries to summarize everything summarizes nothing. Set a modest `max_tokens`
105
+ if the default would overrun. Narrow, answerable source_queries beat broad ones.
106
+ - **Few models per bank.** Each must be earned by content the bank holds. Quality
107
+ over quantity — a bank with three sharp models beats one with ten vague ones.
108
+
109
+ ### 5. Propose within limits
110
+
111
+ Rank the surviving candidates by how much re-derivation they save and how well
112
+ the bank supports them. Propose only the **top few**.
113
+
114
+ **HARD LIMIT: 5 proposal cards per hour per agent (sliding window).** Batch and
115
+ prioritize — never fire one card per cluster. If you have more than 5 worthwhile
116
+ candidates, propose the best 5 and mention the rest in text for a later pass.
117
+
118
+ Propose **one at a time**:
119
+
120
+ ```
121
+ mcp__switchroom-telegram__mental_model_propose(
122
+ chat_id=<the current chat_id>,
123
+ name=<slug>,
124
+ source_query=<the domain question>,
125
+ reason=<CRISP one line — the operator sees this on the card and denies vague ones>,
126
+ ... # refresh_after_consolidation / max_tokens only when justified above
127
+ )
128
+ ```
129
+
130
+ The `reason` is load-bearing: it's the one line the operator reads before
131
+ tapping. "Tracks the athlete's current plan + open adjustments so I stop
132
+ rebuilding it each session" earns a tap; "useful model" gets denied.
133
+
134
+ After firing a card, **END THE TURN CLEANLY.** The flow resumes on its own via a
135
+ synthetic inbound (`mental_model_proposal_applied` / `mental_model_proposal_denied`)
136
+ when the operator taps. Do not loop, do not poll, do not fire the next card in the
137
+ same turn. You cannot self-approve — that's by design.
138
+
139
+ ### 6. Scheduled-sweep note (future extension)
140
+
141
+ Unattended cron proposing is **NOT supported today.** `mental_model_propose`
142
+ needs a live turn with an operator present to tap the card — firing cards into an
143
+ empty topic at 3am is useless and noisy. If invoked from a scheduled sweep,
144
+ **STAGE** the ranked candidates (retain them, or hold them) and surface them on
145
+ the next interactive turn instead of firing cards unattended. Treat live-turn,
146
+ operator-present proposing as the only supported path for now.
147
+
148
+ ## Anti-patterns
149
+
150
+ - ❌ Proposing against an empty or thin bank — synthesizes to noise.
151
+ - ❌ An identity / "who is the user" source_query — forbidden; profile banks own that.
152
+ - ❌ Ten near-duplicate models under different names — semantically dedupe first.
153
+ - ❌ Firing a card per cluster — respect the 5/hour limit; propose the top few.
154
+ - ❌ `refresh_after_consolidation: true` by default — it's an invisible cost; leave it off unless the model tracks fast-moving state.
155
+ - ❌ A vague `reason` — the operator denies it and you've burned a slot.
156
+ - ❌ Looping/polling after a proposal — end the turn; the resume inbound wakes you.
157
+
158
+ ## Output
159
+
160
+ In propose mode: a short line naming what you proposed and that the operator's
161
+ tap will resume the flow. In dry-run mode: the ranked candidate list (name +
162
+ source_query + reason) and nothing fired.
@@ -447,30 +447,27 @@ function pctCells(
447
447
  }
448
448
 
449
449
  /**
450
- * The Status cell: for a blocked account, when the binding window comes back
451
- * (`back <when>`); for a healthy/throttling account, when the soonest window
452
- * refills (`refills <when>`). `<when>` is the tz-aware absolute time (date shown
453
- * only when not today) plus the concise relative hint in parens.
450
+ * The per-window reset cell (`5h resets` / `7d resets`): the tz-aware absolute
451
+ * reset time for THAT specific window (date shown only when not today) plus the
452
+ * concise relative hint in parens e.g. `1:20 PM (in 2h)`.
453
+ *
454
+ * Degrades in lockstep with `pctCells` so the percentage cell and its reset cell
455
+ * always agree: no quota → probe-failed message (or `—`); thin probe → `quota
456
+ * unknown`; missing reset on an otherwise-present window → `—`.
454
457
  */
455
- function statusCell(snap: AccountSnapshot, now: Date, tz: string): string {
458
+ function windowResetCell(
459
+ snap: AccountSnapshot,
460
+ now: Date,
461
+ tz: string,
462
+ win: '5h' | '7d',
463
+ ): string {
456
464
  if (!snap.quota) {
457
- return snap.quotaError ? `probe failed (${snap.quotaError})` : 'probe failed';
465
+ return snap.quotaError ? `probe failed (${snap.quotaError})` : '';
458
466
  }
459
467
  if (isProbeThin(snap.quota)) return 'quota unknown';
460
- const q = snap.quota;
461
- const health = classifyHealth(snap, now);
462
- if (health === 'blocked') {
463
- const win = bindingWindow(q);
464
- const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
465
- if (!reset) return 'back — (reset unknown)';
466
- return `back ${formatStatusTime(reset, now, tz)} (in ${formatRelative(reset, now)})`;
467
- }
468
- // healthy / throttling — show the soonest refill across both windows.
469
- const fiveIn = q.fiveHourResetAt ? q.fiveHourResetAt.getTime() - now.getTime() : Infinity;
470
- const sevenIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
471
- const soonest = fiveIn <= sevenIn ? q.fiveHourResetAt : q.sevenDayResetAt;
472
- if (!soonest) return 'refills —';
473
- return `refills ${formatStatusTime(soonest, now, tz)} (in ${formatRelative(soonest, now)})`;
468
+ const reset = win === '5h' ? snap.quota.fiveHourResetAt : snap.quota.sevenDayResetAt;
469
+ if (!reset) return '—';
470
+ return `${formatStatusTime(reset, now, tz)} (in ${formatRelative(reset, now)})`;
474
471
  }
475
472
 
476
473
  export function renderAuthSnapshotFormat2(
@@ -495,8 +492,8 @@ export function renderAuthSnapshotFormat2(
495
492
 
496
493
  if (ordered.length > 0) {
497
494
  lines.push('');
498
- lines.push('| State | Account | 5h | 7d | Status |');
499
- lines.push('| --- | --- | --- | --- | --- |');
495
+ lines.push('| State | Account | 5h | 5h resets | 7d | 7d resets |');
496
+ lines.push('| --- | --- | --- | --- | --- | --- |');
500
497
  for (const s of ordered) {
501
498
  const emoji = HEALTH_EMOJI[classifyHealth(s, now)];
502
499
  // Account cell: FULL email, never truncated; active gets a (active) suffix.
@@ -512,9 +509,10 @@ export function renderAuthSnapshotFormat2(
512
509
  // tableCell wrap around the finished span.
513
510
  const accountCell = `\`${codeSpanSafe(s.isActive ? `${label} (active)` : label)}\``;
514
511
  const { five, seven } = pctCells(s, now);
515
- const status = statusCell(s, now, tz);
512
+ const fiveReset = windowResetCell(s, now, tz, '5h');
513
+ const sevenReset = windowResetCell(s, now, tz, '7d');
516
514
  lines.push(
517
- `| ${emoji} | ${tableCell(accountCell)} | ${five} | ${seven} | ${tableCell(status)} |`,
515
+ `| ${emoji} | ${tableCell(accountCell)} | ${five} | ${tableCell(fiveReset)} | ${seven} | ${tableCell(sevenReset)} |`,
518
516
  );
519
517
  }
520
518
  }
@@ -30,6 +30,7 @@ import { createIpcClient, type IpcClientHandle } from './ipc-client.js'
30
30
  import { buildEffectiveToolSchemas, LINEAR_ENV } from './tool-filter.js'
31
31
  import type { InboundMessage, PermissionEvent, StatusEvent } from '../gateway/ipc-protocol.js'
32
32
  import { matchesAllowRule } from '../permission-rule.js'
33
+ import { createOutstandingPermissionLedger } from './permission-ledger.js'
33
34
 
34
35
  installPluginLogger()
35
36
 
@@ -417,6 +418,24 @@ const TOOL_SCHEMAS = [
417
418
  required: ['chat_id', 'key'],
418
419
  },
419
420
  },
421
+ {
422
+ name: 'mental_model_propose',
423
+ description:
424
+ "Propose a Hindsight MENTAL MODEL for the operator to approve (agent-proposes → human-approves, hindsight Phase 5). Use this when — over real work — you notice a recurring, domain-specific question worth maintaining a standing, semantically-refreshed answer to from YOUR bank (e.g. a coach's `training-plan-state`, a lawyer's `open-matters`). You may PROPOSE but can NEVER self-approve: this renders a Telegram [Approve]/[Deny] card to the operator. On Approve the model is DECLARED — appended to your `memory.mental_models[]` in switchroom.yaml via the operator-approved config-edit path — and ensured in your bank (it then refreshes from your bank content). On Deny nothing is written. This is NOT for identity/'who is the user' (dedicated profile banks own that) and NOT a substitute for `retain` (store a fact) or `create_mental_model` where you already have direct Hindsight tools — it is the leashed, human-gated way to add a DURABLE declared model to your config. After firing this tool, END YOUR TURN cleanly — a fresh inbound arrives (`<channel source=\"mental_model_proposal_applied\">` / `mental_model_proposal_denied`) once the operator decides. Do NOT propose a model whose name is already declared (it is rejected), and do NOT spam (one card per proposal; the operator sees every one).",
425
+ inputSchema: {
426
+ type: 'object',
427
+ properties: {
428
+ chat_id: { type: 'string', description: 'Chat to render the approval card in (use the chat_id of the user message that triggered the workflow).' },
429
+ name: { type: 'string', description: 'Stable model name — the idempotent-ensure identity key (lowercase kebab/snake, e.g. `training-plan-state`). Must be UNIQUE among your already-declared models or the proposal is rejected.' },
430
+ source_query: { type: 'string', description: 'The reflection query the model answers, semantically refreshed from your bank content (e.g. "What is the athlete\'s current training plan, recent sessions, and open adjustments?"). Frame it as a DOMAIN question, never an identity question.' },
431
+ reason: { type: 'string', description: 'REQUIRED in practice — one-line rationale rendered on the card (e.g. "I keep re-deriving the plan state every session; a standing model would save the lookup"). Omitting it renders "why: not provided" and the operator will usually Deny.' },
432
+ refresh_after_consolidation: { type: 'boolean', description: 'Refresh this model after each consolidation. Defaults OFF — refresh adds bounded background model-spend + timeout risk (RFC Phase 5). Only set true when the model genuinely needs to track fast-moving state.' },
433
+ max_tokens: { type: 'number', description: 'Optional cap on the synthesized model\'s token size.' },
434
+ message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message if not specified.' },
435
+ },
436
+ required: ['chat_id', 'name', 'source_query'],
437
+ },
438
+ },
420
439
  {
421
440
  name: 'linear_agent_activity',
422
441
  description:
@@ -582,6 +601,40 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
582
601
  // added by either is honoured by all.
583
602
  const sessionAllowRules = new Set<string>()
584
603
 
604
+ // #2861: outstanding permission-request ledger. Every permission_request from
605
+ // claude is recorded here and re-sent on each IPC (re)connect, so a request
606
+ // that arrives while the gateway is down (or that the gateway lost across a
607
+ // restart) is redelivered instead of dropped. Deleted in onPermission when a
608
+ // verdict is delivered. Kill switch: SWITCHROOM_PERMISSION_REARM=0 reverts to
609
+ // the legacy drop-when-disconnected behavior.
610
+ const outstandingPermissions = createOutstandingPermissionLedger()
611
+ const permissionRearmEnabled = process.env.SWITCHROOM_PERMISSION_REARM !== '0'
612
+
613
+ /**
614
+ * Re-send every outstanding permission_request to the gateway. Called on each
615
+ * IPC (re)connect via the ipc-client onConnect hook. The gateway dedupes /
616
+ * re-arms them idempotently (gateway/permission-rearm.ts), so re-sending on
617
+ * every reconnect is safe. Never answers a card — re-transmits the question.
618
+ */
619
+ function flushOutstandingPermissionRequests(): void {
620
+ if (!permissionRearmEnabled) return
621
+ if (!ipc || !ipc.isConnected()) return
622
+ const pending = outstandingPermissions.all()
623
+ if (pending.length === 0) return
624
+ process.stderr.write(
625
+ `telegram bridge: re-sending ${pending.length} outstanding permission request(s) on gateway (re)connect\n`,
626
+ )
627
+ for (const p of pending) {
628
+ ipc.sendPermissionRequest({
629
+ type: 'permission_request',
630
+ requestId: p.request_id,
631
+ toolName: p.tool_name,
632
+ description: p.description,
633
+ inputPreview: p.input_preview,
634
+ })
635
+ }
636
+ }
637
+
585
638
  mcp.setNotificationHandler(
586
639
  z.object({
587
640
  method: z.literal('notifications/claude/channel/permission_request'),
@@ -615,8 +668,29 @@ mcp.setNotificationHandler(
615
668
  return
616
669
  }
617
670
  }
671
+ // #2861: record the request in the outstanding ledger BEFORE the connect
672
+ // check so it survives a disconnected gateway and is re-sent on reconnect.
673
+ // It's deleted in onPermission when the verdict is delivered.
674
+ if (permissionRearmEnabled) {
675
+ outstandingPermissions.add({
676
+ request_id: params.request_id,
677
+ tool_name: params.tool_name,
678
+ description: params.description,
679
+ input_preview: params.input_preview,
680
+ })
681
+ }
618
682
  if (!ipc || !ipc.isConnected()) {
619
- process.stderr.write('telegram bridge: permission_request received but not connected to gateway\n')
683
+ // BUFFER, don't drop (#2861 R2). The request is in the ledger; the
684
+ // onConnect flush re-sends it once the gateway is reachable. Falls back
685
+ // to the legacy drop only when re-arm is killed.
686
+ if (permissionRearmEnabled) {
687
+ process.stderr.write(
688
+ `telegram bridge: permission_request buffered (gateway offline), will re-send on reconnect ` +
689
+ `request_id=${params.request_id}\n`,
690
+ )
691
+ } else {
692
+ process.stderr.write('telegram bridge: permission_request received but not connected to gateway\n')
693
+ }
620
694
  return
621
695
  }
622
696
  ipc.sendPermissionRequest({
@@ -658,6 +732,9 @@ function onPermission(msg: PermissionEvent): void {
658
732
  if (msg.rule) {
659
733
  sessionAllowRules.add(msg.rule)
660
734
  }
735
+ // #2861: verdict delivered → drop the request from the outstanding ledger so
736
+ // a later reconnect doesn't re-send an already-resolved approval.
737
+ outstandingPermissions.delete(msg.requestId)
661
738
  mcp.notification({
662
739
  method: 'notifications/claude/channel/permission',
663
740
  params: {
@@ -836,6 +913,8 @@ async function main(): Promise<void> {
836
913
  onInbound,
837
914
  onPermission,
838
915
  onStatus,
916
+ // #2861: re-send outstanding permission requests on every (re)connect.
917
+ onConnect: flushOutstandingPermissionRequests,
839
918
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}\n`),
840
919
  // #2307 Tier-1: the cron-session bridge shares the agent's STATE_DIR
841
920
  // (access.json / history / gateway.sock) but writes its liveness file to a
@@ -48,6 +48,14 @@ export interface IpcClientOptions {
48
48
  onInbound: (msg: InboundMessage) => void;
49
49
  onPermission: (msg: PermissionEvent) => void;
50
50
  onStatus: (msg: StatusEvent) => void;
51
+ /**
52
+ * Called every time the socket (re)connects and has registered — i.e. on
53
+ * the initial connect AND on each background reconnect. The bridge uses
54
+ * this to flush its outstanding permission-request ledger so approvals
55
+ * that arrived while the gateway was down are re-sent (#2861). Best-effort:
56
+ * a throw here is logged, never propagated into the socket open path.
57
+ */
58
+ onConnect?: () => void;
51
59
  log?: (msg: string) => void;
52
60
  reconnectDelayMs?: number;
53
61
  maxReconnectDelayMs?: number;
@@ -93,6 +101,7 @@ export function createIpcClient(options: IpcClientOptions): Promise<IpcClientHan
93
101
  onInbound,
94
102
  onPermission,
95
103
  onStatus,
104
+ onConnect,
96
105
  log = () => {},
97
106
  reconnectDelayMs = 2000,
98
107
  maxReconnectDelayMs = 30000,
@@ -237,6 +246,16 @@ export function createIpcClient(options: IpcClientOptions): Promise<IpcClientHan
237
246
  sendRegister();
238
247
  startHeartbeat();
239
248
  log(`connected to ${socketPath}`);
249
+ // Fire the (re)connect hook AFTER register + heartbeat so the
250
+ // bridge's ledger flush writes onto a live, registered socket.
251
+ // Best-effort: never let a callback throw abort the open path.
252
+ if (onConnect) {
253
+ try {
254
+ onConnect();
255
+ } catch (err) {
256
+ log(`onConnect hook threw: ${err}`);
257
+ }
258
+ }
240
259
  resolve();
241
260
  },
242
261
  data(sock, data) {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Bridge-side outstanding permission-request ledger (#2861 / umbrella #2859).
3
+ *
4
+ * Problem it closes (R2): the bridge's `permission_request` handler used to
5
+ * DROP the request when the gateway IPC was down
6
+ * (`if (!ipc || !ipc.isConnected()) { log; return }`). claude stays suspended
7
+ * inside the MCP permission call, no card is ever posted, the turn wedges
8
+ * forever. There was no record of the ask to re-send once the gateway came
9
+ * back.
10
+ *
11
+ * Fix: every `permission_request` notification from claude is recorded here,
12
+ * keyed by request_id. When the IPC (re)connects, the bridge re-sends every
13
+ * outstanding request (the gateway dedupes / re-arms them idempotently — see
14
+ * `gateway/permission-rearm.ts`). An entry is deleted the moment a verdict is
15
+ * delivered back to claude (`onPermission`), so a resolved request is never
16
+ * re-sent.
17
+ *
18
+ * This never answers a card — it only re-transmits the QUESTION
19
+ * (no-self-escalation invariant). Pure in-memory bookkeeping: no model
20
+ * callsite, no I/O.
21
+ */
22
+
23
+ /** The claude-side `permission_request` params the bridge forwards. */
24
+ export interface OutstandingPermissionParams {
25
+ request_id: string
26
+ tool_name: string
27
+ description: string
28
+ input_preview: string
29
+ }
30
+
31
+ export interface OutstandingPermissionLedger {
32
+ /** Record a request. Idempotent on request_id (latest params win). */
33
+ add(params: OutstandingPermissionParams): void
34
+ /** Remove a request once its verdict has been delivered. */
35
+ delete(requestId: string): void
36
+ has(requestId: string): boolean
37
+ /** Snapshot of all outstanding requests (insertion order). */
38
+ all(): OutstandingPermissionParams[]
39
+ readonly size: number
40
+ }
41
+
42
+ export function createOutstandingPermissionLedger(): OutstandingPermissionLedger {
43
+ const map = new Map<string, OutstandingPermissionParams>()
44
+ return {
45
+ add(params) {
46
+ map.set(params.request_id, params)
47
+ },
48
+ delete(requestId) {
49
+ map.delete(requestId)
50
+ },
51
+ has(requestId) {
52
+ return map.has(requestId)
53
+ },
54
+ all() {
55
+ return [...map.values()]
56
+ },
57
+ get size() {
58
+ return map.size
59
+ },
60
+ }
61
+ }