switchroom 0.21.16 → 0.21.18
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.
- package/dist/agent-scheduler/index.js +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +1492 -1136
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +9 -4
- package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
- package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
- package/telegram-plugin/uat/flip/allowlist.ts +349 -0
- package/telegram-plugin/uat/flip/gate.test.ts +153 -0
- package/telegram-plugin/uat/flip/gate.ts +232 -0
- package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
- package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
- package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
- package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
- package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
- package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
- package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
- package/telegram-plugin/uat/flip/recall-log.ts +178 -0
- package/telegram-plugin/uat/flip/report.ts +95 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
- package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
- package/telegram-plugin/uat/runners/scorer.ts +1 -1
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
- package/vendor/hindsight-memory/scripts/orientation.py +195 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
- package/vendor/hindsight-memory/scripts/recall.py +144 -11
- package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
- package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
- package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Memory v2 M3 (Surface-A) — directive-injection flip guard tests.
|
|
2
|
+
|
|
3
|
+
carve-M3.md §4/§8. The flip's whole point is DETERMINISTIC: once an agent has
|
|
4
|
+
migrated its standing directives into the CLAUDE.md rules block (M1) and the
|
|
5
|
+
operator sets `memory.inject_directives: false`, this hook must STOP injecting
|
|
6
|
+
the `<active_directives>` block on every turn — but ONLY when a non-empty rules
|
|
7
|
+
block physically carries the same guarantees. These tests assert on TRANSPORT
|
|
8
|
+
(what reaches stdout across all three recall emit surfaces), never on an
|
|
9
|
+
internal flag, and each one is written so it would FAIL on the pre-guard code
|
|
10
|
+
(directives always injected).
|
|
11
|
+
|
|
12
|
+
Three defenses under test:
|
|
13
|
+
* flag OFF + rules block present → SUPPRESS `<active_directives>`
|
|
14
|
+
* flag ON (default) → inject normally (control)
|
|
15
|
+
* flag OFF but NO/empty rules block → FAIL SAFE: still inject + degraded
|
|
16
|
+
canary notice (a mis-sequenced flip is loud, never silently memoryless)
|
|
17
|
+
|
|
18
|
+
The rules-block bytes come from the golden fixture generated by the TS
|
|
19
|
+
`renderRulesBlock` (pinned by src/memory/m3-rules-block-golden.test.ts), so a
|
|
20
|
+
format change on the TS side trips both languages.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import sys
|
|
28
|
+
import tempfile
|
|
29
|
+
import unittest
|
|
30
|
+
from unittest.mock import patch
|
|
31
|
+
|
|
32
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
33
|
+
if SCRIPTS_DIR not in sys.path:
|
|
34
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
35
|
+
|
|
36
|
+
import recall # noqa: E402
|
|
37
|
+
|
|
38
|
+
_FIXTURE = os.path.join(
|
|
39
|
+
os.path.dirname(__file__), "fixtures", "rules-block.golden.md"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _golden_rules_block() -> str:
|
|
44
|
+
with open(_FIXTURE, encoding="utf-8") as fh:
|
|
45
|
+
return fh.read()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# An empty rules block (markers present, only the `(none)` placeholder inside)
|
|
49
|
+
# — the shape M1 writes for an agent that has enabled the toolchain but added
|
|
50
|
+
# no rules yet. This MUST NOT count as a populated block (fail-safe fires).
|
|
51
|
+
_EMPTY_RULES_BLOCK = "\n".join(
|
|
52
|
+
[
|
|
53
|
+
"<!-- switchroom:rules:begin -->",
|
|
54
|
+
"Standing rules — sanctioned via the `memory rule` tool. Do not hand-edit;",
|
|
55
|
+
"edits made outside the tool break the tamper sentinel below.",
|
|
56
|
+
"",
|
|
57
|
+
"(none)",
|
|
58
|
+
"",
|
|
59
|
+
"<!-- switchroom:rules:sentinel sha256="
|
|
60
|
+
+ ("0" * 64)
|
|
61
|
+
+ " rules=0 -->",
|
|
62
|
+
"<!-- switchroom:rules:end -->",
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _DirectiveClient:
|
|
68
|
+
"""A client that always has one active directive AND one recallable memory.
|
|
69
|
+
|
|
70
|
+
`recall_called` records whether the noisy memory fetch ran — irrelevant to
|
|
71
|
+
the M3 assertions but kept so a test can prove recall still happens on the
|
|
72
|
+
non-flipped control (memory text present ⇒ synchronous path ran).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self.recall_called = False
|
|
77
|
+
|
|
78
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
79
|
+
return {"items": [{
|
|
80
|
+
"id": "d1", "name": "always-wave", "priority": 10,
|
|
81
|
+
"content": "always end replies with a wave",
|
|
82
|
+
}]}
|
|
83
|
+
|
|
84
|
+
def recall(self, bank_id, query, **kwargs):
|
|
85
|
+
self.recall_called = True
|
|
86
|
+
return {"results": [{
|
|
87
|
+
"text": "a real memory", "type": "fact",
|
|
88
|
+
"mentioned_at": "2026-01-01", "id": "m1", "scores": {"final": 0.9},
|
|
89
|
+
}]}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _DirectiveFlipTestBase(unittest.TestCase):
|
|
93
|
+
def setUp(self):
|
|
94
|
+
self._tmpdir = tempfile.mkdtemp(prefix="recall-m3-flip-test-")
|
|
95
|
+
self._prev_data = os.environ.get("CLAUDE_PLUGIN_DATA")
|
|
96
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = self._tmpdir
|
|
97
|
+
# Project dirs are resolved via CLAUDE_PROJECT_DIR / hook cwd. Pin the
|
|
98
|
+
# env to a NON-project sentinel so tests that rely on the hook `cwd`
|
|
99
|
+
# fallback aren't polluted by an inherited CLAUDE_PROJECT_DIR.
|
|
100
|
+
self._prev_proj = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
101
|
+
os.environ.pop("CLAUDE_PROJECT_DIR", None)
|
|
102
|
+
|
|
103
|
+
def tearDown(self):
|
|
104
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
105
|
+
for key, prev in (
|
|
106
|
+
("CLAUDE_PLUGIN_DATA", self._prev_data),
|
|
107
|
+
("CLAUDE_PROJECT_DIR", self._prev_proj),
|
|
108
|
+
):
|
|
109
|
+
if prev is None:
|
|
110
|
+
os.environ.pop(key, None)
|
|
111
|
+
else:
|
|
112
|
+
os.environ[key] = prev
|
|
113
|
+
|
|
114
|
+
def _project_dir(self, claude_md: str | None) -> str:
|
|
115
|
+
"""Create a project dir; write CLAUDE.md with `claude_md` if given."""
|
|
116
|
+
proj = tempfile.mkdtemp(prefix="proj-", dir=self._tmpdir)
|
|
117
|
+
if claude_md is not None:
|
|
118
|
+
with open(os.path.join(proj, "CLAUDE.md"), "w", encoding="utf-8") as fh:
|
|
119
|
+
fh.write(claude_md)
|
|
120
|
+
return proj
|
|
121
|
+
|
|
122
|
+
def _config(self, inject_directives):
|
|
123
|
+
cfg = {
|
|
124
|
+
"autoRecall": True,
|
|
125
|
+
"bankId": "test-bank",
|
|
126
|
+
"recallMaxTokens": 4096,
|
|
127
|
+
"recallBudget": "mid",
|
|
128
|
+
"recallContextTurns": 1,
|
|
129
|
+
"recallMaxQueryChars": 800,
|
|
130
|
+
"recallPromptPreamble": "",
|
|
131
|
+
"recallParallelDeadlineSeconds": 5,
|
|
132
|
+
"directivesCacheTtlSeconds": 0,
|
|
133
|
+
}
|
|
134
|
+
if inject_directives is not None:
|
|
135
|
+
cfg["injectDirectives"] = inject_directives
|
|
136
|
+
return cfg
|
|
137
|
+
|
|
138
|
+
def _run_main(self, prompt, config, cwd, client):
|
|
139
|
+
hook_input = {
|
|
140
|
+
"prompt": prompt,
|
|
141
|
+
"session_id": "test-session",
|
|
142
|
+
"transcript_path": "",
|
|
143
|
+
"cwd": cwd,
|
|
144
|
+
}
|
|
145
|
+
stdout = io.StringIO()
|
|
146
|
+
with patch("recall.load_config", return_value=config), \
|
|
147
|
+
patch("recall.get_api_url", return_value="http://fake"), \
|
|
148
|
+
patch("recall.HindsightClient", return_value=client), \
|
|
149
|
+
patch("recall.ensure_bank_mission"), \
|
|
150
|
+
patch("sys.stdin", io.StringIO(json.dumps(hook_input))), \
|
|
151
|
+
patch("sys.stdout", stdout):
|
|
152
|
+
recall.main()
|
|
153
|
+
return stdout.getvalue()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class RulesBlockDetectionTests(_DirectiveFlipTestBase):
|
|
157
|
+
"""Unit coverage for the physical-evidence check the guard turns on."""
|
|
158
|
+
|
|
159
|
+
def test_present_block_from_golden_fixture_is_detected(self):
|
|
160
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
161
|
+
self.assertTrue(recall.rules_block_present(proj))
|
|
162
|
+
|
|
163
|
+
def test_empty_none_placeholder_block_is_not_a_present_block(self):
|
|
164
|
+
proj = self._project_dir("# Agent\n\n" + _EMPTY_RULES_BLOCK)
|
|
165
|
+
self.assertFalse(
|
|
166
|
+
recall.rules_block_present(proj),
|
|
167
|
+
"the `(none)` placeholder must not count as a populated rules block",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def test_no_markers_is_not_a_present_block(self):
|
|
171
|
+
proj = self._project_dir("# Agent\n\njust some prose, no rules block.")
|
|
172
|
+
self.assertFalse(recall.rules_block_present(proj))
|
|
173
|
+
|
|
174
|
+
def test_missing_claude_md_is_not_a_present_block(self):
|
|
175
|
+
proj = self._project_dir(None)
|
|
176
|
+
self.assertFalse(recall.rules_block_present(proj))
|
|
177
|
+
|
|
178
|
+
def test_none_project_dir_is_not_a_present_block(self):
|
|
179
|
+
self.assertFalse(recall.rules_block_present(None))
|
|
180
|
+
|
|
181
|
+
def test_project_dir_prefers_env_then_hook_cwd(self):
|
|
182
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
183
|
+
# env wins when set
|
|
184
|
+
os.environ["CLAUDE_PROJECT_DIR"] = proj
|
|
185
|
+
self.assertEqual(recall._recall_project_dir({"cwd": "/nope"}), proj)
|
|
186
|
+
# falls back to hook cwd when env unset
|
|
187
|
+
os.environ.pop("CLAUDE_PROJECT_DIR", None)
|
|
188
|
+
self.assertEqual(recall._recall_project_dir({"cwd": proj}), proj)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DirectiveInjectionDecisionTests(_DirectiveFlipTestBase):
|
|
192
|
+
"""The pure decision function — the deterministic core the surfaces share."""
|
|
193
|
+
|
|
194
|
+
def test_flag_on_injects(self):
|
|
195
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
196
|
+
inject, notice = recall.directive_injection_decision(
|
|
197
|
+
self._config(True), {"cwd": proj}
|
|
198
|
+
)
|
|
199
|
+
self.assertTrue(inject)
|
|
200
|
+
self.assertIsNone(notice)
|
|
201
|
+
|
|
202
|
+
def test_flag_absent_defaults_to_inject(self):
|
|
203
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
204
|
+
inject, notice = recall.directive_injection_decision(
|
|
205
|
+
self._config(None), {"cwd": proj}
|
|
206
|
+
)
|
|
207
|
+
self.assertTrue(inject, "injectDirectives must default TRUE when unset")
|
|
208
|
+
self.assertIsNone(notice)
|
|
209
|
+
|
|
210
|
+
def test_flag_off_with_rules_block_suppresses(self):
|
|
211
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
212
|
+
inject, notice = recall.directive_injection_decision(
|
|
213
|
+
self._config(False), {"cwd": proj}
|
|
214
|
+
)
|
|
215
|
+
self.assertFalse(inject, "flipped agent with a rules block must suppress")
|
|
216
|
+
self.assertIsNone(notice)
|
|
217
|
+
|
|
218
|
+
def test_flag_off_without_rules_block_fails_safe(self):
|
|
219
|
+
proj = self._project_dir("# Agent\n\nno rules block here.")
|
|
220
|
+
inject, notice = recall.directive_injection_decision(
|
|
221
|
+
self._config(False), {"cwd": proj}
|
|
222
|
+
)
|
|
223
|
+
self.assertTrue(inject, "flag off + no rules block must FAIL SAFE (inject)")
|
|
224
|
+
self.assertIsNotNone(notice)
|
|
225
|
+
self.assertIn("injectDirectives is OFF", notice)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class MainPathTransportTests(_DirectiveFlipTestBase):
|
|
229
|
+
"""Surface 1 (synchronous main path) — assert on stdout transport."""
|
|
230
|
+
|
|
231
|
+
def test_flipped_in_budget_agent_suppresses_active_directives(self):
|
|
232
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
233
|
+
client = _DirectiveClient()
|
|
234
|
+
out = self._run_main("what did we decide about X?", self._config(False), proj, client)
|
|
235
|
+
# Recall still ran and returned a memory — but the directives block is gone.
|
|
236
|
+
self.assertTrue(out, "a flipped agent still emits its recalled memories")
|
|
237
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
238
|
+
self.assertNotIn(
|
|
239
|
+
"<active_directives>", ctx,
|
|
240
|
+
"flipped + in-budget agent must NOT inject the directives block",
|
|
241
|
+
)
|
|
242
|
+
self.assertNotIn("always end replies with a wave", ctx)
|
|
243
|
+
self.assertIn("a real memory", ctx, "non-directive recall must be untouched")
|
|
244
|
+
|
|
245
|
+
def test_unflipped_agent_still_injects_active_directives(self):
|
|
246
|
+
# Control: identical setup, flag ON — directives MUST still appear. A
|
|
247
|
+
# test that passed under the pre-guard code (always injects) is only
|
|
248
|
+
# valid because its sibling above proves suppression actually happens.
|
|
249
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
250
|
+
out = self._run_main("what did we decide about X?", self._config(True), proj, _DirectiveClient())
|
|
251
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
252
|
+
self.assertIn("<active_directives>", ctx)
|
|
253
|
+
self.assertIn("always end replies with a wave", ctx)
|
|
254
|
+
|
|
255
|
+
def test_flag_off_no_rules_block_fails_safe_with_notice(self):
|
|
256
|
+
proj = self._project_dir("# Agent\n\nno rules block here.")
|
|
257
|
+
out = self._run_main("what did we decide about X?", self._config(False), proj, _DirectiveClient())
|
|
258
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
259
|
+
# Fail-safe: directives STILL injected, plus the degraded canary notice.
|
|
260
|
+
self.assertIn("<active_directives>", ctx)
|
|
261
|
+
self.assertIn("always end replies with a wave", ctx)
|
|
262
|
+
self.assertIn("injectDirectives is OFF", ctx)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class DirectivesOnlyPathTransportTests(_DirectiveFlipTestBase):
|
|
266
|
+
"""Surface 2 (task-notification directives-only fast path)."""
|
|
267
|
+
|
|
268
|
+
def _run_directives_only(self, config, cwd, client):
|
|
269
|
+
return self._run_main(
|
|
270
|
+
"<task-notification>a background task finished</task-notification>",
|
|
271
|
+
config, cwd, client,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def test_flipped_in_budget_suppresses_on_task_notification(self):
|
|
275
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
276
|
+
out = self._run_directives_only(self._config(False), proj, _DirectiveClient())
|
|
277
|
+
self.assertEqual(
|
|
278
|
+
out, "",
|
|
279
|
+
"flipped agent's task-notification path must emit no directives",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def test_unflipped_injects_directives_on_task_notification(self):
|
|
283
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
284
|
+
out = self._run_directives_only(self._config(True), proj, _DirectiveClient())
|
|
285
|
+
self.assertTrue(out, "unflipped agent still injects directives on this path")
|
|
286
|
+
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
|
287
|
+
self.assertIn("<active_directives>", ctx)
|
|
288
|
+
self.assertIn("always end replies with a wave", ctx)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class PrefetchPathTransportTests(_DirectiveFlipTestBase):
|
|
292
|
+
"""Surface 3 (memory-prefetch fast path, `_handle_prefetch_buffer`)."""
|
|
293
|
+
|
|
294
|
+
def _run_prefetch(self, config, cwd, client):
|
|
295
|
+
hook_input = {
|
|
296
|
+
"prompt": "what did we decide about X?",
|
|
297
|
+
"session_id": "test-session",
|
|
298
|
+
"transcript_path": "",
|
|
299
|
+
"cwd": cwd,
|
|
300
|
+
}
|
|
301
|
+
stdout = io.StringIO()
|
|
302
|
+
with patch("recall.get_api_url", return_value="http://fake"), \
|
|
303
|
+
patch("recall.HindsightClient", return_value=client), \
|
|
304
|
+
patch("recall.recall_buffer.sentinel_exists", return_value=False), \
|
|
305
|
+
patch("recall.recall_buffer.read_if_fresh", return_value=(None, None)), \
|
|
306
|
+
patch("recall.read_state", return_value={}), \
|
|
307
|
+
patch("sys.stdout", stdout):
|
|
308
|
+
emitted = recall._handle_prefetch_buffer(config, hook_input, hook_input["prompt"])
|
|
309
|
+
return emitted, stdout.getvalue()
|
|
310
|
+
|
|
311
|
+
def test_flipped_in_budget_suppresses_on_prefetch_fast_path(self):
|
|
312
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
313
|
+
_emitted, out = self._run_prefetch(self._config(False), proj, _DirectiveClient())
|
|
314
|
+
self.assertNotIn(
|
|
315
|
+
"<active_directives>", out,
|
|
316
|
+
"flipped agent must not leak directives on the prefetch fast path",
|
|
317
|
+
)
|
|
318
|
+
self.assertNotIn("always end replies with a wave", out)
|
|
319
|
+
|
|
320
|
+
def test_unflipped_injects_directives_on_prefetch_fast_path(self):
|
|
321
|
+
proj = self._project_dir("# Agent\n\n" + _golden_rules_block())
|
|
322
|
+
_emitted, out = self._run_prefetch(self._config(True), proj, _DirectiveClient())
|
|
323
|
+
self.assertIn("<active_directives>", out)
|
|
324
|
+
self.assertIn("always end replies with a wave", out)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
unittest.main()
|