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
@@ -116,3 +116,29 @@ export function decideInboundDelivery(
116
116
  if (input.turnInFlight) return 'buffer-until-idle'
117
117
  return 'deliver'
118
118
  }
119
+
120
+ /**
121
+ * #2917 — atomic check-and-reserve for per-chat outbound FIFO.
122
+ *
123
+ * `decideInboundDelivery` decides deliver-vs-buffer, but on the concurrent
124
+ * `handleInbound` path that decision and the busy-mark that records "a turn is
125
+ * now in flight for this chat" are separated by an `await` (attachment
126
+ * download, composer-clear). Two same-chat inbounds can therefore each read
127
+ * "idle" during the other's async lead-in and both deliver — the replies then
128
+ * come back reordered. This helper couples the decision with a `reserve` flag:
129
+ * a FRESH-TURN deliver must reserve the chat's busy key SYNCHRONOUSLY (before
130
+ * any await) so the next same-chat inbound sees it and buffers behind it.
131
+ *
132
+ * `reserve` is true ONLY for a fresh-turn deliver. Steering / interrupt
133
+ * inbounds deliver mid-turn WITHOUT starting a turn, so they must not reserve
134
+ * (reserving would wedge the running turn's key). A buffered decision never
135
+ * reserves.
136
+ */
137
+ export function reserveInboundDelivery(
138
+ input: InboundDeliveryGateInput,
139
+ ): { decision: InboundDeliveryDecision; reserve: boolean } {
140
+ const decision = decideInboundDelivery(input)
141
+ const reserve =
142
+ decision === 'deliver' && !input.isSteering && input.isInterrupt !== true
143
+ return { decision, reserve }
144
+ }
@@ -94,6 +94,28 @@ export function spoolId(msg: InboundMessage): string {
94
94
  ) {
95
95
  return `s:resume:${msg.meta.resume_turn_key}`
96
96
  }
