switchroom 0.17.6 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -0,0 +1,516 @@
1
+ """Switchroom #2848 Stage C — unit tests for the Stop-hook directive verify.
2
+
3
+ Stage B (recall.py) nudges the model to persist a durable rule but still
4
+ relies on the model CHOOSING to call create_directive. Stage C
5
+ (directive_verify.py) closes the residual gap for the high-confidence case:
6
+ on Stop it re-checks the human turn against a NARROW durable-rule regex and,
7
+ if the turn stated a durable rule but recorded no create_directive call,
8
+ blocks the stop ONCE to re-prompt capture.
9
+
10
+ These tests pin:
11
+ * The high-precision durable detector — explicit standing-rule framings
12
+ fire; bare "always"/"stop …"/"don't …" and one-off requests do NOT
13
+ (the detector is a STRICT SUBSET of Stage B's inclusive one).
14
+ * Pleasantry scrub is inherited (Stage B negative guard).
15
+ * Transcript parsing — last human turn is isolated (tool_result-only and
16
+ injected recall/nudge blocks are skipped), and a create_directive
17
+ tool_use anywhere after it counts as captured.
18
+ * evaluate() end-to-end: block iff durable-rule-stated AND no directive
19
+ call AND feature on AND not stop_hook_active (loop guard).
20
+ * The block reason is the create_directive re-prompt with a one-off escape.
21
+
22
+ Stdlib-only.
23
+ """
24
+
25
+ import os
26
+ import sys
27
+ import unittest
28
+
29
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
30
+ if SCRIPTS_DIR not in sys.path:
31
+ sys.path.insert(0, SCRIPTS_DIR)
32
+
33
+ from directive_verify import ( # noqa: E402
34
+ _VERIFY_BLOCK_REASON,
35
+ directive_recorded_after,
36
+ evaluate,
37
+ find_last_human_turn,
38
+ is_synthetic_inbound,
39
+ looks_like_durable_directive,
40
+ )
41
+ from recall import looks_like_standing_rule # noqa: E402
42
+
43
+
44
+ # Explicit durable standing-rule framings that MUST fire the verify block.
45
+ DURABLE_POSITIVES = [
46
+ "From now on, use British spelling in everything you write.",
47
+ "Going forward, never open a PR without a test plan.",
48
+ "In the future, always run the linter before you commit.",
49
+ "As a rule, keep replies under three sentences.",
50
+ "As a general policy, don't tag me on weekends.",
51
+ "You should always cite the file path when you quote code.",
52
+ "You should never push directly to main.",
53
+ "You must never ping the whole channel.",
54
+ "I want you to always double-check the branch name first.",
55
+ "Please always address me formally.",
56
+ "Please never use em dashes.",
57
+ "I'd prefer you keep the summaries terse.",
58
+ "I prefer that you use tabs, not spaces.",
59
+ "Call me Ken, not Kenneth.",
60
+ "Remember to always sign off with my initials.",
61
+ "Remember that I'm on Sydney time.",
62
+ "Don't ever guess at config values again.",
63
+ ]
64
+
65
+ # Shapes that MUST NOT fire the verify block. Either genuine one-offs, or the
66
+ # inclusive-but-not-durable shapes Stage B nudges on but Stage C must not
67
+ # block on (bare always/never/stop without a standing frame), or pleasantries.
68
+ DURABLE_NEGATIVES = [
69
+ # one-off task requests
70
+ "Can you fix the failing test in auth.py?",
71
+ "Please deploy the staging branch now.",
72
+ "What's the status of the build?",
73
+ "Add a docstring to this function.",
74
+ "Run the tests and tell me what breaks.",
75
+ # inclusive-but-not-durable (Stage B may nudge, Stage C must not block):
76
+ # bare always/never/stop/don't and world-fact corrections carry too much
77
+ # one-off risk for a blocking re-prompt.
78
+ "always double-check that number for me here",
79
+ "stop the server so I can restart it",
80
+ "stop using that deprecated endpoint for this test",
81
+ "don't merge that yet, I'm still reviewing",
82
+ "that's wrong, the port is 8080",
83
+ "no longer needed for this one file, skip it",
84
+ "we don't use Jenkins anymore; it's all GitHub Actions now",
85
+ # pleasantries (shared negative scrub)
86
+ "always happy to help, thanks!",
87
+ "never mind, I figured it out.",
88
+ "as always, appreciate the quick turnaround.",
89
+ "thanks as always!",
90
+ # empty / junk
91
+ "",
92
+ " ",
93
+ "ok",
94
+ ]
95
+
96
+
97
+ class TestDurableDetector(unittest.TestCase):
98
+ def test_positives_fire(self):
99
+ for text in DURABLE_POSITIVES:
100
+ with self.subTest(text=text):
101
+ self.assertTrue(
102
+ looks_like_durable_directive(text),
103
+ f"expected durable-rule detection to FIRE on: {text!r}",
104
+ )
105
+
106
+ def test_negatives_do_not_fire(self):
107
+ for text in DURABLE_NEGATIVES:
108
+ with self.subTest(text=text):
109
+ self.assertFalse(
110
+ looks_like_durable_directive(text),
111
+ f"expected durable-rule detection NOT to fire on: {text!r}",
112
+ )
113
+
114
+ def test_non_string_input(self):
115
+ for bad in (None, 123, [], {}, object()):
116
+ self.assertFalse(looks_like_durable_directive(bad))
117
+
118
+ def test_durable_is_subset_of_standing_rule(self):
119
+ # Every high-confidence durable positive must ALSO be caught by Stage
120
+ # B's inclusive detector — Stage C never blocks on something Stage B
121
+ # wouldn't have nudged on.
122
+ for text in DURABLE_POSITIVES:
123
+ with self.subTest(text=text):
124
+ self.assertTrue(
125
+ looks_like_standing_rule(text),
126
+ f"durable positive not a subset of standing_rule: {text!r}",
127
+ )
128
+
129
+
130
+ class TestTranscriptParsing(unittest.TestCase):
131
+ def test_find_last_human_turn_plain_string(self):
132
+ msgs = [
133
+ {"role": "user", "content": "hello"},
134
+ {"role": "assistant", "content": "hi"},
135
+ {"role": "user", "content": "from now on use tabs"},
136
+ ]
137
+ idx, text = find_last_human_turn(msgs)
138
+ self.assertEqual(idx, 2)
139
+ self.assertEqual(text, "from now on use tabs")
140
+
141
+ def test_skips_tool_result_only_user_message(self):
142
+ msgs = [
143
+ {"role": "user", "content": "from now on use tabs"},
144
+ {
145
+ "role": "assistant",
146
+ "content": [{"type": "tool_use", "name": "Bash", "input": {}}],
147
+ },
148
+ {
149
+ "role": "user",
150
+ "content": [{"type": "tool_result", "content": "done"}],
151
+ },
152
+ ]
153
+ idx, text = find_last_human_turn(msgs)
154
+ self.assertEqual(idx, 0)
155
+ self.assertEqual(text, "from now on use tabs")
156
+
157
+ def test_skips_injected_recall_block(self):
158
+ msgs = [
159
+ {"role": "user", "content": "call me Ken"},
160
+ {
161
+ "role": "user",
162
+ "content": "<hindsight_memories>old stuff</hindsight_memories>",
163
+ },
164
+ ]
165
+ idx, text = find_last_human_turn(msgs)
166
+ self.assertEqual(idx, 0)
167
+ self.assertEqual(text, "call me Ken")
168
+
169
+ def test_extracts_text_from_list_content(self):
170
+ msgs = [
171
+ {
172
+ "role": "user",
173
+ "content": [
174
+ {"type": "text", "text": "as a rule, keep it terse"},
175
+ ],
176
+ }
177
+ ]
178
+ idx, text = find_last_human_turn(msgs)
179
+ self.assertEqual(idx, 0)
180
+ self.assertEqual(text, "as a rule, keep it terse")
181
+
182
+ def test_no_human_turn(self):
183
+ msgs = [{"role": "assistant", "content": "hi"}]
184
+ idx, text = find_last_human_turn(msgs)
185
+ self.assertIsNone(idx)
186
+ self.assertEqual(text, "")
187
+
188
+
189
+ class TestDirectiveDetection(unittest.TestCase):
190
+ def _mk(self, name):
191
+ return {
192
+ "role": "assistant",
193
+ "content": [{"type": "tool_use", "name": name, "input": {}}],
194
+ }
195
+
196
+ def test_detects_mcp_create_directive(self):
197
+ msgs = [
198
+ {"role": "user", "content": "call me Ken"},
199
+ self._mk("mcp__hindsight__create_directive"),
200
+ ]
201
+ self.assertTrue(directive_recorded_after(msgs, 0))
202
+
203
+ def test_detects_bare_create_directive(self):
204
+ msgs = [
205
+ {"role": "user", "content": "call me Ken"},
206
+ self._mk("create_directive"),
207
+ ]
208
+ self.assertTrue(directive_recorded_after(msgs, 0))
209
+
210
+ def test_ignores_other_tools(self):
211
+ msgs = [
212
+ {"role": "user", "content": "call me Ken"},
213
+ self._mk("mcp__hindsight__recall"),
214
+ self._mk("Bash"),
215
+ ]
216
+ self.assertFalse(directive_recorded_after(msgs, 0))
217
+
218
+ def test_only_counts_calls_after_index(self):
219
+ # A create_directive from an EARLIER turn does not count for this one.
220
+ msgs = [
221
+ self._mk("mcp__hindsight__create_directive"),
222
+ {"role": "user", "content": "call me Ken"},
223
+ {"role": "assistant", "content": "sure"},
224
+ ]
225
+ self.assertFalse(directive_recorded_after(msgs, 1))
226
+
227
+ def _mk_id(self, name, tid):
228
+ return {
229
+ "role": "assistant",
230
+ "content": [
231
+ {"type": "tool_use", "id": tid, "name": name, "input": {}}
232
+ ],
233
+ }
234
+
235
+ def _result(self, tid, is_error):
236
+ return {
237
+ "role": "user",
238
+ "content": [
239
+ {
240
+ "type": "tool_result",
241
+ "tool_use_id": tid,
242
+ "is_error": is_error,
243
+ "content": "boom" if is_error else "ok",
244
+ }
245
+ ],
246
+ }
247
+
248
+ def test_errored_create_directive_is_not_recorded(self):
249
+ # SWITCHROOM DIVERGENCE #2903 Fix 1.3: a create_directive whose result
250
+ # errored must NOT count as recorded, so the verifier re-prompts once.
251
+ msgs = [
252
+ {"role": "user", "content": "call me Ken"},
253
+ self._mk_id("mcp__hindsight__create_directive", "toolu_1"),
254
+ self._result("toolu_1", True),
255
+ ]
256
+ self.assertFalse(directive_recorded_after(msgs, 0))
257
+
258
+ def test_successful_create_directive_with_result_is_recorded(self):
259
+ msgs = [
260
+ {"role": "user", "content": "call me Ken"},
261
+ self._mk_id("mcp__hindsight__create_directive", "toolu_1"),
262
+ self._result("toolu_1", False),
263
+ ]
264
+ self.assertTrue(directive_recorded_after(msgs, 0))
265
+
266
+ def test_one_failed_one_successful_directive_is_recorded(self):
267
+ msgs = [
268
+ {"role": "user", "content": "call me Ken"},
269
+ self._mk_id("mcp__hindsight__create_directive", "toolu_1"),
270
+ self._result("toolu_1", True),
271
+ self._mk_id("mcp__hindsight__create_directive", "toolu_2"),
272
+ self._result("toolu_2", False),
273
+ ]
274
+ self.assertTrue(directive_recorded_after(msgs, 0))
275
+
276
+ def test_call_without_id_still_counts_as_attempt(self):
277
+ # Older/testing shape with no tool_use id has no pairable result.
278
+ msgs = [
279
+ {"role": "user", "content": "call me Ken"},
280
+ self._mk("mcp__hindsight__create_directive"),
281
+ ]
282
+ self.assertTrue(directive_recorded_after(msgs, 0))
283
+
284
+
285
+ class TestEvaluate(unittest.TestCase):
286
+ ON = {"directiveCaptureNudge": True}
287
+
288
+ def _hook(self, messages, transcript_path, stop_hook_active=False):
289
+ return {
290
+ "transcript_path": transcript_path,
291
+ "stop_hook_active": stop_hook_active,
292
+ }
293
+
294
+ def _write_transcript(self, messages):
295
+ import json
296
+ import tempfile
297
+
298
+ fd, path = tempfile.mkstemp(suffix=".jsonl")
299
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
300
+ for m in messages:
301
+ f.write(json.dumps({"type": m["role"], "message": m}) + "\n")
302
+ self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
303
+ return path
304
+
305
+ def test_blocks_on_durable_rule_without_directive(self):
306
+ msgs = [
307
+ {"role": "user", "content": "From now on, always use British spelling."},
308
+ {"role": "assistant", "content": "Got it, will do."},
309
+ ]
310
+ path = self._write_transcript(msgs)
311
+ reason = evaluate(self._hook(msgs, path), self.ON)
312
+ self.assertEqual(reason, _VERIFY_BLOCK_REASON)
313
+
314
+ def test_allows_when_directive_recorded(self):
315
+ msgs = [
316
+ {"role": "user", "content": "From now on, always use British spelling."},
317
+ {
318
+ "role": "assistant",
319
+ "content": [
320
+ {
321
+ "type": "tool_use",
322
+ "name": "mcp__hindsight__create_directive",
323
+ "input": {"text": "Always use British spelling."},
324
+ }
325
+ ],
326
+ },
327
+ ]
328
+ path = self._write_transcript(msgs)
329
+ self.assertIsNone(evaluate(self._hook(msgs, path), self.ON))
330
+
331
+ def test_allows_on_one_off_request(self):
332
+ msgs = [
333
+ {"role": "user", "content": "Please fix the failing test in auth.py."},
334
+ {"role": "assistant", "content": "Fixed."},
335
+ ]
336
+ path = self._write_transcript(msgs)
337
+ self.assertIsNone(evaluate(self._hook(msgs, path), self.ON))
338
+
339
+ def test_allows_when_feature_disabled(self):
340
+ msgs = [
341
+ {"role": "user", "content": "From now on, always use British spelling."},
342
+ {"role": "assistant", "content": "Got it."},
343
+ ]
344
+ path = self._write_transcript(msgs)
345
+ reason = evaluate(self._hook(msgs, path), {"directiveCaptureNudge": False})
346
+ self.assertIsNone(reason)
347
+
348
+ def test_loop_guard_does_not_reblock(self):
349
+ # Already blocked once this turn → respect model judgment, never loop.
350
+ msgs = [
351
+ {"role": "user", "content": "From now on, always use British spelling."},
352
+ {"role": "assistant", "content": "Got it."},
353
+ ]
354
+ path = self._write_transcript(msgs)
355
+ reason = evaluate(
356
+ self._hook(msgs, path, stop_hook_active=True), self.ON
357
+ )
358
+ self.assertIsNone(reason)
359
+
360
+ def test_empty_transcript_allows(self):
361
+ self.assertIsNone(evaluate(self._hook([], "/nonexistent/path.jsonl"), self.ON))
362
+
363
+ def test_never_raises_on_garbage(self):
364
+ # Defensive: evaluate must degrade to None, never raise.
365
+ self.assertIsNone(evaluate({}, self.ON))
366
+
367
+ # --- #2903 Fix 6.2: verify-knob split + directive dedup ------------------
368
+
369
+ def test_verify_knob_off_keeps_nudge_but_drops_block(self):
370
+ # directiveCaptureNudge on (Stage B still nudges) but
371
+ # directiveCaptureVerify off → the Stop block must NOT fire.
372
+ msgs = [
373
+ {"role": "user", "content": "From now on, always use British spelling."},
374
+ {"role": "assistant", "content": "Got it."},
375
+ ]
376
+ path = self._write_transcript(msgs)
377
+ cfg = {"directiveCaptureNudge": True, "directiveCaptureVerify": False}
378
+ self.assertIsNone(evaluate(self._hook(msgs, path), cfg))
379
+ # Sanity: with the knob left default (on) the same turn DOES block.
380
+ self.assertEqual(evaluate(self._hook(msgs, path), self.ON), _VERIFY_BLOCK_REASON)
381
+
382
+ def test_allows_when_rule_already_an_active_directive(self):
383
+ # The turn restates a rule that is ALREADY in the injected
384
+ # <active_directives> block; the model correctly declines to re-create
385
+ # it. The verifier must NOT block (dedup).
386
+ active_block = (
387
+ "<active_directives>\n"
388
+ "The following are HARD RULES the agent must follow on this turn.\n\n"
389
+ "1. [P10] spelling: Always use British spelling in all replies.\n"
390
+ "</active_directives>"
391
+ )
392
+ msgs = [
393
+ {"role": "user", "content": active_block},
394
+ {"role": "user", "content": "From now on, always use British spelling please."},
395
+ {"role": "assistant", "content": "That's already how I write — noted."},
396
+ ]
397
+ path = self._write_transcript(msgs)
398
+ self.assertIsNone(evaluate(self._hook(msgs, path), self.ON))
399
+
400
+ def test_blocks_when_active_directive_is_a_different_rule(self):
401
+ # An unrelated active directive must NOT suppress the block for a new
402
+ # durable rule.
403
+ active_block = (
404
+ "<active_directives>\n"
405
+ "The following are HARD RULES the agent must follow on this turn.\n\n"
406
+ "1. [P10] tz: Always show times in UTC.\n"
407
+ "</active_directives>"
408
+ )
409
+ msgs = [
410
+ {"role": "user", "content": active_block},
411
+ {"role": "user", "content": "From now on, always use British spelling."},
412
+ {"role": "assistant", "content": "Got it."},
413
+ ]
414
+ path = self._write_transcript(msgs)
415
+ self.assertEqual(evaluate(self._hook(msgs, path), self.ON), _VERIFY_BLOCK_REASON)
416
+
417
+
418
+ class TestSyntheticInbound(unittest.TestCase):
419
+ """Non-interactive turns (cron / synthesized inbounds) must never trip the
420
+ blocking directive re-prompt — they aren't human corrections.
421
+ """
422
+
423
+ def test_cron_source_is_synthetic(self):
424
+ self.assertTrue(
425
+ is_synthetic_inbound('<channel source="cron">Time for the daily digest.</channel>')
426
+ )
427
+
428
+ def test_various_synthetic_sources(self):
429
+ for src in (
430
+ "cron",
431
+ "reaction",
432
+ "resume_interrupted",
433
+ "resume_watchdog_timeout",
434
+ "vault_grant_approved",
435
+ "subagent_handback",
436
+ "obligation_represent",
437
+ ):
438
+ with self.subTest(src=src):
439
+ self.assertTrue(
440
+ is_synthetic_inbound(f'<channel source="{src}" foo="bar">body</channel>')
441
+ )
442
+
443
+ def test_self_closing_synthetic_source(self):
444
+ self.assertTrue(is_synthetic_inbound('<channel source="reaction"/>'))
445
+
446
+ def test_telegram_source_is_human(self):
447
+ self.assertFalse(
448
+ is_synthetic_inbound('<channel source="telegram" chat_id="1" user="ken">hi</channel>')
449
+ )
450
+
451
+ def test_no_envelope_is_human(self):
452
+ # Interactive session: no channel envelope → treated as human.
453
+ self.assertFalse(is_synthetic_inbound("From now on, always use British spelling."))
454
+
455
+ def test_non_string_is_not_synthetic(self):
456
+ for bad in (None, 123, [], {}):
457
+ self.assertFalse(is_synthetic_inbound(bad))
458
+
459
+ def test_cron_turn_with_durable_shape_does_not_block(self):
460
+ # A cron inbound whose text happens to contain a durable-rule phrasing
461
+ # must STILL be allowed — capture only makes sense on human turns.
462
+ import json
463
+ import tempfile
464
+
465
+ msgs = [
466
+ {
467
+ "role": "user",
468
+ "content": (
469
+ '<channel source="cron">Reminder: from now on, always '
470
+ "post the digest at 8am.</channel>"
471
+ ),
472
+ },
473
+ {"role": "assistant", "content": "Posted the digest."},
474
+ ]
475
+ fd, path = tempfile.mkstemp(suffix=".jsonl")
476
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
477
+ for m in msgs:
478
+ f.write(json.dumps({"type": m["role"], "message": m}) + "\n")
479
+ self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
480
+ hook = {"transcript_path": path, "stop_hook_active": False}
481
+ self.assertIsNone(evaluate(hook, {"directiveCaptureNudge": True}))
482
+
483
+ def test_telegram_turn_with_durable_shape_still_blocks(self):
484
+ # Control: the SAME durable phrasing on a genuine telegram inbound
485
+ # must still block (proves the synthetic guard didn't over-reach).
486
+ import json
487
+ import tempfile
488
+
489
+ msgs = [
490
+ {
491
+ "role": "user",
492
+ "content": (
493
+ '<channel source="telegram" chat_id="1" user="ken">From now '
494
+ "on, always use British spelling.</channel>"
495
+ ),
496
+ },
497
+ {"role": "assistant", "content": "Got it."},
498
+ ]
499
+ fd, path = tempfile.mkstemp(suffix=".jsonl")
500
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
501
+ for m in msgs:
502
+ f.write(json.dumps({"type": m["role"], "message": m}) + "\n")
503
+ self.addCleanup(lambda: os.path.exists(path) and os.remove(path))
504
+ hook = {"transcript_path": path, "stop_hook_active": False}
505
+ self.assertEqual(evaluate(hook, {"directiveCaptureNudge": True}), _VERIFY_BLOCK_REASON)
506
+
507
+
508
+ class TestBlockReason(unittest.TestCase):
509
+ def test_reason_mentions_create_directive_and_escape_hatch(self):
510
+ self.assertIn("create_directive", _VERIFY_BLOCK_REASON)
511
+ self.assertIn("one-off", _VERIFY_BLOCK_REASON)
512
+ self.assertIn("<directive_capture_verify>", _VERIFY_BLOCK_REASON)
513
+
514
+
515
+ if __name__ == "__main__":
516
+ unittest.main()
@@ -23,6 +23,8 @@ from lib.directives import ( # noqa: E402
23
23
  MAX_DIRECTIVES,
24
24
  fetch_active_directives,
25
25
  format_active_directives_block,
26
+ parse_active_directives_block,
27
+ rule_already_captured,
26
28
  )
