switchroom 0.17.5 → 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 (158) hide show
  1. package/dist/agent-scheduler/index.js +39 -5
  2. package/dist/auth-broker/index.js +386 -208
  3. package/dist/cli/notion-write-pretool.mjs +36 -3
  4. package/dist/cli/switchroom.js +1185 -585
  5. package/dist/host-control/main.js +149 -15
  6. package/dist/vault/approvals/kernel-server.js +141 -56
  7. package/dist/vault/broker/server.js +143 -58
  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/auth-snapshot-format.ts +50 -2
  13. package/telegram-plugin/bridge/bridge.ts +80 -1
  14. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  15. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  16. package/telegram-plugin/consolidation-legibility.ts +279 -0
  17. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  18. package/telegram-plugin/dist/gateway/gateway.js +2597 -645
  19. package/telegram-plugin/dist/server.js +86 -2
  20. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  21. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  22. package/telegram-plugin/gateway/auth-command.ts +1 -1
  23. package/telegram-plugin/gateway/gateway.ts +1414 -119
  24. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  26. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  27. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  28. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  29. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  30. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  31. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  32. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  33. package/telegram-plugin/memory-legibility.ts +217 -0
  34. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  35. package/telegram-plugin/scoped-approval.ts +59 -0
  36. package/telegram-plugin/silent-end.ts +78 -0
  37. package/telegram-plugin/subagent-watcher.ts +60 -6
  38. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  40. package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -0
  41. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  42. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  43. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  44. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  45. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  46. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  48. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  49. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  50. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  51. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  52. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  53. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  54. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  55. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  56. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  57. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  58. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  59. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  60. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  61. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  62. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  63. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  64. package/telegram-plugin/uat/assertions.ts +88 -4
  65. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  66. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  71. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  72. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  74. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  75. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  76. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  77. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  97. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  98. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  99. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  100. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  101. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  129. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  130. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  131. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  132. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  157. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  158. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Narrated mid-turn work still shows words on the card (Phase 4a,
3
+ * `deterministic-turn-liveness.md`) — DM.
4
+ *
5
+ * The Phase-1 climb fills the GAPS the model leaves; it must never regress
6
+ * the pre-existing narration path (session-tail → gateway `case 'text'` →
7
+ * `stagePendingNarrative` → `showNarrativeStep` → `appendActivityLabel`).
8
+ * This scenario proves the model's own narrated steps still land on the
9
+ * card mid-turn, chronologically interleaved with the climb, when the model
10
+ * DOES talk (the complement of `jtbd-liveness-climb-dm`, which forces the
11
+ * model silent).
12
+ *
13
+ * IMPORTANT: switchroom has no streaming REPLY path. The words asserted here
14
+ * land on the pre-existing activity/liveness card (a card-edit surface), not
15
+ * a streamed reply — the final reply is still sent whole.
16
+ */
17
+
18
+ import { describe, expect, it } from "vitest";
19
+ import { spinUp } from "../harness.js";
20
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
21
+ import type { ObservedMessage } from "../driver.js";
22
+
23
+ /**
24
+ * Extract the narration/tool body lines of an activity card, dropping the
25
+ * two-line header (`🤖 Agent` / `<elapsed> · <N> tools`) and the bare
26
+ * `Working…` placeholder the 0-label climb paints. Whatever remains is
27
+ * model-authored narration or tool labels — the content this scenario proves
28
+ * still lands on the card mid-turn.
29
+ */
30
+ function narratedBodyLines(text: string): string[] {
31
+ return text
32
+ .split("\n")
33
+ .map((l) => l.trim())
34
+ .filter((l) => l.length > 0)
35
+ // strip glyphs so a `→ Working…` placeholder compares as `Working…`
36
+ .map((l) => l.replace(/^[→✓↳]+\s*/u, "").trim())
37
+ .filter(
38
+ (l) =>
39
+ l.length > 0 &&
40
+ // header line 1 (emoji + label, optional description)
41
+ !/^(?:🤖|🛠[️]?|⚙[️]?)\s/u.test(l) &&
42
+ // header line 2 (elapsed · tools, running or done)
43
+ !/^(?:(?:\d+m)?\d+s\s*·\s*\d+\s+tools?|(?:done|failed)\s*·)/iu.test(l) &&
44
+ // the bare climb placeholder is NOT narration
45
+ !/^Working(?:\s*[·…]|$)/i.test(l) &&
46
+ // rolling-overflow header is not narration
47
+ !/^\+?\d+\s+earlier/i.test(l),
48
+ );
49
+ }
50
+
51
+ const OVERALL_BUDGET_MS = 130_000;
52
+
53
+ const NARRATED_WORK_PROMPT =
54
+ "Do four short steps, ONE AT A TIME. Before EACH step, write a brief " +
55
+ 'one-sentence narration of what you are about to do (e.g. "Checking the ' +
56
+ 'hostname now.") — this is not a reply, just your normal narration — then ' +
57
+ "run the step via Bash (`sleep 3` is fine as the step body). After all " +
58
+ "four steps, send a one-line final summary reply.";
59
+
60
+ describe("uat: deterministic turn liveness — narration lands mid-turn (DM)", () => {
61
+ it(
62
+ "the model's own words appear on the activity card mid-turn (narration not regressed)",
63
+ async () => {
64
+ const sc = await spinUp({ agent: "test-harness" });
65
+ try {
66
+ const iter = sc.driver
67
+ .observeMessages(sc.botUserId)
68
+ [Symbol.asyncIterator]();
69
+
70
+ await sc.sendDM(NARRATED_WORK_PROMPT);
71
+ const sentAt = Date.now();
72
+ console.log("[liveness-narration-dm] prompt sent; watching for narrated card content…");
73
+
74
+ const narrationSamples: string[] = [];
75
+ let answer: ObservedMessage | null = null;
76
+ let otherLoudMessage: ObservedMessage | null = null;
77
+
78
+ const deadline = Date.now() + 90_000;
79
+ while (Date.now() < deadline) {
80
+ if (answer) break;
81
+ const remaining = deadline - Date.now();
82
+ const next = await Promise.race([
83
+ iter.next(),
84
+ new Promise<{ done: true; value: undefined }>((r) =>
85
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
86
+ ),
87
+ ]);
88
+ if (next.done || next.value == null) break;
89
+ const m = next.value as ObservedMessage;
90
+ if (m.senderUserId === sc.driverUserId) continue;
91
+
92
+ if (isActivityFeedMessage(m)) {
93
+ // The card carries the two-line header + (climb placeholder |
94
+ // narrated body). Any body line that ISN'T the header or the bare
95
+ // "Working…" placeholder is model narration landing mid-turn.
96
+ const narrated = narratedBodyLines(m.text);
97
+ if (narrated.length > 0) {
98
+ narrationSamples.push(narrated.join(" | "));
99
+ console.log(
100
+ `[liveness-narration-dm] narrated card content at +${Date.now() - sentAt}ms: ` +
101
+ JSON.stringify(narrated.join(" | ").slice(0, 140)),
102
+ );
103
+ }
104
+ if (m.edited && m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
105
+ continue;
106
+ }
107
+ if (m.edited) continue;
108
+ // A framework fallback TEXT send is a mid-turn erosion, not the
109
+ // answer — reject it from the answer lane so it trips the assertion.
110
+ if (isFrameworkFallbackText(m.text)) {
111
+ otherLoudMessage = otherLoudMessage ?? m;
112
+ continue;
113
+ }
114
+ if (!answer && m.text.trim().length > 0) {
115
+ answer = m;
116
+ continue;
117
+ }
118
+ if (m.silent === false) otherLoudMessage = otherLoudMessage ?? m;
119
+ }
120
+ await iter.return?.();
121
+
122
+ expect(
123
+ otherLoudMessage,
124
+ `a mid-turn message pinged the user's device: ${JSON.stringify(otherLoudMessage?.text?.slice(0, 120))}`,
125
+ ).toBeNull();
126
+
127
+ if (narrationSamples.length === 0) {
128
+ console.warn(
129
+ "[liveness-narration-dm] INCONCLUSIVE — no narrated content observed on the " +
130
+ "card (model may have batched steps or the turn was too fast). Not " +
131
+ "necessarily a regression — but worth a human look if this recurs.",
132
+ );
133
+ }
134
+ // (No tautological `expect(narrationSamples.length).toBeGreaterThan(0)`
135
+ // in the else-branch — it was asserting a condition the branch already
136
+ // guarantees. The meaningful signals are the ping-free assertion above
137
+ // and the answer-arrived assertion below; narration presence is
138
+ // logged, not force-asserted, since a live model may legitimately
139
+ // batch its steps.)
140
+
141
+ expect(answer, "FAIL — no answer arrived within budget; the turn may be wedged.").not.toBeNull();
142
+ } finally {
143
+ await sc.tearDown();
144
+ }
145
+ },
146
+ OVERALL_BUDGET_MS,
147
+ );
148
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Sparse chat-legible memory in a SUPERGROUP (#2849, hindsight Phase 4) — UAT.
3
+ *
4
+ * Channel twin of `jtbd-memory-legibility-dm`. A directive stored while the
5
+ * conversation is happening in a supergroup must surface its `📌 remembered:`
6
+ * line IN the supergroup — never the operator DM (the known status-routing
7
+ * bug class). Proves the memory-legibility status surface has DM/channel
8
+ * parity: it routes on the ORIGINATING topic (`turn.sessionChatId` /
9
+ * `sessionThreadId`), not the owner DM.
10
+ *
11
+ * Self-skips green when no test supergroup is wired (SWITCHROOM_UAT_CHAT_ID
12
+ * unset or not resolvable). Uses the General topic (mtcute here has no
13
+ * forum-topic create API).
14
+ */
15
+
16
+ import { describe, expect, it } from "vitest";
17
+ import { spinUp } from "../harness.js";
18
+ import { expectMessage } from "../assertions.js";
19
+
20
+ const AGENT = "test-harness";
21
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
22
+
23
+ const REMEMBER_PROMPT =
24
+ `Persist this as a standing directive using the ` +
25
+ `mcp__hindsight__create_directive tool, with the directive content set ` +
26
+ `to exactly: "Always greet me as Captain at the start of a reply". ` +
27
+ `Name the directive "greeting-captain". After creating it, send a brief ` +
28
+ `one-line confirmation.`;
29
+
30
+ const REMEMBERED_RE = /📌\s*remembered/i;
31
+
32
+ describe("uat: sparse chat-legible memory in a supergroup (#2849 channel parity)", () => {
33
+ it(
34
+ "surfaces the 📌 remembered line IN the supergroup, not the DM",
35
+ async () => {
36
+ if (!Number.isFinite(SUPERGROUP_ID)) {
37
+ console.warn("[memory-legibility channel UAT] SWITCHROOM_UAT_CHAT_ID unset — skipping");
38
+ return;
39
+ }
40
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
41
+ try {
42
+ await sc.driver.primeDialogs();
43
+ if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
44
+ console.warn(`[memory-legibility channel UAT] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
45
+ return;
46
+ }
47
+ await sc.driver.sendText(SUPERGROUP_ID, REMEMBER_PROMPT);
48
+
49
+ const line = await expectMessage(sc.driver, SUPERGROUP_ID, REMEMBERED_RE, {
50
+ timeout: 90_000,
51
+ senderFilter: { notUserId: sc.driverUserId },
52
+ });
53
+ console.log(
54
+ `[memory-legibility channel UAT] remembered line (id=${line.messageId}, chat=${line.chatId}): ${JSON.stringify(line.text)}`,
55
+ );
56
+ expect(line.chatId).toBe(SUPERGROUP_ID); // parity proof: in the channel, not the DM
57
+ expect(line.fromBot).toBe(true);
58
+ expect(line.messageId).toBeGreaterThan(0);
59
+ expect(line.text).toMatch(/Captain/i);
60
+ } finally {
61
+ await sc.tearDown();
62
+ }
63
+ },
64
+ 180_000,
65
+ );
66
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Sparse chat-legible memory (#2849, hindsight Phase 4) — DM UAT.
3
+ *
4
+ * Serves the `remember-across-sessions` job. When the interactive session
5
+ * stores a new standing directive, ONE terse line must surface in the
6
+ * originating chat: `📌 remembered: "<directive>"`. It's a REAL message
7
+ * (sendMessage, not a draft/reaction) so mtcute can observe it. The line
8
+ * is sparse + material-only — ordinary recall never surfaces one — and is
9
+ * ON by default (kill-switch SWITCHROOM_MEMORY_LEGIBILITY=0).
10
+ *
11
+ * This scenario asks the agent to persist an explicit standing rule via
12
+ * the create_directive tool, then asserts the 📌 remembered line appears
13
+ * from the bot in the DM.
14
+ *
15
+ * Note: the model must actually call `mcp__hindsight__create_directive`.
16
+ * The prompt names the tool + the rule verbatim to make the tool call
17
+ * deterministic. Requires the test-harness agent running an image that
18
+ * includes #2849.
19
+ */
20
+
21
+ import { describe, expect, it } from "vitest";
22
+ import { spinUp } from "../harness.js";
23
+
24
+ const REMEMBER_PROMPT =
25
+ `Persist this as a standing directive using the ` +
26
+ `mcp__hindsight__create_directive tool, with the directive content set ` +
27
+ `to exactly: "Always greet me as Captain at the start of a reply". ` +
28
+ `Name the directive "greeting-captain". After creating it, send a brief ` +
29
+ `one-line confirmation.`;
30
+
31
+ // The legibility line: `📌 remembered: "<directive body, truncated>"`.
32
+ const REMEMBERED_RE = /📌\s*remembered/i;
33
+
34
+ describe("uat: sparse chat-legible memory — remembered line (#2849)", () => {
35
+ it(
36
+ "surfaces a 📌 remembered line in the DM when a directive is created",
37
+ async () => {
38
+ const sc = await spinUp({ agent: "test-harness" });
39
+ try {
40
+ await sc.sendDM(REMEMBER_PROMPT);
41
+
42
+ // The 📌 remembered line — a real bot message in this DM. May arrive
43
+ // alongside/near the agent's confirmation reply; generous budget for
44
+ // the tool round-trip + transcript flush.
45
+ const line = await sc.expectMessage(REMEMBERED_RE, {
46
+ from: "bot",
47
+ timeout: 90_000,
48
+ });
49
+ console.log(
50
+ `[memory-legibility UAT] remembered line (id=${line.messageId}): ${JSON.stringify(line.text)}`,
51
+ );
52
+ expect(line.messageId).toBeGreaterThan(0);
53
+ // The directive body should ride along in the line.
54
+ expect(line.text).toMatch(/Captain/i);
55
+ } finally {
56
+ await sc.tearDown();
57
+ }
58
+ },
59
+ 180_000,
60
+ );
61
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Silent-end recovery — CHANNEL twin of `silent-end-recovery-dm.test.ts`
3
+ * (Phase 4a, `deterministic-turn-liveness.md`: the dark-turn guard must
4
+ * deliver exactly one fallback message on the real surface, in a DM AND a
5
+ * forum topic alike).
6
+ *
7
+ * Phase 2 (already shipped) hardens `recordUndeliveredTurnEnd` /
8
+ * `SILENT_END_FALLBACK_TEXT` as the ONE deterministic guard for a turn that
9
+ * ends having delivered no reply. This scenario proves it fires — at most
10
+ * once — for the same tool-heavy prompt shape, in a supergroup, AND that no
11
+ * mid-turn device ping is emitted before the terminal reply/fallback (the
12
+ * ping-free invariant the RFC/job spec claim is inline in every scenario).
13
+ * The terminal dark-turn fallback itself is a sanctioned loud unwedge send, so
14
+ * only pings STRICTLY BEFORE the terminal message count as erosion.
15
+ *
16
+ * Self-skips green when no test supergroup is wired.
17
+ */
18
+
19
+ import { describe, it, expect } from "vitest";
20
+ import { spinUp } from "../harness.js";
21
+ import { expectMessage, isActivityFeedMessage } from "../assertions.js";
22
+ import type { ObservedMessage } from "../driver.js";
23
+
24
+ const AGENT = "test-harness";
25
+ const SUPERGROUP_ID = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID ?? "", 10);
26
+
27
+ const TOOL_HEAVY_PROMPT =
28
+ "Pick a directory under /tmp that doesn't exist yet. Create it. " +
29
+ "List its contents (should be empty). Write a small file in it. " +
30
+ "List again. Then report what you did in a one-line reply.";
31
+
32
+ describe("uat: silent-end recovery (channel parity)", () => {
33
+ it(
34
+ "user asks in a supergroup → agent replies exactly once via the fallback ladder if needed, no mid-turn ping",
35
+ async () => {
36
+ if (!Number.isFinite(SUPERGROUP_ID)) {
37
+ console.warn("[silent-end-recovery-channel] SWITCHROOM_UAT_CHAT_ID unset — skipping");
38
+ return;
39
+ }
40
+ const sc = await spinUp({ agent: AGENT, settleMs: 0 });
41
+ try {
42
+ await sc.driver.primeDialogs();
43
+ if (!(await sc.driver.canResolve(SUPERGROUP_ID))) {
44
+ console.warn(`[silent-end-recovery-channel] supergroup ${SUPERGROUP_ID} not resolvable — skipping`);
45
+ return;
46
+ }
47
+
48
+ const iter = sc.driver
49
+ .observeMessages(SUPERGROUP_ID)
50
+ [Symbol.asyncIterator]();
51
+
52
+ await sc.driver.sendText(SUPERGROUP_ID, TOOL_HEAVY_PROMPT);
53
+ const sentAt = Date.now();
54
+
55
+ // Same budget rationale as the DM twin: covers normal latency, one
56
+ // Stop-hook re-prompt cycle, and the worst-case 300s framework
57
+ // fallback. We observe the whole window so we can assert the ping-free
58
+ // invariant BEFORE the terminal reply, not just wait for the reply.
59
+ let reply: ObservedMessage | null = null;
60
+ let midTurnPing: ObservedMessage | null = null;
61
+
62
+ const deadline = Date.now() + 320_000;
63
+ while (Date.now() < deadline) {
64
+ if (reply) break;
65
+ const remaining = deadline - Date.now();
66
+ const next = await Promise.race([
67
+ iter.next(),
68
+ new Promise<{ done: true; value: undefined }>((r) =>
69
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
70
+ ),
71
+ ]);
72
+ if (next.done || next.value == null) break;
73
+ const m = next.value as ObservedMessage;
74
+ if (m.senderUserId === sc.driverUserId) continue;
75
+
76
+ // Card edits are the sanctioned mid-turn liveness surface (silent by
77
+ // construction) — skip them from the ping accounting.
78
+ if (isActivityFeedMessage(m)) {
79
+ if (m.edited && m.silent === false) midTurnPing = midTurnPing ?? m;
80
+ continue;
81
+ }
82
+ if (m.edited) continue;
83
+ // First substantive non-card bot message is the terminal reply
84
+ // (model answer OR the dark-turn fallback — both terminal, both may
85
+ // legitimately ping).
86
+ if (m.text.trim().length > 0) {
87
+ reply = m;
88
+ continue;
89
+ }
90
+ }
91
+
92
+ expect(
93
+ midTurnPing,
94
+ `a mid-turn message pinged the device BEFORE the terminal reply in the supergroup: ` +
95
+ JSON.stringify(midTurnPing?.text?.slice(0, 120)),
96
+ ).toBeNull();
97
+
98
+ expect(reply, "FAIL — no reply arrived within budget; the silent-end guard may have regressed.").not.toBeNull();
99
+ expect(reply!.text.length).toBeGreaterThan(0);
100
+ expect(reply!.chatId).toBe(SUPERGROUP_ID);
101
+ console.log(`[silent-end-recovery-channel] reply at +${Date.now() - sentAt}ms.`);
102
+
103
+ if (/no update from agent|didn't send a reply/i.test(reply!.text)) {
104
+ console.warn(
105
+ "[silent-end-recovery-channel] reply was the framework/dark-turn fallback — " +
106
+ `model never replied on its own. Reply text: ${JSON.stringify(reply!.text.slice(0, 200))}`,
107
+ );
108
+
109
+ // Exactly-once proof: no SECOND fallback-shaped message should
110
+ // follow within a short window (the `exhausted` latch).
111
+ let secondFallback: ObservedMessage | null = null;
112
+ try {
113
+ secondFallback = await expectMessage(
114
+ sc.driver,
115
+ SUPERGROUP_ID,
116
+ /no update from agent|didn't send a reply/i,
117
+ { timeout: 15_000, senderFilter: { notUserId: sc.driverUserId } },
118
+ );
119
+ } catch {
120
+ // Timeout is the expected/good outcome — no second fallback fired.
121
+ }
122
+ expect(
123
+ secondFallback,
124
+ "the dark-turn/framework fallback fired MORE than once for the same turn " +
125
+ "(the `exhausted` latch should give fire-once)",
126
+ ).toBeNull();
127
+ }
128
+
129
+ await iter.return?.();
130
+ } finally {
131
+ await sc.tearDown();
132
+ }
133
+ },
134
+ 360_000,
135
+ );
136
+ });
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { describe, it, expect } from "vitest";
36
36
  import { spinUp } from "../harness.js";
37
+ import type { ObservedMessage } from "../driver.js";
37
38
 
38
39
  // The prompt pushes the model into a tool-heavy state where it has
39
40
  // produced "an answer" internally but hasn't yet realised it must
@@ -77,12 +78,33 @@ describe("uat: silent-end recovery", () => {
77
78
  // fallback wording ("still working… (no update from agent
78
79
  // in N min)") that means the silent-end loop fired AND the
79
80
  // model didn't recover. Acceptable outcome — the user got
80
- // something — but a design-health alarm. Log it.
81
- if (/no update from agent/i.test(reply.text)) {
81
+ // something — but a design-health alarm. Log it AND assert the
82
+ // `exhausted` latch gave fire-once (transport-side), matching the
83
+ // channel twin exactly — the job-spec Prove-it claim is
84
+ // "asserted transport-side in a DM AND a supergroup alike", so the
85
+ // DM twin must make the same exactly-once assertion, not just warn.
86
+ if (/no update from agent|didn't send a reply/i.test(reply.text)) {
82
87
  console.warn(
83
88
  `[silent-end-recovery] reply was the framework fallback — `
84
89
  + `model never replied on its own. Reply text: ${JSON.stringify(reply.text.slice(0, 200))}`,
85
90
  );
91
+
92
+ // No SECOND fallback-shaped message should follow within a short
93
+ // window (the `exhausted` latch gives fire-once).
94
+ let secondFallback: ObservedMessage | null = null;
95
+ try {
96
+ secondFallback = await sc.expectMessage(
97
+ /no update from agent|didn't send a reply/i,
98
+ { from: "bot", timeout: 15_000 },
99
+ );
100
+ } catch {
101
+ // Timeout is the expected/good outcome — no second fallback fired.
102
+ }
103
+ expect(
104
+ secondFallback,
105
+ "the dark-turn/framework fallback fired MORE than once for the same turn "
106
+ + "(the `exhausted` latch should give fire-once)",
107
+ ).toBeNull();
86
108
  }
87
109
  } finally {
88
110
  await sc.tearDown();
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Ownership filter for the worktree-isolated cwds the subagent-watcher should
3
+ * additionally watch (deterministic-turn-liveness.md Known Gap 2 + the #2893
4
+ * ownership-predicate review fix).
5
+ *
6
+ * A sub-agent dispatched into a `switchroom worktree claim` cwd runs under a
7
+ * different project-dir slug than the agent's own `agentCwd`, so the #1116
8
+ * foreign-slug filter would skip it forever unless the watcher also watches
9
+ * the slugs of worktrees THIS agent owns. This helper derives that set from
10
+ * the host-global worktree registry, and it is deliberately fail-CLOSED:
11
+ *
12
+ * - Unset/empty identity ⇒ contribute NOTHING. `ownerAgent` is optional in
13
+ * the registry, so a naive `r.ownerAgent === process.env.SWITCHROOM_AGENT_NAME`
14
+ * with the env var unset becomes `undefined === undefined` and matches
15
+ * every OWNERLESS record in the host-global registry — including other
16
+ * agents' worktrees. That is precisely the #1116 leak the filter exists to
17
+ * prevent, reintroduced by failing OPEN. With no identity we cannot prove
18
+ * ownership of anything, so we return `[]`.
19
+ * - A registry read failure ⇒ `[]` (best-effort; never disturb the base
20
+ * agentCwd watch).
21
+ * - Owner match ⇒ include, realpath'd. Claude Code mints the project slug off
22
+ * the process's PHYSICAL cwd, so a symlinked base (macOS `/tmp` →
23
+ * `/private/tmp`) would otherwise derive a slug that misses the physical
24
+ * one; realpath best-effort, falling back to the raw path.
25
+ */
26
+ import { realpathSync } from "node:fs";
27
+
28
+ export interface WorktreeOwnershipRecord {
29
+ path: string;
30
+ ownerAgent?: string;
31
+ }
32
+
33
+ export interface OwnedWorktreeCwdsOptions {
34
+ /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME`. */
35
+ self: string | undefined;
36
+ /** The host-global registry read (`listRecords` from src/worktree/registry). */
37
+ listRecords: () => WorktreeOwnershipRecord[];
38
+ /** Injectable for tests; defaults to `fs.realpathSync`. */
39
+ realpath?: (p: string) => string;
40
+ }
41
+
42
+ export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
43
+ const { self } = opts;
44
+ if (self == null || self === "") return [];
45
+ const rp = opts.realpath ?? realpathSync;
46
+ try {
47
+ return opts
48
+ .listRecords()
49
+ .filter((r) => r.ownerAgent === self)
50
+ .map((r) => {
51
+ try {
52
+ return rp(r.path);
53
+ } catch {
54
+ return r.path;
55
+ }
56
+ });
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
@@ -23,6 +23,15 @@
23
23
  }
24
24
  ],
25
25
  "Stop": [
26
+ {
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/directive_verify.py\"",
31
+ "timeout": 10
32
+ }
33
+ ]
34
+ },
26
35
  {
27
36
  "hooks": [
28
37
  {