switchroom 0.17.6 → 0.17.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/agent-scheduler/index.js +38 -4
  2. package/dist/auth-broker/index.js +302 -203
  3. package/dist/cli/notion-write-pretool.mjs +35 -2
  4. package/dist/cli/switchroom.js +1178 -576
  5. package/dist/host-control/main.js +148 -14
  6. package/dist/vault/approvals/kernel-server.js +140 -55
  7. package/dist/vault/broker/server.js +142 -57
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/bridge/bridge.ts +80 -1
  13. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  14. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  15. package/telegram-plugin/consolidation-legibility.ts +279 -0
  16. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  17. package/telegram-plugin/dist/gateway/gateway.js +2565 -610
  18. package/telegram-plugin/dist/server.js +86 -2
  19. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  20. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  21. package/telegram-plugin/gateway/gateway.ts +1376 -82
  22. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  23. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  24. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  25. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  26. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  27. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  28. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  29. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  30. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  31. package/telegram-plugin/memory-legibility.ts +217 -0
  32. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  33. package/telegram-plugin/scoped-approval.ts +59 -0
  34. package/telegram-plugin/silent-end.ts +78 -0
  35. package/telegram-plugin/subagent-watcher.ts +60 -6
  36. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  38. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  39. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  40. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  41. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  42. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  43. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  44. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  45. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  46. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  47. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  48. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  49. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  50. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  51. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  52. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  53. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  54. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  55. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  56. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  57. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  58. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  59. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  60. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  61. package/telegram-plugin/uat/assertions.ts +88 -4
  62. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  63. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  64. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  65. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  66. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  71. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  72. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  73. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  74. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  75. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  77. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  81. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  94. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  95. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  96. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  97. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  98. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  126. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  127. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  129. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -46,16 +46,80 @@ export function isWorkerFeedMessage(m: ObservedMessage): boolean {
46
46
  */
47
47
  const ACTIVITY_FEED_LINE_RE = /^[→✓]\s/u;
48
48
 
49
+ /**
50
+ * A body line of the activity card: an in-progress `→`, a done `✓`, a nested
51
+ * child `↳`, or the rolling `✓ +N earlier…` / `↳ +N earlier…` overflow
52
+ * headers (both covered by the leading glyph). Distinct from
53
+ * {@link ACTIVITY_FEED_LINE_RE} in that it does NOT require the trailing space
54
+ * — `↳→ …` nested-in-progress lines render glyph-adjacent.
55
+ */
56
+ const ACTIVITY_BODY_LINE_RE = /^[→✓↳]/u;
57
+
58
+ /**
59
+ * The two-line header `renderActivityHeader`
60
+ * (telegram-plugin/tool-activity-summary.ts) prepends to the session
61
+ * activity / liveness card — the shape the pure-arrow
62
+ * {@link ACTIVITY_FEED_LINE_RE} predicate could never match, which is why the
63
+ * Phase-1 climb card (`silentTurnClimbRender`) was mis-classified as the
64
+ * answer and the whole climb test wall passed vacuously
65
+ * (`deterministic-turn-liveness.md` Phase 4a). Telegram strips the bold/italic
66
+ * entities, so the OBSERVED lines are:
67
+ *
68
+ * line 1: `<emoji> <label>` e.g. `🤖 Agent` (optionally ` · <description>`)
69
+ * line 2 running: `<elapsed> · <N> tool(s)` e.g. `12s · 0 tools`, `2m05s · 3 tools`
70
+ * line 2 done: `<state> · <N> tools · <elapsed>` e.g. `done · 3 tools · 41s`
71
+ *
72
+ * Elapsed is `formatFeedElapsed`: `<N>s` under a minute, else `<M>m<SS>s`.
73
+ */
74
+ const LIVENESS_HEADER_L1_RE = /^(?:🤖|🛠[️]?|⚙[️]?)\s+\S/u;
75
+ const LIVENESS_ELAPSED = String.raw`(?:\d+m)?\d+s`;
76
+ const LIVENESS_HEADER_L2_RE = new RegExp(
77
+ `^(?:${LIVENESS_ELAPSED}\\s*·\\s*\\d+\\s+tools?` +
78
+ `|(?:done|failed)\\s*·\\s*\\d+\\s+tools?\\s*·\\s*${LIVENESS_ELAPSED})$`,
79
+ "iu",
80
+ );
81
+
82
+ /**
83
+ * True when `m` is the session activity / liveness card that carries the
84
+ * two-line `renderActivityHeader` (emoji + label, then the climbing
85
+ * `<elapsed> · <N> tools` status), followed only by `→`/`✓`/`↳` body lines.
86
+ *
87
+ * This is the card the Phase-1 climb (`feed-heartbeat-climb.ts`) and every
88
+ * headered activity feed render. The predicate stays strict — it requires
89
+ * BOTH header lines to match their exact shape — so a real reply that merely
90
+ * opens with an emoji or contains an arrow is never misclassified (the same
91
+ * documented reason the pure-arrow predicate demands every line be an activity
92
+ * line).
93
+ */
94
+ export function isLivenessCardMessage(m: ObservedMessage): boolean {
95
+ const lines = m.text
96
+ .split("\n")
97
+ .map((l) => l.trim())
98
+ .filter((l) => l.length > 0);
99
+ if (lines.length < 2) return false;
100
+ if (!LIVENESS_HEADER_L1_RE.test(lines[0])) return false;
101
+ if (!LIVENESS_HEADER_L2_RE.test(lines[1])) return false;
102
+ // Any remaining line must be an activity body line — the moment prose
103
+ // appears below the header, this is no longer the card (guards against a
104
+ // reply that happens to lead with a header-shaped emoji).
105
+ return lines.slice(2).every((l) => ACTIVITY_BODY_LINE_RE.test(l));
106
+ }
107
+
49
108
  /**
50
109
  * True when `m` is the live tool-activity feed (the one-message list of
51
110
  * "what the agent is doing this turn") rather than the agent's reply. A
52
- * message qualifies only when EVERY non-empty line is an activity line —
53
- * so a real reply that merely contains an arrow is never misclassified.
111
+ * message qualifies when EITHER every non-empty line is a pure activity line
112
+ * (`→`/`✓`, the header-less feed) OR it carries the two-line liveness header
113
+ * (see {@link isLivenessCardMessage}) — so a real reply that merely contains
114
+ * an arrow is never misclassified.
54
115
  *
55
116
  * Recall/reply scenarios must skip this in addition to
56
117
  * {@link isWorkerFeedMessage}: on a turn that uses tools, the feed paints
57
118
  * `→ Finding the right tool` as its own bot message before the real answer
58
- * lands, and an `expectMessage(/\S/)` would otherwise latch onto it.
119
+ * lands, and an `expectMessage(/\S/)` would otherwise latch onto it. Before
120
+ * this predicate learned the header shape, the Phase-1 climb card (which
121
+ * ALWAYS carries the header) slipped through as an "answer" and silently
122
+ * broke the entire liveness-climb test wall.
59
123
  */
60
124
  export function isActivityFeedMessage(m: ObservedMessage): boolean {
61
125
  const lines = m.text
@@ -63,7 +127,8 @@ export function isActivityFeedMessage(m: ObservedMessage): boolean {
63
127
  .map((l) => l.trim())
64
128
  .filter((l) => l.length > 0);
65
129
  if (lines.length === 0) return false;
66
- return lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l));
130
+ if (lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l))) return true;
131
+ return isLivenessCardMessage(m);
67
132
  }