27
29
 
28
30
 
@@ -207,5 +209,52 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
207
209
  self.assertIn("(+3 more, omitted)", out)
208
210
 
209
211
 
212
+ class TestDirectiveDedup(unittest.TestCase):
213
+ """#2903 Fix 6.2 — parse a rendered <active_directives> block back to
214
+ content strings, and detect whether a restated rule is already covered."""
215
+
216
+ def _block(self, *contents):
217
+ directives = [
218
+ {"name": f"d{i}", "content": c, "priority": 10 - i}
219
+ for i, c in enumerate(contents)
220
+ ]
221
+ return format_active_directives_block(directives)
222
+
223
+ def test_roundtrip_parse(self):
224
+ block = self._block(
225
+ "Always use British spelling in replies.",
226
+ "Show all times in UTC.",
227
+ )
228
+ parsed = parse_active_directives_block(block)
229
+ self.assertEqual(
230
+ parsed,
231
+ ["Always use British spelling in replies.", "Show all times in UTC."],
232
+ )
233
+
234
+ def test_parse_absent_block_is_empty(self):
235
+ self.assertEqual(parse_active_directives_block("no block here"), [])
236
+ self.assertEqual(parse_active_directives_block(None), [])
237
+
238
+ def test_captured_when_restated_rule_overlaps_existing(self):
239
+ contents = parse_active_directives_block(
240
+ self._block("Always use British spelling in all replies.")
241
+ )
242
+ self.assertTrue(
243
+ rule_already_captured("From now on, always use British spelling please.", contents)
244
+ )
245
+
246
+ def test_not_captured_for_unrelated_rule(self):
247
+ contents = parse_active_directives_block(self._block("Always show times in UTC."))
248
+ self.assertFalse(
249
+ rule_already_captured("From now on, always use British spelling.", contents)
250
+ )
251
+
252
+ def test_not_captured_against_empty_directives(self):
253
+ self.assertFalse(rule_already_captured("Always use British spelling.", []))
254
+
255
+ def test_empty_rule_is_not_captured(self):
256
+ self.assertFalse(rule_already_captured("", ["Always use British spelling."]))
257
+
258
+
210
259
  if __name__ == "__main__":