97
+ // Cron BOOT-REPLAY (#2793 part B): a scheduled fire that the boot
98
+ // replay re-injects because it was missed across a restart. Keyed on
99
+ // the minute-aligned fire it is replaying (`replay_fire_ms`) plus the
100
+ // schedule index, NOT the fresh synthetic `messageId` (=ts, minted per
101
+ // replay attempt) — so a re-replay of the SAME missed fire across a
102
+ // subsequent gateway restart collapses to ONE live spool entry instead
103
+ // of stacking a fresh one each boot. This is what makes routing cron
104
+ // replays through the durable spool at-least-once WITH dedup, mirroring
105
+ // the resume/handback stable-id idiom above. Live (non-replay) cron
106
+ // ticks never set `replay_fire_ms`, so they are unaffected and keep
107
+ // their per-fire identity.
108
+ if (
109
+ msg.meta?.source === 'cron' &&
110
+ typeof msg.meta?.replay_fire_ms === 'string' &&
111
+ msg.meta.replay_fire_ms.length > 0
112
+ ) {
113
+ const idx =
114
+ typeof msg.meta?.schedule_index === 'string' && msg.meta.schedule_index.length > 0
115
+ ? msg.meta.schedule_index
116
+ : '-'
117
+ return `s:cron-replay:${msg.chatId}:${idx}:${msg.meta.replay_fire_ms}`
118
+ }
97
119
  if (typeof msg.messageId === 'number' && msg.messageId > 0) {
98
120
  return `m:${msg.chatId}:${msg.messageId}`
99
121
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pure renderer for the agent-initiated mental-model PROPOSAL approval card
3
+ * (hindsight Phase 5, stacked on #2874).
4
+ *
5
+ * Mirrors `vault-request-access-card.ts` exactly — same rich-message (GFM
6
+ * markdown) escaping discipline:
7
+ * - Non-code-span interpolations (`**agent**`, `_reason_`, `_source_query_`)
8
+ * are markdown-escaped via `escapeHtmlForTg` so a stray `*`/`_`/`[` can't
9
+ * break the surrounding emphasis.
10
+ * - Code-span interpolations (`name`) are LITERAL inside the span, so they
11
+ * must NOT be markdown-escaped; only the backtick is defused via
12
+ * `codeSpanSafe`.
13
+ *
14
+ * The card asks the OPERATOR to approve turning a proposed model into a
15
+ * first-class DECLARED model (`memory.mental_models[]`). The agent can only
16
+ * PROPOSE — the [Approve]/[Deny] tap is operator-gated in the gateway, exactly
17
+ * like the vault flow, so an agent can never self-approve.
18
+ */
19
+
20
+ import { escapeHtmlForTg } from "../shared/bot-runtime.js";
21
+ import { codeSpanSafe } from "./approval-card.js";
22
+ import { hardenCardBreaks } from "../format.js";
23
+
24
+ /** Minimal shape the card needs — a subset of the pending proposal. */
25
+ export interface MentalModelProposeCardInput {
26
+ agent: string;
27
+ name: string;
28
+ source_query: string;
29
+ reason?: string;
30
+ refresh_after_consolidation?: boolean;
31
+ max_tokens?: number;
32
+ }
33
+
34
+ export function renderMentalModelProposeCard(
35
+ req: MentalModelProposeCardInput,
36
+ ): string {
37
+ const lines: string[] = [];
38
+ lines.push(`🧠 **${escapeHtmlForTg(req.agent)}** proposes a mental model`);
39
+ // Code-span content is literal — defuse only the backtick, never
40
+ // markdown-escape (see file header).
41
+ lines.push(`name: \`${codeSpanSafe(req.name)}\``);
42
+ lines.push(`source query: _${escapeHtmlForTg(req.source_query)}_`);
43
+ const refreshLabel = req.refresh_after_consolidation ? "on" : "off";
44
+ lines.push(`refresh after consolidation: \`${codeSpanSafe(refreshLabel)}\``);
45
+ // Always show max_tokens so the operator approves EXACTLY what's applied.
46
+ // When the proposal omits it, the declared model inherits the bank default —
47
+ // render that explicitly rather than hiding the field.
48
+ const maxTokensLabel =
49
+ typeof req.max_tokens === "number" ? String(req.max_tokens) : "default";
50
+ lines.push(`max tokens: \`${codeSpanSafe(maxTokensLabel)}\``);
51
+ // Always render the why-line, even when omitted — a missing rationale is
52
+ // then visibly an agent-side failure, not a card-template choice (mirrors
53
+ // the vault card's #1790 behaviour).
54
+ if (req.reason && req.reason.length > 0) {
55
+ lines.push(`why: _${escapeHtmlForTg(req.reason)}_`);
56
+ } else {
57
+ lines.push(`why: _not provided_`);
58
+ }
59
+ lines.push("");
60
+ lines.push(
61
+ `_Tap Approve to DECLARE this model — it is appended to ${escapeHtmlForTg(req.agent)}'s ` +
62
+ `\`memory.mental_models[]\` (operator-approved config edit) and ensured in the bank. ` +
63
+ `Tap Deny to drop it — nothing is written._`,
64
+ );
65
+ // hardenCardBreaks: the labelled field lines would soft-collapse into one
66
+ // blob under the GFM rich renderer; this card is sent direct via
67
+ // richMessage, bypassing the switchroomReply chokepoint.
68
+ return hardenCardBreaks(lines.join("\n"));
69
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Pure (no-network) helpers for the agent-proposes → human-approves
3
+ * mental-model curation flow (hindsight Phase 5, stacked on #2874).
4
+ *
5
+ * #2874 shipped the operator-DECLARED half: a per-agent
6
+ * `memory.mental_models[]` config that `ensureDeclaredMentalModels`
7
+ * materialises at scaffold/reconcile. This module is the persistence
8
+ * primitive for the SECOND half: when an agent PROPOSES a model and the
9
+ * operator taps Approve, the proposed model must become a first-class
10
+ * DECLARED model — i.e. it must land in `agents.<agent>.memory.mental_models[]`
11
+ * in switchroom.yaml so the exact same #2874 ensure/reconcile path
12
+ * consumes it. Nothing bespoke: the approved proposal is just a new
13
+ * declared model.
14
+ *
15
+ * The persist reuses the proven `config_propose_edit` apply+reconcile
16
+ * machinery (hostd is the sole config writer — the leash). So this module
17
+ * only has to produce a git-apply-compatible unified diff that appends the
18
+ * declared model to the agent's block. We build `after` with the
19
+ * structure-preserving `yaml` document API and diff it against the live
20
+ * `before` bytes with the same `git diff --no-index` family hostd applies
21
+ * with (see src/web/config-diff.ts) — so the patch round-trips.
22
+ *
23
+ * Deliberately no I/O here beyond the git-diff subprocess in
24
+ * `generateUnifiedDiff`; callers read the raw config bytes and feed them in.
25
+ */
26
+
27
+ import { parseDocument } from "yaml";
28
+ import { generateUnifiedDiff } from "../../src/web/config-diff.js";
29
+
30
+ /**
31
+ * A proposed mental model, in the same snake_case shape the
32
+ * `memory.mental_models[]` schema (#2874) accepts. `name` + `source_query`
33
+ * are required; the knobs are opt-in and only serialised when set (a minimal
34
+ * declaration is the schema-clean default).
35
+ */
36
+ export interface MentalModelProposeSpec {
37
+ name: string;
38
+ source_query: string;
39
+ refresh_after_consolidation?: boolean;
40
+ max_tokens?: number;
41
+ }
42
+
43
+ export type BuildDiffResult =
44
+ | { ok: true; diff: string; after: string }
45
+ | { ok: false; error: "agent-not-found" | "duplicate" | "no-change" | "parse-error"; detail: string };
46
+
47
+ /**
48
+ * Read the NAMES of the models already DECLARED in
49
+ * `agents.<agentName>.memory.mental_models[]` of raw config text. Used for the
50
+ * duplicate-name guard so a proposal for an already-declared model is rejected
51
+ * BEFORE a card is ever posted. Returns [] when the agent has no declared
52
+ * models (or the block/keys are absent). Never throws — a parse failure yields
53
+ * [] so the caller degrades to the (also-guarded) build step.
54
+ */
55
+ export function readDeclaredMentalModelNames(
56
+ configText: string,
57
+ agentName: string,
58
+ ): string[] {
59
+ if (!configText || !agentName) return [];
60
+ let js: unknown;
61
+ try {
62
+ js = parseDocument(configText, { merge: false, strict: false }).toJS();
63
+ } catch {
64
+ return [];
65
+ }
66
+ const models = navigateMentalModels(js, agentName);
67
+ if (!Array.isArray(models)) return [];
68
+ return models
69
+ .map((m) => (m && typeof m === "object" ? (m as { name?: unknown }).name : undefined))
70
+ .filter((n): n is string => typeof n === "string");
71
+ }
72
+
73
+ function navigateMentalModels(js: unknown, agentName: string): unknown {
74
+ if (!js || typeof js !== "object") return undefined;
75
+ const agents = (js as Record<string, unknown>).agents;
76
+ if (!agents || typeof agents !== "object") return undefined;
77
+ const agent = (agents as Record<string, unknown>)[agentName];
78
+ if (!agent || typeof agent !== "object") return undefined;
79
+ const memory = (agent as Record<string, unknown>).memory;
80
+ if (!memory || typeof memory !== "object") return undefined;
81
+ return (memory as Record<string, unknown>).mental_models;
82
+ }
83
+
84
+ /**
85
+ * Build a git-apply-compatible unified diff that APPENDS `spec` to the target
86
+ * agent's `memory.mental_models[]` in raw `configText`. Creates the
87
+ * `memory:` / `mental_models:` keys if absent. Returns a structured error
88
+ * (never throws) when:
89
+ * - the agent block is absent (`agent-not-found`) — we never invent one;
90
+ * - a model with the same name is already declared (`duplicate`) — the
91
+ * schema's idempotent-ensure key must stay unique;
92
+ * - the edit is a no-op (`no-change`);
93
+ * - the config doesn't parse (`parse-error`).
94
+ *
95
+ * Only fields that are set on `spec` are serialised, matching the minimal,
96
+ * schema-clean create payload #2874 emits.
97
+ */
98
+ export function buildMentalModelAppendDiff(args: {
99
+ configText: string;
100
+ agentName: string;
101
+ spec: MentalModelProposeSpec;
102
+ /** Injectable git binary for tests (defaults to "git"). */
103
+ gitBin?: string;
104
+ }): BuildDiffResult {
105
+ const { configText, agentName, spec } = args;
106
+
107
+ let doc;
108
+ try {
109
+ doc = parseDocument(configText, { merge: false, strict: false });
110
+ } catch (e) {
111
+ return { ok: false, error: "parse-error", detail: (e as Error).message };
112
+ }
113
+ if (doc.errors && doc.errors.length > 0) {
114
+ return { ok: false, error: "parse-error", detail: doc.errors[0]!.message };
115
+ }
116
+
117
+ // The agent block must already exist — a proposal never scaffolds a new
118
+ // agent, only augments the caller's own declared models.
119
+ if (doc.getIn(["agents", agentName]) === undefined) {
120
+ return {
121
+ ok: false,
122
+ error: "agent-not-found",
123
+ detail: `agents.${agentName} not present in config`,
124
+ };
125
+ }
126
+
127
+ // Duplicate-name guard (defense-in-depth; the executor also checks up front
128
+ // so it never even posts a card for a dupe).
129
+ const existing = readDeclaredMentalModelNames(configText, agentName);
130
+ if (existing.includes(spec.name)) {
131
+ return {
132
+ ok: false,
133
+ error: "duplicate",
134
+ detail: `mental model "${spec.name}" is already declared for ${agentName}`,
135
+ };
136
+ }
137
+
138
+ // Assemble the minimal, schema-clean declaration node.
139
+ const item: Record<string, unknown> = {
140
+ name: spec.name,
141
+ source_query: spec.source_query,
142
+ };
143
+ if (spec.refresh_after_consolidation !== undefined) {
144
+ item.refresh_after_consolidation = spec.refresh_after_consolidation;
145
+ }
146
+ if (spec.max_tokens !== undefined) {
147
+ item.max_tokens = spec.max_tokens;
148
+ }
149
+
150
+ // Append into agents.<agent>.memory.mental_models[], creating the
151
+ // intermediate mapping/sequence if they don't exist yet. `addIn` on an
152
+ // absent path creates the sequence; when it exists we push onto it.
153
+ const path = ["agents", agentName, "memory", "mental_models"];
154
+ const seq = doc.getIn(path);
155
+ if (seq === undefined) {
156
+ doc.setIn(path, [item]);
157
+ } else {
158
+ doc.addIn(path, item);
159
+ }
160
+
161
+ const after = String(doc);
162
+ if (after === configText) {
163
+ return { ok: false, error: "no-change", detail: "append produced no change" };
164
+ }
165
+
166
+ const diff = generateUnifiedDiff(configText, after, "switchroom.yaml", args.gitBin ?? "git");
167
+ if (diff === "") {
168
+ return { ok: false, error: "no-change", detail: "diff was empty" };
169
+ }
170
+ return { ok: true, diff, after };
171
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Pure builders for the synthetic inbounds the gateway injects after the
3
+ * operator taps Approve / Deny on a mental-model PROPOSAL card (hindsight
4
+ * Phase 5, stacked on #2874). Mirrors `vault-grant-inbound-builders.ts`.
5
+ *
6
+ * Extracted from `gateway.ts` so the InboundMessage shape is pinned by tests
7
+ * separately from the config-apply/IPC plumbing. The shape is load-bearing —
8
+ * `meta.source` is what the bridge keys on to render
9
+ * `<channel source="mental_model_proposal_applied">` /
10
+ * `mental_model_proposal_denied` / `mental_model_proposal_failed` blocks, and
11
+ * the `meta.{agent,name,stage_id,operator_id}` fields are the forensic anchor.
12
+ *
13
+ * A regression that drops a meta field or changes the source string would
14
+ * silently break the agent's wake-up: the bridge wouldn't recognise the source
15
+ * and would route as a generic channel event, the model wouldn't know its
16
+ * proposal resolved, and the conversation would drift. Pinning these against
17
+ * fixture tests is cheaper than catching that downstream.
18
+ */
19
+
20
+ import type { InboundMessage } from "./ipc-protocol.js";
21
+
22
+ /** Subset of the pending proposal the builders need. Kept narrow so callers
23
+ * don't have to pass the full pending record. */
24
+ export interface MentalModelProposeInboundContext {
25
+ agent: string;
26
+ /** The proposed model's stable name (identity key). */
27
+ name: string;
28
+ /** Telegram chat id where the approval card lived. Used as the inbound's
29
+ * chatId so the synthesized turn stays associated with the originating
30
+ * conversation. */
31
+ chat_id: string;
32
+ /** Supergroup forum topic (message_thread_id) the agent was working in when
33
+ * it proposed — so the resumed turn's reply lands back in that topic, not
34
+ * General. Undefined for DM / non-topic proposals. */
35
+ threadId?: number;
36
+ }
37
+
38
+ /**
39
+ * Build the synthetic InboundMessage for a successful operator approval whose
40
+ * config edit APPLIED — the proposed model is now a declared model and has
41
+ * been (or will be, on reconcile) ensured in the bank.
42
+ */
43
+ export function buildMentalModelProposeAppliedInbound(opts: {
44
+ ctx: MentalModelProposeInboundContext;
45
+ stageId: string;
46
+ operatorId: string;
47
+ nowMs?: number;
48
+ }): InboundMessage {
49
+ const ts = opts.nowMs ?? Date.now();
50
+ return {
51
+ type: "inbound",
52
+ chatId: opts.ctx.chat_id,
53
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
54
+ messageId: ts, // synthetic — no Telegram message id exists
55
+ user: "hindsight-curator",
56
+ userId: 0,
57
+ ts,
58
+ text:
59
+ `✅ Operator approved your mental-model proposal \`${opts.ctx.name}\`. ` +
60
+ `It is now a DECLARED model in your \`memory.mental_models[]\` and is ` +
61
+ `being ensured in your bank (idempotent). Resume whatever you were doing ` +
62
+ `— the model will refresh from your bank content; no further action is ` +
63
+ `needed to create it.`,
64
+ meta: {
65
+ source: "mental_model_proposal_applied",
66
+ agent: opts.ctx.agent,
67
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
68
+ name: opts.ctx.name,
69
+ stage_id: opts.stageId,
70
+ operator_id: opts.operatorId,
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Build the synthetic InboundMessage for an operator denial. The model was
77
+ * NOT declared and NOTHING was written. Steers the agent to a fallback rather
78
+ * than re-proposing.
79
+ */
80
+ export function buildMentalModelProposeDeniedInbound(opts: {
81
+ ctx: MentalModelProposeInboundContext;
82
+ stageId: string;
83
+ operatorId: string;
84
+ nowMs?: number;
85
+ }): InboundMessage {
86
+ const ts = opts.nowMs ?? Date.now();
87
+ return {
88
+ type: "inbound",
89
+ chatId: opts.ctx.chat_id,
90
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
91
+ messageId: ts,
92
+ user: "hindsight-curator",
93
+ userId: 0,
94
+ ts,
95
+ text:
96
+ `🚫 Operator denied your mental-model proposal \`${opts.ctx.name}\`. ` +
97
+ `NOTHING was written — the model was not declared or created. Carry on ` +
98
+ `with the original task without it. Do NOT re-propose the same model ` +
99
+ `without first asking the user.`,
100
+ meta: {
101
+ source: "mental_model_proposal_denied",
102
+ agent: opts.ctx.agent,
103
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
104
+ name: opts.ctx.name,
105
+ stage_id: opts.stageId,
106
+ operator_id: opts.operatorId,
107
+ },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Build the synthetic InboundMessage for an approval whose config edit FAILED
113
+ * (hostd apply/reconcile error, config-edit disabled, a dupe that raced in,
114
+ * etc.). The operator tapped Approve but the model did NOT land — be honest so
115
+ * the agent doesn't assume it exists.
116
+ */
117
+ export function buildMentalModelProposeFailedInbound(opts: {
118
+ ctx: MentalModelProposeInboundContext;
119
+ stageId: string;
120
+ operatorId: string;
121
+ reason: string;
122
+ nowMs?: number;
123
+ }): InboundMessage {
124
+ const ts = opts.nowMs ?? Date.now();
125
+ return {
126
+ type: "inbound",
127
+ chatId: opts.ctx.chat_id,
128
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
129
+ messageId: ts,
130
+ user: "hindsight-curator",
131
+ userId: 0,
132
+ ts,
133
+ text:
134
+ `⚠️ The operator approved your mental-model proposal \`${opts.ctx.name}\` but ` +
135
+ `persisting it FAILED (${opts.reason}). The model was NOT declared or ` +
136
+ `created — do NOT assume it exists. Carry on with the original task; the ` +
137
+ `operator can retry the declaration by hand if it still matters.`,
138
+ meta: {
139
+ source: "mental_model_proposal_failed",
140
+ agent: opts.ctx.agent,
141
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
142
+ name: opts.ctx.name,
143
+ stage_id: opts.stageId,
144
+ operator_id: opts.operatorId,
145
+ },
146
+ };
147
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The approve/deny ORCHESTRATION for the mental-model proposal flow (hindsight
3
+ * Phase 5, stacked on #2874), factored out of gateway.ts so the invariants are
4
+ * unit-testable with injected deps (no gateway, no Telegram, no hostd, no
5
+ * network):
6
+ *
7
+ * - APPROVE appends the model to config (via the injected config-edit
8
+ * dispatch) AND ensures it, then wakes the agent with an "applied" inbound.
9
+ * - DENY writes NOTHING (never reads config, never builds a diff, never
10
+ * dispatches an edit, never ensures) and wakes the agent with a "denied"
11
+ * inbound.
12
+ * - A duplicate / agent-not-found / no-change proposal is REJECTED before any
13
+ * edit is dispatched, and the agent gets a "failed" inbound.
14
+ *
15
+ * The gateway callback is a thin adapter that wires the real deps (read live
16
+ * config bytes, register the single-tap correlation, `config_propose_edit` via
17
+ * hostd, `ensureMentalModel`, inject-inbound) into `resolveMentalModelProposal`.
18
+ * Self-approval is impossible by construction: this function is only ever
19
+ * reached AFTER the gateway callback has verified the tapper is an allow-listed
20
+ * operator — the agent has no path to invoke it.
21
+ */
22
+
23
+ import {
24
+ buildMentalModelAppendDiff,
25
+ type MentalModelProposeSpec,
26
+ } from "./mental-model-propose-diff.js";
27
+ import {
28
+ buildMentalModelProposeAppliedInbound,
29
+ buildMentalModelProposeDeniedInbound,
30
+ buildMentalModelProposeFailedInbound,
31
+ type MentalModelProposeInboundContext,
32
+ } from "./mental-model-propose-inbound-builders.js";
33
+ import type { InboundMessage } from "./ipc-protocol.js";
34
+
35
+ /** The pending proposal state the resolver needs. */
36
+ export interface MentalModelPendingProposal {
37
+ agent: string;
38
+ chat_id: string;
39
+ threadId?: number;
40
+ spec: MentalModelProposeSpec;
41
+ /** Human-readable rationale, echoed into the config-edit reason. */
42
+ reason?: string;
43
+ }
44
+
45
+ /** Outcome of dispatching the config edit through hostd's apply+reconcile. */
46
+ export type ConfigEditDispatchResult =
47
+ | { state: "applied" }
48
+ | { state: "denied"; reason: string }
49
+ | { state: "error"; reason: string };
50
+
51
+ export interface ResolveDeps {
52
+ /** Read the live switchroom.yaml bytes (before-image for the diff). */
53
+ readConfigText: () => string;
54
+ /**
55
+ * Register a single-tap correlation so hostd's config-approval callback
56
+ * auto-resolves the edit WITHOUT posting a SECOND operator card — the
57
+ * operator already approved on THIS proposal card. Forge-resistance is the
58
+ * caller's job (exact diff byte-match). Called only on the approve path,
59
+ * only after a diff was successfully built.
60
+ */
61
+ registerPreApproval: (agent: string, diff: string) => void;
62
+ /** Drop a previously-registered correlation (cleanup, single-shot). */
63
+ clearPreApproval: (agent: string, diff: string) => void;
64
+ /**
65
+ * Persist the append diff via hostd `config_propose_edit`
66
+ * (validate → approve → apply → reconcile). Reconcile runs
67
+ * `ensureDeclaredMentalModels` (#2874), materialising the model.
68
+ */
69
+ dispatchConfigEdit: (args: {
70
+ agent: string;
71
+ diff: string;
72
+ reason: string;
73
+ }) => Promise<ConfigEditDispatchResult>;
74
+ /**
75
+ * Best-effort belt-and-suspenders ensure of the model in the bank
76
+ * immediately after apply (reconcile is the authoritative suspenders). Must
77
+ * not throw. Optional — when absent, ensure is left entirely to reconcile.
78
+ */
79
+ ensureModel?: (spec: MentalModelProposeSpec) => Promise<void>;
80
+ /** Deliver a synthetic inbound to wake the agent with the outcome. */
81
+ injectInbound: (inbound: InboundMessage) => void;
82
+ /** Injectable clock for deterministic tests. */
83
+ now?: () => number;
84
+ /** Injectable git binary for the diff subprocess (tests). */
85
+ gitBin?: string;
86
+ log?: (msg: string) => void;
87
+ }
88
+
89
+ export type ResolveOutcome =
90
+ | { outcome: "applied" }
91
+ | { outcome: "denied" }
92
+ | { outcome: "rejected"; reason: string }
93
+ | { outcome: "failed"; reason: string };
94
+
95
+ /**
96
+ * Resolve an operator's Approve / Deny decision on a mental-model proposal.
97
+ * Pure orchestration — all side effects go through `deps`.
98
+ */
99
+ export async function resolveMentalModelProposal(
100
+ action: "approve" | "deny",
101
+ pending: MentalModelPendingProposal,
102
+ stageId: string,
103
+ operatorId: string,
104
+ deps: ResolveDeps,
105
+ ): Promise<ResolveOutcome> {
106
+ const nowMs = (deps.now ?? Date.now)();
107
+ const ctx: MentalModelProposeInboundContext = {
108
+ agent: pending.agent,
109
+ name: pending.spec.name,
110
+ chat_id: pending.chat_id,
111
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
112
+ };
113
+
114
+ // ── DENY: write NOTHING. No config read, no diff, no dispatch, no ensure. ──
115
+ if (action === "deny") {
116
+ deps.injectInbound(
117
+ buildMentalModelProposeDeniedInbound({ ctx, stageId, operatorId, nowMs }),
118
+ );
119
+ return { outcome: "denied" };
120
+ }
121
+
122
+ // ── APPROVE: append to config (reused apply+reconcile) + ensure. ──
123
+ const configText = deps.readConfigText();
124
+ const built = buildMentalModelAppendDiff({
125
+ configText,
126
+ agentName: pending.agent,
127
+ spec: pending.spec,
128
+ ...(deps.gitBin ? { gitBin: deps.gitBin } : {}),
129
+ });
130
+ if (!built.ok) {
131
+ // Duplicate / agent-not-found / no-change / parse-error — reject BEFORE
132
+ // any edit is dispatched. Nothing is written.
133
+ deps.log?.(
134
+ `mental_model_propose: approve rejected (${built.error}) for ${pending.agent} "${pending.spec.name}": ${built.detail}`,
135
+ );
136
+ deps.injectInbound(
137
+ buildMentalModelProposeFailedInbound({
138
+ ctx,
139
+ stageId,
140
+ operatorId,
141
+ reason: built.detail,
142
+ nowMs,
143
+ }),
144
+ );
145
+ return { outcome: "rejected", reason: built.detail };
146
+ }
147
+
148
+ // Pre-register the single-tap correlation so hostd auto-approves the edit
149
+ // (operator already approved on this proposal card — no second card).
150
+ deps.registerPreApproval(pending.agent, built.diff);
151
+ let dispatch: ConfigEditDispatchResult;
152
+ try {
153
+ dispatch = await deps.dispatchConfigEdit({
154
+ agent: pending.agent,
155
+ diff: built.diff,
156
+ reason:
157
+ `Declare agent-proposed mental model "${pending.spec.name}"` +
158
+ (pending.reason ? ` — ${pending.reason}` : ""),
159
+ });
160
+ } catch (err) {
161
+ dispatch = { state: "error", reason: (err as Error).message };
162
+ } finally {
163
+ // Single-shot: drop the correlation whether or not hostd consumed it.
164
+ deps.clearPreApproval(pending.agent, built.diff);
165
+ }
166
+
167
+ if (dispatch.state !== "applied") {
168
+ deps.log?.(
169
+ `mental_model_propose: config edit ${dispatch.state} for ${pending.agent} "${pending.spec.name}": ${dispatch.reason}`,
170
+ );
171
+ deps.injectInbound(
172
+ buildMentalModelProposeFailedInbound({
173
+ ctx,
174
+ stageId,
175
+ operatorId,
176
+ reason: dispatch.reason,
177
+ nowMs,
178
+ }),
179
+ );
180
+ return { outcome: "failed", reason: dispatch.reason };
181
+ }
182
+
183
+ // Applied. Belt-and-suspenders ensure (reconcile already ensures via #2874;
184
+ // this makes the model available without waiting for the restart). Never let
185
+ // an ensure failure flip a successful declaration into a "failed" inbound —
186
+ // the model IS declared and will be ensured at reconcile regardless.
187
+ if (deps.ensureModel) {
188
+ try {
189
+ await deps.ensureModel(pending.spec);
190
+ } catch (err) {
191
+ deps.log?.(
192
+ `mental_model_propose: best-effort ensure threw (declaration still applied) for ${pending.agent} "${pending.spec.name}": ${(err as Error).message}`,
193
+ );
194
+ }
195
+ }
196
+
197
+ deps.injectInbound(
198
+ buildMentalModelProposeAppliedInbound({ ctx, stageId, operatorId, nowMs }),
199
+ );
200
+ return { outcome: "applied" };
201
+ }