68
133
 
69
134
  /**
@@ -83,6 +148,25 @@ export function isAnswer(m: ObservedMessage, driverUserId: number): boolean {
83
148
  );
84
149
  }
85
150
 
151
+ /**
152
+ * Wording of the framework's own mid-turn / dark-turn fallback sends — the
153
+ * exact erosion class the liveness test wall exists to catch. A re-added
154
+ * cadence "still working…" text ping (the #2667 shape the RFC bans) or a
155
+ * dark-turn fallback would carry one of these phrases. Scenarios use this to
156
+ * REJECT such a message from the "answer" lane: without it, the first loud
157
+ * mid-turn framework send is swallowed as the turn's answer and the ping-free
158
+ * guarantee passes vacuously. Keep in sync with `SILENT_END_FALLBACK_TEXT`
159
+ * (gateway.ts) and `formatFrameworkFallbackText` (silence-poke.ts).
160
+ */
161
+ export const FRAMEWORK_FALLBACK_RE =
162
+ /still working|no update from agent|didn't send a reply|finished working but|waiting for your approval/i;
163
+
164
+ /** True when `text` reads like a framework mid-turn/dark-turn fallback send
165
+ * (see {@link FRAMEWORK_FALLBACK_RE}) rather than a model-authored answer. */
166
+ export function isFrameworkFallbackText(text: string): boolean {
167
+ return FRAMEWORK_FALLBACK_RE.test(text);
168
+ }
169
+
86
170
  export interface ReplyIsLastOptions {
87
171
  /**
88
172
  * The answer message that must be last in its foreground turn. The turn is
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  isActivityFeedMessage,
4
+ isFrameworkFallbackText,
5
+ isLivenessCardMessage,
4
6
  isWorkerFeedMessage,
5
7
  WORKER_FEED_RE,
6
8
  } from "./assertions.js";
@@ -83,4 +85,71 @@ describe("isActivityFeedMessage", () => {
83
85
  it("does NOT match an empty message", () => {
84
86
  expect(isActivityFeedMessage(feed(" "))).toBe(false);
85
87
  });
88
+
89
+ // The regression that silently broke the whole liveness-climb test wall
90
+ // (deterministic-turn-liveness.md Phase 4a): the climb card carries the
91
+ // two-line `renderActivityHeader`, which the pure-arrow predicate could
92
+ // never match — so it was classified as the answer and every climb test
93
+ // exited vacuously. isActivityFeedMessage must now recognise the header.
94
+ it("matches the Phase-1 climb card (two-line header + Working… body)", () => {
95
+ expect(
96
+ isActivityFeedMessage(feed("🤖 Agent\n12s · 0 tools\n→ Working…")),
97
+ ).toBe(true);
98
+ expect(
99
+ isActivityFeedMessage(feed("🤖 Agent\n2m05s · 0 tools\n→ Working…")),
100
+ ).toBe(true);
101
+ });
102
+
103
+ it("matches a headered narration card (header + narrated → step)", () => {
104
+ expect(
105
+ isActivityFeedMessage(feed("🤖 Agent\n18s · 2 tools\n✓ Checking the hostname\n→ Writing the file")),
106
+ ).toBe(true);
107
+ });
108
+ });
109
+
110
+ describe("isLivenessCardMessage", () => {
111
+ it("matches the climbing Working… card (running header)", () => {
112
+ expect(isLivenessCardMessage(feed("🤖 Agent\n12s · 0 tools\n→ Working…"))).toBe(true);
113
+ expect(isLivenessCardMessage(feed("🤖 Agent\n1m41s · 3 tools\n→ Working…"))).toBe(true);
114
+ });
115
+
116
+ it("matches the done header shape", () => {
117
+ expect(isLivenessCardMessage(feed("🤖 Agent\ndone · 3 tools · 41s\n✓ Ran the check"))).toBe(true);
118
+ });
119
+
120
+ it("matches a header with a description on line 1", () => {
121
+ expect(isLivenessCardMessage(feed("🤖 Agent · summarising the logs\n8s · 1 tool\n→ Working…"))).toBe(true);
122
+ });
123
+
124
+ it("does NOT match a plain reply", () => {
125
+ expect(isLivenessCardMessage(feed("done! I created the file and listed it."))).toBe(false);
126
+ });
127
+
128
+ it("does NOT match a reply that opens with an emoji but is prose", () => {
129
+ expect(
130
+ isLivenessCardMessage(feed("🤖 Agent here — I finished the task.\nAll four steps done.")),
131
+ ).toBe(false);
132
+ });
133
+
134
+ it("does NOT match when prose follows the header", () => {
135
+ expect(
136
+ isLivenessCardMessage(feed("🤖 Agent\n12s · 0 tools\nHere is your answer.")),
137
+ ).toBe(false);
138
+ });
139
+
140
+ it("does NOT match a bare single header line", () => {
141
+ expect(isLivenessCardMessage(feed("🤖 Agent"))).toBe(false);
142
+ });
143
+ });
144
+
145
+ describe("isFrameworkFallbackText", () => {
146
+ it("flags the mid-turn / dark-turn fallback wording", () => {
147
+ expect(isFrameworkFallbackText("⚠️ still working… (no update from agent in 5 min)")).toBe(true);
148
+ expect(isFrameworkFallbackText("The agent finished working but didn't send a reply.")).toBe(true);
149
+ expect(isFrameworkFallbackText("I'm blocked — waiting for your approval to proceed.")).toBe(true);
150
+ });
151
+
152
+ it("does NOT flag an ordinary answer", () => {
153
+ expect(isFrameworkFallbackText("Done — I created /tmp/foo and wrote a file in it.")).toBe(false);
154
+ });
86
155
  });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Fuzz: dead-air-between-visible-updates bound across turn shapes (Phase 4c,
3
+ * `deterministic-turn-liveness.md`). Non-required (`uat-fuzz`,
4
+ * `workflow_dispatch` + scheduled — see `ci-uat.yml`), scoped to `fuzz-*`.
5
+ *
6
+ * SCOPE HONESTY (read before trusting this as "the fuzz invariant" wholesale):
7
+ * the RFC's Phase 4c corpus is message-timing × turn-length × tool-churn ×
8
+ * sub-agent-fan-out × surface × role — a genuinely randomized property-fuzz.
9
+ * That full corpus already exists at the DECISION layer
10
+ * (`telegram-plugin/tests/turn-liveness-invariant.test.ts`, 2000 random
11
+ * shapes × both surfaces, fast/local/every-CI-run). What does NOT exist yet
12
+ * is a live-transport fuzz of the same breadth — each live turn burns real
13
+ * subscription quota and ~30-90s wall-clock, so a 2000-shape live corpus is
14
+ * not realistic to author or run from this sandbox (or CI, at any cadence
15
+ * short of a dedicated long-running canary). This file is the SCAFFOLD:
16
+ * a handful of FIXED, hand-picked turn shapes run on the real surface,
17
+ * checked against the SAME dead-air bound the decision-layer fuzz proves in
18
+ * the abstract. It is not a substitute for a true randomized live corpus —
19
+ * see the RFC's Known gaps / follow-up list, where this limitation is named
20
+ * explicitly rather than left implicit.
21
+ *
22
+ * Each case fires a turn shape and watches the two Phase-1 (keystone)
23
+ * invariants on the real surface — this is a climb/dead-air fuzz, NOT a
24
+ * dark-turn fuzz (the dark-turn at-most-once latch is proven in the
25
+ * `silent-end-recovery-{dm,channel}` scenarios, not here):
26
+ *
27
+ * - dead air between VISIBLE updates (card edits, narration, or the final
28
+ * answer) never exceeds `MAX_DEAD_AIR_MS` — generous slack over the
29
+ * ~6-12s Phase-1 bound to absorb live Bot API + model latency jitter;
30
+ * - zero mid-turn erosion: no non-final message with `silent===false`, and
31
+ * no framework fallback TEXT send masquerading as the answer.
32
+ */
33
+
34
+ import { describe, expect, it } from "vitest";
35
+ import { spinUp } from "../harness.js";
36
+ import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
37
+ import type { ObservedMessage } from "../driver.js";
38
+
39
+ const MAX_DEAD_AIR_MS = 25_000; // generous slack over the ~6-12s Phase-1 bound
40
+ const CASE_BUDGET_MS = 130_000;
41
+
42
+ interface FuzzCase {
43
+ name: string;
44
+ prompt: string;
45
+ windowMs: number;
46
+ }
47
+
48
+ const CASES: FuzzCase[] = [
49
+ {
50
+ name: "single-silent-tool-short",
51
+ prompt:
52
+ "Run exactly one Bash command `sleep 20` with NO narration before or " +
53
+ "during it, then reply with a one-line confirmation.",
54
+ windowMs: 30_000,
55
+ },
56
+ {
57
+ name: "single-silent-tool-long",
58
+ prompt:
59
+ "Run exactly one Bash command `sleep 50` with NO narration before or " +
60
+ "during it, then reply with a one-line confirmation.",
61
+ windowMs: 60_000,
62
+ },
63
+ {
64
+ name: "two-silent-tools-back-to-back",
65
+ prompt:
66
+ "Run Bash `sleep 20`, then immediately (no narration in between) run " +
67
+ "Bash `sleep 20` again, then reply with a one-line confirmation.",
68
+ windowMs: 50_000,
69
+ },
70
+ ];
71
+
72
+ describe("uat-fuzz: liveness dead-air bound across a handful of turn shapes (Phase 4c scaffold)", () => {
73
+ for (const fc of CASES) {
74
+ it(
75
+ `[${fc.name}] dead air between visible updates never exceeds ${MAX_DEAD_AIR_MS}ms; no mid-turn ping`,
76
+ async () => {
77
+ const sc = await spinUp({ agent: "test-harness" });
78
+ try {
79
+ const iter = sc.driver
80
+ .observeMessages(sc.botUserId)
81
+ [Symbol.asyncIterator]();
82
+
83
+ await sc.sendDM(fc.prompt);
84
+ const sentAt = Date.now();
85
+ let lastVisibleAt = sentAt;
86
+ let maxGap = 0;
87
+ let answer: ObservedMessage | null = null;
88
+ let loudMidTurn: ObservedMessage | null = null;
89
+
90
+ const deadline = Date.now() + fc.windowMs + 40_000;
91
+ while (Date.now() < deadline) {
92
+ if (answer) break;
93
+ const remaining = deadline - Date.now();
94
+ const next = await Promise.race([
95
+ iter.next(),
96
+ new Promise<{ done: true; value: undefined }>((r) =>
97
+ setTimeout(() => r({ done: true, value: undefined }), Math.max(0, remaining)),
98
+ ),
99
+ ]);
100
+ if (next.done || next.value == null) break;
101
+ const m = next.value as ObservedMessage;
102
+ if (m.senderUserId === sc.driverUserId) continue;
103
+
104
+ const now = Date.now();
105
+ if (isActivityFeedMessage(m)) {
106
+ const gap = now - lastVisibleAt;
107
+ maxGap = Math.max(maxGap, gap);
108
+ lastVisibleAt = now;
109
+ if (m.edited && m.silent === false) loudMidTurn = loudMidTurn ?? m;
110
+ continue;
111
+ }
112
+ if (m.edited) continue;
113
+ // A framework fallback TEXT send is a mid-turn erosion, not the
114
+ // answer — never let it be swallowed as the reply (else a re-added
115
+ // "still working…" ping would pass this fuzz vacuously).
116
+ if (isFrameworkFallbackText(m.text)) {
117
+ loudMidTurn = loudMidTurn ?? m;
118
+ continue;
119
+ }
120
+ if (!answer && m.text.trim().length > 0) {
121
+ answer = m;
122
+ const gap = now - lastVisibleAt;
123
+ maxGap = Math.max(maxGap, gap);
124
+ continue;
125
+ }
126
+ if (m.silent === false) loudMidTurn = loudMidTurn ?? m;
127
+ }
128
+ await iter.return?.();
129
+
130
+ console.log(
131
+ `[fuzz-liveness-climb][${fc.name}] maxGap=${maxGap}ms answer=${answer != null}`,
132
+ );
133
+
134
+ expect(
135
+ loudMidTurn,
136
+ `[${fc.name}] a mid-turn message pinged the device: ` +
137
+ JSON.stringify(loudMidTurn?.text?.slice(0, 120)),
138
+ ).toBeNull();
139
+
140
+ expect(answer, `[${fc.name}] FAIL — no answer within budget; turn may be wedged.`).not.toBeNull();
141
+
142
+ expect(
143
+ maxGap,
144
+ `[${fc.name}] dead air of ${maxGap}ms between visible updates exceeds the ` +
145
+ `${MAX_DEAD_AIR_MS}ms bound — the Phase-1 climb should have kept the card ` +
146
+ "moving throughout the silent tool stretch.",
147
+ ).toBeLessThanOrEqual(MAX_DEAD_AIR_MS);
148
+ } finally {
149
+ await sc.tearDown();
150
+ }
151
+ },
152
+ CASE_BUDGET_MS,
153
+ );
154
+ }
155
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * JTBD scenario — a correction lands as a directive and survives `/reset`.
3
+ *
4
+ * Serves: `reference/jobs/remember-across-sessions.md` — the job's headline
5
+ * criterion is "a rule set once stays respected / a correction sticks."
6
+ *
7
+ * ## Why this exists (issue #2848 Stage B)
8
+ *
9
+ * Directive capture was guidance-only: the model is *told* to call
10
+ * `create_directive` on a durable correction, and a Stage A audit measured a
11
+ * **~55% miss rate** — the same broadcast correction captured by one agent,
12
+ * silently dropped by two others. Stage B adds a DETERMINISTIC regex nudge in
13
+ * the vendored recall hook (`vendor/hindsight-memory/scripts/recall.py`): on a
14
+ * correction-shaped inbound it appends a terse advisory telling the model to
15
+ * persist the rule with `create_directive` before answering. Detection is pure
16
+ * regex — the judgment happens IN the interactive session (claude-native
17
+ * invariant: no model callsite, no silent hook-side write).
18
+ *
19
+ * ## Contract this asserts
20
+ *
21
+ * 1. **Capture**: after a correction-shaped DM ("from now on, always end every
22
+ * reply with <MARKER>"), an ACTIVE directive referencing the rule exists in
23
+ * the agent's hindsight bank (queried via the REST API, the same surface
24
+ * Stage A used).
25
+ * 2. **Survival across `/reset`**: after `/reset` clears the session, a fresh
26
+ * neutral follow-up is still answered honoring the rule (the MARKER appears
27
+ * in the reply) — proving the directive was re-injected from the bank, not
28
+ * merely held in the wiped session context.
29
+ *
30
+ * ## Self-skip
31
+ *
32
+ * Self-skips GREEN when the mtcute driver isn't wired (no
33
+ * TELEGRAM_UAT_DRIVER_SESSION etc.), so it never reds an unwired host. The
34
+ * whole uat/** tree is excluded from gating CI regardless; run live with
35
+ * `bun run --cwd telegram-plugin test:uat jtbd-directive-capture-nudge-dm`.
36
+ */
37
+
38
+ import { describe, it, expect } from "vitest";
39
+ import { randomBytes } from "node:crypto";
40
+ import { spinUp } from "../harness.js";
41
+ import { isActivityFeedMessage, isWorkerFeedMessage } from "../assertions.js";
42
+ import type { ObservedMessage } from "../driver.js";
43
+
44
+ const AGENT = "test-harness";
45
+
46
+ // Match the first non-empty bot reply that is neither the worker-activity
47
+ // feed nor the tool-activity feed (same guard as jtbd-memory-survives).
48
+ const isReply = (m: ObservedMessage): boolean =>
49
+ /\S/.test(m.text) && !isWorkerFeedMessage(m) && !isActivityFeedMessage(m);
50
+
51
+ const CORRECTION_REPLY_BUDGET_MS = 60_000;
52
+ const DIRECTIVE_SETTLE_MS = 12_000;
53
+ const RESET_SETTLE_MS = 45_000;
54
+ const POSTRESET_REPLY_BUDGET_MS = 120_000;
55
+
56
+ // Unique per-run marker so directive-content matching and the honored-rule
57
+ // check can't latch onto stale state from a prior run.
58
+ const MARKER = `SR_UAT_DIRECTIVE_${randomBytes(6).toString("hex").toUpperCase()}`;
59
+
60
+ // Hindsight REST base. Bank id == agent name (Stage A + CLAUDE.md). Override
61
+ // via HINDSIGHT_UAT_API_URL if the host runs it elsewhere.
62
+ const HINDSIGHT_BASE =
63
+ process.env.HINDSIGHT_UAT_API_URL ??
64
+ process.env.HINDSIGHT_API_URL ??
65
+ "http://127.0.0.1:18888";
66
+
67
+ interface Directive {
68
+ content?: string;
69
+ name?: string;
70
+ }
71
+
72
+ async function fetchActiveDirectives(bank: string): Promise<Directive[]> {
73
+ const url = `${HINDSIGHT_BASE}/v1/default/banks/${encodeURIComponent(bank)}/directives?active_only=false`;
74
+ const res = await fetch(url, { signal: AbortSignal.timeout(8_000) });
75
+ if (!res.ok) {
76
+ throw new Error(`hindsight directives HTTP ${res.status} for bank ${bank} (${url})`);
77
+ }
78
+ const body = (await res.json()) as { items?: Directive[] };
79
+ return Array.isArray(body.items) ? body.items : [];
80
+ }
81
+
82
+ const uatWired = Boolean(
83
+ process.env.TELEGRAM_API_ID &&
84
+ process.env.TELEGRAM_API_HASH &&
85
+ process.env.TELEGRAM_UAT_DRIVER_SESSION &&
86
+ (process.env.TELEGRAM_TEST_BOT_USERNAME || false),
87
+ );
88
+
89
+ (uatWired ? describe : describe.skip)(
90
+ "uat: correction lands as a directive and survives /reset (remember-across-sessions JTBD)",
91
+ () => {
92
+ it(
93
+ "a standing-rule correction is persisted as a directive and honored after /reset",
94
+ async () => {
95
+ // --- Phase 1: give the agent a durable, correction-shaped rule ---
96
+ const sc1 = await spinUp({ agent: AGENT });
97
+ try {
98
+ await sc1.sendDM(
99
+ `From now on, always end every reply you send me with this exact ` +
100
+ `marker on its own final line: ${MARKER}. This is a standing ` +
101
+ `rule for how you should behave going forward — please persist ` +
102
+ `it so it sticks across sessions. Confirm you've noted it.`,
103
+ );
104
+
105
+ const reply = await sc1.expectMessage(isReply, {
106
+ from: "bot",
107
+ timeout: CORRECTION_REPLY_BUDGET_MS,
108
+ });
109
+ expect(reply.text.length).toBeGreaterThan(0);
110
+
111
+ // Give the in-turn create_directive call (nudged by the hook) time
112
+ // to land in the bank before we query it.
113
+ await new Promise((r) => setTimeout(r, DIRECTIVE_SETTLE_MS));
114
+
115
+ // --- Phase 2: CAPTURE — an active directive references the rule ---
116
+ const directives = await fetchActiveDirectives(AGENT);
117
+ const matched = directives.some((d) => {
118
+ const hay = `${d.content ?? ""} ${d.name ?? ""}`;
119
+ return (
120
+ hay.includes(MARKER) ||
121
+ /end (?:every|each|your) repl/i.test(hay) ||
122
+ /marker/i.test(hay)
123
+ );
124
+ });
125
+ if (!matched) {
126
+ throw new Error(
127
+ `[directive-capture] CONTRACT FAILED (capture): no active directive ` +
128
+ `references the standing rule. The correction was correction-shaped ` +
129
+ `(the recall hook should have nudged create_directive) but nothing ` +
130
+ `persisted. Directives seen: ` +
131
+ `${JSON.stringify(directives.map((d) => d.content ?? d.name).slice(0, 8))}`,
132
+ );
133
+ }
134
+ expect(matched).toBe(true);
135
+ } finally {
136
+ await sc1.tearDown();
137
+ }
138
+
139
+ // --- Phase 3: /reset clears the session ---
140
+ const scReset = await spinUp({ agent: AGENT });
141
+ try {
142
+ await scReset.sendDM("/reset");
143
+ } finally {
144
+ await scReset.tearDown();
145
+ }
146
+ // Let the reset complete + the bridge reattach before probing.
147
+ await new Promise((r) => setTimeout(r, RESET_SETTLE_MS));
148
+
149
+ // --- Phase 4: SURVIVAL — a fresh neutral turn still honors the rule ---
150
+ const sc2 = await spinUp({ agent: AGENT });
151
+ try {
152
+ await sc2.sendDM(
153
+ `What is 2 + 2? Answer normally — nothing special about this message.`,
154
+ );
155
+ const reply = await sc2.expectMessage(isReply, {
156
+ from: "bot",
157
+ timeout: POSTRESET_REPLY_BUDGET_MS,
158
+ });
159
+ expect(reply.text.length).toBeGreaterThan(0);
160
+
161
+ // The rule was NOT in this session's context (it was wiped by
162
+ // /reset). If the marker appears, the directive was re-injected from
163
+ // the bank on recall — the correction stuck.
164
+ const honored = reply.text.includes(MARKER);
165
+ if (!honored) {
166
+ throw new Error(
167
+ `[directive-capture] CONTRACT FAILED (survival): after /reset the ` +
168
+ `agent did not honor the standing rule — marker ${MARKER} absent ` +
169
+ `from the reply, so the directive did not survive/re-inject. Reply: ` +
170
+ `${JSON.stringify(reply.text.slice(0, 400))}`,
171
+ );
172
+ }
173
+ expect(honored).toBe(true);
174
+ } finally {
175
+ await sc2.tearDown();
176
+ }
177
+ },
178
+ CORRECTION_REPLY_BUDGET_MS +
179
+ DIRECTIVE_SETTLE_MS +
180
+ RESET_SETTLE_MS +
181
+ POSTRESET_REPLY_BUDGET_MS +
182
+ 30_000,
183
+ );
184
+ },
185
+ );