211
260
  unittest.main()
@@ -74,6 +74,7 @@ class _FakeClient:
74
74
  tags=None,
75
75
  tags_match=None,
76
76
  tag_groups=None,
77
+ prefer_observations=None,
77
78
  timeout=10,
78
79
  ):
79
80
  self.recall_calls.append(
@@ -18,12 +18,13 @@ Stdlib-only; runs under `python3 -m unittest discover tests/`.
18
18
  import os
19
19
  import sys
20
20
  import unittest
21
+ from unittest import mock
21
22
 
22
23
  SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
23
24
  if SCRIPTS_DIR not in sys.path:
24
25
  sys.path.insert(0, SCRIPTS_DIR)
25
26
 
26
- from retain import select_retain_window # noqa: E402
27
+ from retain import run_retain, select_retain_window # noqa: E402
27
28
 
28
29
 
29
30
  def _transcript(num_turns: int) -> list:
@@ -257,5 +258,69 @@ class SelectRetainWindowForce(unittest.TestCase):
257
258
  self.assertEqual(_user_turn_indices(result), [7, 8, 9]) # last 3 turns
258
259
 
259
260
 
261
+ class RunRetainStopHookActiveGuard(unittest.TestCase):
262
+ """switchroom #2848 Phase 3 — blocked-Stop double-fire guard.
263
+
264
+ When directive_verify.py blocks a Stop, the model continues and the Stop
265
+ event fires AGAIN with ``stop_hook_active: true``. run_retain must no-op on
266
+ that re-fire — no store, no increment_turn_count — so the same logical turn
267
+ can't produce a duplicate transcript document or drift the cadence counter.
268
+ """
269
+
270
+ _CONFIG = {"autoRetain": True, "retainEveryNTurns": 10, "retainMode": "chunked"}
271
+
272
+ def test_stop_hook_active_noops_no_store_no_increment(self):
273
+ hook_input = {
274
+ "session_id": "s1",
275
+ "transcript_path": "/some/path.jsonl",
276
+ "stop_hook_active": True,
277
+ }
278
+ with mock.patch("retain.load_config", return_value=self._CONFIG), \
279
+ mock.patch("retain.increment_turn_count") as inc, \
280
+ mock.patch("retain.read_transcript") as read_t, \
281
+ mock.patch("retain.get_api_url") as get_url:
282
+ result = run_retain(hook_input, force=False)
283
+ # Early-return skip with the dedicated reason.
284
+ self.assertEqual(result.get("status"), "skipped")
285
+ self.assertEqual(result.get("reason"), "stop_hook_active")
286
+ # No cadence increment, no transcript read, no API resolution/store.
287
+ inc.assert_not_called()
288
+ read_t.assert_not_called()
289
+ get_url.assert_not_called()
290
+
291
+ def test_first_stop_not_guarded(self):
292
+ # The FIRST Stop (no stop_hook_active) must NOT be short-circuited by
293
+ # this guard — it proceeds into normal retain flow (which we stub out
294
+ # right after the guard by making the transcript empty).
295
+ hook_input = {
296
+ "session_id": "s2",
297
+ "transcript_path": "/some/path.jsonl",
298
+ "stop_hook_active": False,
299
+ }
300
+ with mock.patch("retain.load_config", return_value=self._CONFIG), \
301
+ mock.patch("retain.read_transcript", return_value=[]) as read_t:
302
+ result = run_retain(hook_input, force=False)
303
+ # Got past the guard into the flow — proven by the transcript read.
304
+ read_t.assert_called_once()
305
+ self.assertEqual(result.get("status"), "skipped")
306
+ self.assertEqual(result.get("reason"), "empty transcript")
307
+
308
+ def test_forced_session_end_sweep_exempt_from_guard(self):
309
+ # A forced (SessionEnd) sweep is a distinct hook event and must always
310
+ # flush even if stop_hook_active happens to be set — the guard is
311
+ # gated on `not force`.
312
+ hook_input = {
313
+ "session_id": "s3",
314
+ "transcript_path": "/some/path.jsonl",
315
+ "stop_hook_active": True,
316
+ }
317
+ with mock.patch("retain.load_config", return_value=self._CONFIG), \
318
+ mock.patch("retain.read_transcript", return_value=[]) as read_t:
319
+ result = run_retain(hook_input, force=True)
320
+ # force=True bypasses the stop_hook_active guard → reaches transcript read.
321
+ read_t.assert_called_once()
322
+ self.assertEqual(result.get("reason"), "empty transcript")
323
+
324
+
260
325
  if __name__ == "__main__":
261
326
  unittest.main()