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.
Files changed (43) hide show
  1. package/dist/agent-scheduler/index.js +5 -0
  2. package/dist/auth-broker/index.js +5 -0
  3. package/dist/cli/notion-write-pretool.mjs +5 -0
  4. package/dist/cli/switchroom.js +1492 -1136
  5. package/dist/host-control/main.js +6 -1
  6. package/dist/vault/approvals/kernel-server.js +5 -0
  7. package/dist/vault/broker/server.js +5 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +9 -4
  11. package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
  12. package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
  13. package/telegram-plugin/uat/flip/allowlist.ts +349 -0
  14. package/telegram-plugin/uat/flip/gate.test.ts +153 -0
  15. package/telegram-plugin/uat/flip/gate.ts +232 -0
  16. package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
  17. package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
  18. package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
  19. package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
  20. package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
  21. package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
  22. package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
  23. package/telegram-plugin/uat/flip/recall-log.ts +178 -0
  24. package/telegram-plugin/uat/flip/report.ts +95 -0
  25. package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
  26. package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
  27. package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
  28. package/telegram-plugin/uat/runners/scorer.ts +1 -1
  29. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  30. package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
  31. package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
  32. package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
  33. package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
  34. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
  35. package/vendor/hindsight-memory/scripts/orientation.py +195 -0
  36. package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
  37. package/vendor/hindsight-memory/scripts/recall.py +144 -11
  38. package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
  39. package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
@@ -157,6 +157,110 @@ def _topic_filter_mode() -> str:
157
157
  return "soft-preamble"
158
158
 
159
159
 
160
+ # ── Memory v2 M3 (Surface-A) — directive-injection flip guard ───────────────
161
+ #
162
+ # Once an agent has migrated its standing directives into the CLAUDE.md rules
163
+ # block (Memory v2 M1) AND flipped `injectDirectives` OFF, this hook stops
164
+ # injecting the `<active_directives>` block on every turn: the rules block
165
+ # already carries the same guarantees inline, so re-injecting the directives is
166
+ # ~pure token waste (the whole point of M3 — ~49M directive-tokens/30d saved
167
+ # fleet-wide once the heavy agents follow ziggy).
168
+ #
169
+ # `injectDirectives` defaults TRUE (lib/config.py DEFAULTS); an agent is only
170
+ # flipped when switchroom.yaml sets `memory.inject_directives: false`, which
171
+ # plumbs to `HINDSIGHT_INJECT_DIRECTIVES=false` (lib/config.py ENV_OVERRIDES).
172
+ #
173
+ # The flip is DELIBERATELY guarded on physical evidence, not the flag alone:
174
+ # suppression fires IFF the flag is off AND a non-empty rules block is actually
175
+ # present in CLAUDE.md. If the flag is off but no rules block exists (mis-
176
+ # sequenced flip, or M1 not yet run for this agent), we FAIL SAFE — keep
177
+ # injecting directives and surface a one-line degraded-canary notice — rather
178
+ # than strip every guardrail the agent has. A mis-sequenced flip is therefore
179
+ # loud and non-destructive, never silently memory-less. Suppress-only: this
180
+ # NEVER deletes a directive, it only stops re-injecting it. Format-locked to
181
+ # src/memory/rules-block.ts (a golden-fixture test guards against TS drift).
182
+ # See carve-M3.md §2/§4.
183
+ RULES_BLOCK_BEGIN_MARKER = "<!-- switchroom:rules:begin -->"
184
+ RULES_BLOCK_END_MARKER = "<!-- switchroom:rules:end -->"
185
+
186
+ # A rendered rule line looks like `- **R-01** (source: X, added Y): text`. The
187
+ # literal `(none)` placeholder an empty block renders must NOT count as a rule
188
+ # (that is exactly the empty-block case we fail-safe on). Anchored, no
189
+ # unbounded alternation.
190
+ _RULES_BLOCK_RULE_LINE_RE = re.compile(r"^- \*\*R-", re.MULTILINE)
191
+
192
+ # config.py DEFAULTS key + settings.json field name for the flip flag.
193
+ INJECT_DIRECTIVES_CONFIG_KEY = "injectDirectives"
194
+
195
+ _DIRECTIVE_FLIP_DEGRADED_NOTICE = (
196
+ "⚠️ memory config: injectDirectives is OFF for this agent but no CLAUDE.md "
197
+ "rules block was found — still injecting <active_directives> this turn to "
198
+ "avoid a guardrail-less turn. Run the M1 rules-block migration (or "
199
+ "re-enable injectDirectives) to clear this notice."
200
+ )
201
+
202
+
203
+ def _recall_project_dir(hook_input: dict) -> str | None:
204
+ """Resolve the agent's project directory (where CLAUDE.md lives).
205
+
206
+ Prefers `CLAUDE_PROJECT_DIR` (set by Claude Code for hook subprocesses);
207
+ falls back to the hook payload's `cwd`. Returns None when neither is a
208
+ usable directory, so callers treat "can't locate the project dir" the same
209
+ as "no rules block" — fail-safe, keep injecting.
210
+ """
211
+ for candidate in (os.environ.get("CLAUDE_PROJECT_DIR"), hook_input.get("cwd")):
212
+ if candidate and os.path.isdir(candidate):
213
+ return candidate
214
+ return None
215
+
216
+
217
+ def rules_block_present(project_dir: str | None) -> bool:
218
+ """True iff CLAUDE.md in `project_dir` carries a NON-EMPTY rules block.
219
+
220
+ Non-empty means: the `switchroom:rules:begin/end` markers are both present
221
+ AND at least one rendered rule line (`- **R-…`) sits between them. The
222
+ `(none)` placeholder an empty block renders does NOT count. Any IO error →
223
+ False (fail-safe: absence of proof of a rules block keeps injection on).
224
+ """
225
+ if not project_dir:
226
+ return False
227
+ path = os.path.join(project_dir, "CLAUDE.md")
228
+ try:
229
+ with open(path, encoding="utf-8") as fh:
230
+ text = fh.read()
231
+ except OSError:
232
+ return False
233
+ begin = text.find(RULES_BLOCK_BEGIN_MARKER)
234
+ if begin == -1:
235
+ return False
236
+ end = text.find(RULES_BLOCK_END_MARKER, begin)
237
+ if end == -1:
238
+ return False
239
+ inner = text[begin + len(RULES_BLOCK_BEGIN_MARKER):end]
240
+ return _RULES_BLOCK_RULE_LINE_RE.search(inner) is not None
241
+
242
+
243
+ def directive_injection_decision(config: dict, hook_input: dict) -> tuple[bool, str | None]:
244
+ """Decide whether to inject the `<active_directives>` block this turn.
245
+
246
+ Returns `(inject, degraded_notice)`:
247
+ * flag ON (default) → (True, None) inject normally
248
+ * flag OFF and rules block present → (False, None) SUPPRESS (M3 flip)
249
+ * flag OFF but NO rules block present → (True, notice) FAIL SAFE: still
250
+ inject AND surface a one-line degraded-canary notice, so a mis-
251
+ sequenced flip is loud instead of silently guardrail-less.
252
+
253
+ Central to all three recall emit surfaces (main path, prefetch fast path,
254
+ task-notification directives-only path) so a flipped agent cannot leak
255
+ directives on a cache hit. Suppress-only — never deletes a directive.
256
+ """
257
+ if config.get(INJECT_DIRECTIVES_CONFIG_KEY, True):
258
+ return True, None
259
+ if rules_block_present(_recall_project_dir(hook_input)):
260
+ return False, None
261
+ return True, _DIRECTIVE_FLIP_DEGRADED_NOTICE
262
+
263
+
160
264
  def _filter_by_active_topic(results: list, active_thread_id: str | None) -> tuple[list, int]:
161
265
  """When hard-filter mode is on AND we know the active thread, drop
162
266
  any memory whose stored metadata.thread_id is set to a different
@@ -471,9 +575,17 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
471
575
  debug_log(config, f"Prefetch buffer: directive fetch failed: {exc}")
472
576
  directives_block = None
473
577
 
578
+ # Memory v2 M3 (Surface-A) — apply the directive-injection flip decision on
579
+ # the fast path too, so a flipped agent does not leak `<active_directives>`
580
+ # on a prefetch/cache hit. Suppress iff flag off AND rules block present;
581
+ # else keep injecting (and carry the degraded-canary notice, if any).
582
+ _inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
583
+ if not _inject_directives:
584
+ directives_block = None
585
+
474
586
  if payload is not None:
475
587
  memories_block = payload.get("context") or ""
476
- parts = [b for b in (directives_block, memories_block) if b]
588
+ parts = [b for b in (_dir_notice, directives_block, memories_block) if b]
477
589
  if not parts:
478
590
  return False
479
591
  _emit_cached_context("\n\n".join(parts))
@@ -486,13 +598,13 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
486
598
  last = read_state(LAST_RECALL_STATE) or {}
487
599
  stale_memories = last.get("memories_context") or ""
488
600
  stale_block = stale_recall_notice(stale_memories)
489
- parts = [b for b in (directives_block, stale_block) if b]
601
+ parts = [b for b in (_dir_notice, directives_block, stale_block) if b]
490
602
  if parts:
491
603
  _emit_cached_context("\n\n".join(parts))
492
604
  return True
493
605
 
494
- if directives_block:
495
- _emit_cached_context(directives_block)
606
+ if directives_block or _dir_notice:
607
+ _emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
496
608
  return True
497
609
 
498
610
  # Nothing fresh, nothing stale, nothing cached — say so explicitly
@@ -534,8 +646,15 @@ def _emit_directives_only(config: dict, hook_input: dict) -> None:
534
646
  debug_log(config, f"Task-notification skip: directive fetch failed: {exc}")
535
647
  return
536
648
 
537
- if directives_block:
538
- _emit_cached_context(directives_block)
649
+ # Memory v2 M3 (Surface-A) — apply the flip decision here too, so a flipped
650
+ # agent's directives-only fast path (task-notification junk gate) also
651
+ # suppresses `<active_directives>` once the rules block carries them.
652
+ _inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
653
+ if not _inject_directives:
654
+ directives_block = None
655
+
656
+ if directives_block or _dir_notice:
657
+ _emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
539
658
 
540
659
 
541
660
  def _is_demoted_memory(memory) -> bool:
@@ -2829,6 +2948,16 @@ def main():
2829
2948
  bank_errored = any(bt.get("errored") for bt in bank_timings)
2830
2949
 
2831
2950
  directives_block = format_active_directives_block(directives) if directives else None
2951
+
2952
+ # Memory v2 M3 (Surface-A) — directive-injection flip decision. Suppress the
2953
+ # `<active_directives>` block iff this agent is flipped (injectDirectives
2954
+ # OFF) AND a non-empty CLAUDE.md rules block physically carries the same
2955
+ # guarantees; else keep injecting and carry a degraded-canary notice. This
2956
+ # is the primary (synchronous) surface; the two fast paths above mirror it.
2957
+ _inject_directives, directive_flip_notice = directive_injection_decision(config, hook_input)
2958
+ if not _inject_directives:
2959
+ directives_block = None
2960
+
2832
2961
  if directives_block:
2833
2962
  debug_log(config, f"Injecting {len(directives)} active directives")
2834
2963
 
@@ -3267,12 +3396,13 @@ def main():
3267
3396
  # is precisely the turn on which the agent must not assume it remembers.
3268
3397
  # #3837: so is a set the score floor withheld entirely.
3269
3398
  if not directives_block and not memories_block and not transcript_fallback_block:
3270
- if degraded_block or withheld_block or nudge_block or profile_nudge_block:
3399
+ if degraded_block or withheld_block or nudge_block or profile_nudge_block or directive_flip_notice:
3271
3400
  _emit_cached_context(
3272
3401
  "\n\n".join(
3273
3402
  [
3274
3403
  b
3275
3404
  for b in (
3405
+ directive_flip_notice,
3276
3406
  degraded_block,
3277
3407
  withheld_block,
3278
3408
  nudge_block,
@@ -3346,14 +3476,17 @@ def main():
3346
3476
  "hookSpecificOutput": {
3347
3477
  "hookEventName": "UserPromptSubmit",
3348
3478
  "additionalContext": _combine_context(
3479
+ directive_flip_notice,
3349
3480
  _combine_context(
3350
3481
  _combine_context(
3351
- _combine_context(degraded_block, withheld_block),
3352
- context_message,
3482
+ _combine_context(
3483
+ _combine_context(degraded_block, withheld_block),
3484
+ context_message,
3485
+ ),
3486
+ nudge_block,
3353
3487
  ),
3354
- nudge_block,
3488
+ profile_nudge_block,
3355
3489
  ),
3356
- profile_nudge_block,
3357
3490
  ),
3358
3491
  }
3359
3492
  }
@@ -73,7 +73,16 @@ def build_hooks(plugin_root: str) -> dict:
73
73
  "timeout": 5,
74
74
  }
75
75
  ]
76
- }
76
+ },
77
+ {
78
+ "hooks": [
79
+ {
80
+ "type": "command",
81
+ "command": f'python3 "{plugin_root}/scripts/orientation.py"',
82
+ "timeout": 8,
83
+ }
84
+ ]
85
+ },
77
86
  ],
78
87
  "SessionEnd": [
79
88
  {
@@ -0,0 +1,9 @@
1
+ <!-- switchroom:rules:begin -->
2
+ Standing rules — sanctioned via the `memory rule` tool. Do not hand-edit;
3
+ edits made outside the tool break the tamper sentinel below.
4
+
5
+ - **R-01** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Always end replies to the user with a one-line summary.
6
+ - **R-02** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Never send email without explicit user approval.
7
+
8
+ <!-- switchroom:rules:sentinel sha256=533c23603bb3b72930c8c79b4f4970481b0ce7caf13c3e2bb63953b652b8a0ae rules=2 -->
9
+ <!-- switchroom:rules:end -->
@@ -0,0 +1,283 @@
1
+ """Memory v2 M5 — Surface B: orientation-at-boot (hook wiring).
2
+
3
+ End-to-end tests for orientation.py's run() — the SessionStart hook entry.
4
+ Every assertion is an OUTCOME (what reached stdout / whether a refresh was
5
+ requested / that boot was never blocked), not a call-path spy, so reverting the
6
+ fix it guards turns the test RED (carve-M5 §8 tautology-guard discipline).
7
+
8
+ Coverage:
9
+ T2 DARK by default: memoryOrientationEnabled off is a HARD no-op — no
10
+ stdout, no bank resolve, no network call (the red-team kill switch).
11
+ T2 Every failure path (server unreachable, no model, read error, empty
12
+ payload, degraded/undated model) degrades to a VISIBLE cold notice AND
13
+ enqueues a refresh AND never raises — boot is never blocked.
14
+ T3 A stale (1.5x-3x) model is injected WITH a visible prefix; a fresh model
15
+ is injected plainly; a degraded (>3x) model is NOT injected.
16
+ T4 Matcher-less re-fire: a "compact" SessionStart produces the SAME output
17
+ as "startup" (the free post-compaction re-seat — E-88).
18
+ main() always exits 0 (a non-zero SessionStart hook BLOCKS the turn).
19
+
20
+ Stdlib-only.
21
+ """
22
+
23
+ import io
24
+ import json
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ import unittest
29
+ from contextlib import redirect_stdout
30
+ from datetime import datetime, timedelta, timezone
31
+ from unittest import mock
32
+
33
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
34
+ if SCRIPTS_DIR not in sys.path:
35
+ sys.path.insert(0, SCRIPTS_DIR)
36
+
37
+ import orientation # noqa: E402
38
+
39
+
40
+ def _iso_hours_ago(hours):
41
+ return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
42
+
43
+
44
+ class _FakeClient:
45
+ """Stand-in for HindsightClient. Records the OUTCOME the hook depends on:
46
+ which bank was read, and returns scripted list/get responses (or raises)."""
47
+
48
+ def __init__(self, models=None, model_full=None, list_raises=None, get_raises=None):
49
+ self._models = models if models is not None else {"items": []}
50
+ self._full = model_full
51
+ self._list_raises = list_raises
52
+ self._get_raises = get_raises
53
+ self.listed_bank = None
54
+ self.got = None
55
+
56
+ def list_mental_models(self, bank_id, timeout=5):
57
+ self.listed_bank = bank_id
58
+ if self._list_raises:
59
+ raise self._list_raises
60
+ return self._models
61
+
62
+ def get_mental_model(self, bank_id, model_id, detail="full", timeout=5):
63
+ self.got = (bank_id, model_id)
64
+ if self._get_raises:
65
+ raise self._get_raises
66
+ return self._full
67
+
68
+
69
+ def _run(config, hook_input=None, client=None, api_raises=None):
70
+ """Drive orientation.run() with patched I/O; return (stdout_str, client)."""
71
+ hook_input = hook_input or {"source": "startup"}
72
+ buf = io.StringIO()
73
+ with mock.patch.object(orientation, "derive_bank_id", return_value="klanker"), \
74
+ mock.patch.object(orientation, "HindsightClient", return_value=client):
75
+ if api_raises is not None:
76
+ api_ctx = mock.patch.object(orientation, "get_api_url", side_effect=api_raises)
77
+ else:
78
+ api_ctx = mock.patch.object(
79
+ orientation, "get_api_url", return_value="http://127.0.0.1:18888"
80
+ )
81
+ with api_ctx, redirect_stdout(buf):
82
+ orientation.run(hook_input, config)
83
+ return buf.getvalue(), client
84
+
85
+
86
+ def _emitted_context(stdout_str):
87
+ """Parse the additionalContext out of the hook's stdout envelope, or None."""
88
+ stdout_str = stdout_str.strip()
89
+ if not stdout_str:
90
+ return None
91
+ payload = json.loads(stdout_str)
92
+ return payload["hookSpecificOutput"]["additionalContext"]
93
+
94
+
95
+ class _RefreshSpyMixin:
96
+ """Point HINDSIGHT_STATE_DIR at a tmpdir so enqueue_refresh's JSONL marker is
97
+ inspectable — the OUTCOME 'a refresh was requested' without a live scheduler."""
98
+
99
+ def setUp(self):
100
+ self._tmp = tempfile.mkdtemp()
101
+ self._env = mock.patch.dict(os.environ, {"HINDSIGHT_STATE_DIR": self._tmp})
102
+ self._env.start()
103
+
104
+ def tearDown(self):
105
+ self._env.stop()
106
+ import shutil
107
+ shutil.rmtree(self._tmp, ignore_errors=True)
108
+
109
+ def _refresh_requested(self):
110
+ marker = os.path.join(self._tmp, "orientation-refresh-pending.jsonl")
111
+ if not os.path.exists(marker):
112
+ return False
113
+ with open(marker) as f:
114
+ return any(line.strip() for line in f)
115
+
116
+
117
+ ENABLED = {"memoryOrientationEnabled": True, "memoryOrientationCadenceHours": 48}
118
+
119
+
120
+ class TestDarkByDefault(_RefreshSpyMixin, unittest.TestCase):
121
+ """T2 — the kill switch. Off (the default) is a hard, silent no-op."""
122
+
123
+ def test_disabled_emits_nothing_and_never_touches_client(self):
124
+ client = _FakeClient()
125
+ # A client whose methods would blow up if called — proves no I/O happens.
126
+ client.list_mental_models = mock.Mock(side_effect=AssertionError("must not read"))
127
+ stdout, _ = _run({"memoryOrientationEnabled": False}, client=client)
128
+ self.assertEqual(stdout.strip(), "")
129
+ self.assertFalse(self._refresh_requested())
130
+
131
+ def test_absent_flag_defaults_to_off(self):
132
+ # A stripped/absent value must fail to OFF (fail-safe).
133
+ client = _FakeClient()
134
+ client.list_mental_models = mock.Mock(side_effect=AssertionError("must not read"))
135
+ stdout, _ = _run({}, client=client)
136
+ self.assertEqual(stdout.strip(), "")
137
+
138
+
139
+ class TestColdPaths(_RefreshSpyMixin, unittest.TestCase):
140
+ """T2 — every failure degrades to a visible cold notice + refresh, never a block."""
141
+
142
+ def _assert_cold(self, stdout):
143
+ ctx = _emitted_context(stdout)
144
+ self.assertIsNotNone(ctx)
145
+ self.assertIn("not yet built or refreshed", ctx)
146
+ self.assertTrue(self._refresh_requested())
147
+
148
+ def test_server_unreachable(self):
149
+ stdout, _ = _run(ENABLED, client=None, api_raises=RuntimeError("no daemon"))
150
+ self._assert_cold(stdout)
151
+
152
+ def test_no_model_named_orientation(self):
153
+ client = _FakeClient(models={"items": [{"id": "x", "name": "something-else"}]})
154
+ stdout, _ = _run(ENABLED, client=client)
155
+ self._assert_cold(stdout)
156
+
157
+ def test_list_models_raises(self):
158
+ client = _FakeClient(list_raises=TimeoutError("read timed out"))
159
+ stdout, _ = _run(ENABLED, client=client)
160
+ self._assert_cold(stdout)
161
+
162
+ def test_model_read_raises(self):
163
+ client = _FakeClient(
164
+ models={"items": [{"id": "m1", "name": "orientation"}]},
165
+ get_raises=TimeoutError("read timed out"),
166
+ )
167
+ stdout, _ = _run(ENABLED, client=client)
168
+ self._assert_cold(stdout)
169
+
170
+ def test_empty_content(self):
171
+ client = _FakeClient(
172
+ models={"items": [{"id": "m1", "name": "orientation"}]},
173
+ model_full={"content": " ", "last_refreshed_at": _iso_hours_ago(1)},
174
+ )
175
+ stdout, _ = _run(ENABLED, client=client)
176
+ self._assert_cold(stdout)
177
+
178
+ def test_degraded_model_not_injected(self):
179
+ # Refreshed 200h ago at cadence 48 -> >3x -> degraded -> cold, never
180
+ # presented stale-as-fresh.
181
+ client = _FakeClient(
182
+ models={"items": [{"id": "m1", "name": "orientation"}]},
183
+ model_full={"content": "# Real\nbody", "last_refreshed_at": _iso_hours_ago(200)},
184
+ )
185
+ stdout, _ = _run(ENABLED, client=client)
186
+ self._assert_cold(stdout)
187
+
188
+ def test_undated_model_not_injected(self):
189
+ client = _FakeClient(
190
+ models={"items": [{"id": "m1", "name": "orientation"}]},
191
+ model_full={"content": "# Real\nbody", "last_refreshed_at": None},
192
+ )
193
+ stdout, _ = _run(ENABLED, client=client)
194
+ self._assert_cold(stdout)
195
+
196
+
197
+ class TestInjectPaths(_RefreshSpyMixin, unittest.TestCase):
198
+ """T3 — usable models are injected; fresh plainly, stale with a prefix."""
199
+
200
+ def test_fresh_model_injected_plainly(self):
201
+ client = _FakeClient(
202
+ models={"items": [{"id": "m1", "name": "orientation"}]},
203
+ model_full={"content": "# Brief\nthe orientation body",
204
+ "last_refreshed_at": _iso_hours_ago(2)},
205
+ )
206
+ stdout, _ = _run(ENABLED, client=client)
207
+ ctx = _emitted_context(stdout)
208
+ self.assertIn("the orientation body", ctx)
209
+ self.assertNotIn("may be stale", ctx)
210
+ # A usable inject does NOT request a refresh.
211
+ self.assertFalse(self._refresh_requested())
212
+ # It read the agent's OWN bank.
213
+ self.assertEqual(client.listed_bank, "klanker")
214
+
215
+ def test_stale_model_injected_with_prefix(self):
216
+ # 100h ago at cadence 48 -> 1.5x(72) < 100 < 3x(144) -> stale.
217
+ client = _FakeClient(
218
+ models={"items": [{"id": "m1", "name": "orientation"}]},
219
+ model_full={"content": "# Brief\nthe orientation body",
220
+ "last_refreshed_at": _iso_hours_ago(100)},
221
+ )
222
+ stdout, _ = _run(ENABLED, client=client)
223
+ ctx = _emitted_context(stdout)
224
+ self.assertIn("the orientation body", ctx)
225
+ self.assertIn("may be stale", ctx)
226
+
227
+
228
+ class TestCompactionReSeat(_RefreshSpyMixin, unittest.TestCase):
229
+ """T4 — matcher-less: a compaction SessionStart re-seats identically to startup."""
230
+
231
+ def _model(self):
232
+ return {"items": [{"id": "m1", "name": "orientation"}]}, {
233
+ "content": "# Brief\nbody text", "last_refreshed_at": _iso_hours_ago(2)
234
+ }
235
+
236
+ def test_compact_source_matches_startup_source(self):
237
+ models, full = self._model()
238
+ out_start, _ = _run(ENABLED, hook_input={"source": "startup"},
239
+ client=_FakeClient(models=models, model_full=full))
240
+ out_compact, _ = _run(ENABLED, hook_input={"source": "compact"},
241
+ client=_FakeClient(models=models, model_full=full))
242
+ self.assertEqual(_emitted_context(out_start), _emitted_context(out_compact))
243
+ self.assertIn("body text", _emitted_context(out_compact))
244
+
245
+
246
+ class TestNeverBlocksBoot(unittest.TestCase):
247
+ """The script must exit 0 on every path — a non-zero SessionStart hook BLOCKS
248
+ the turn in Claude Code. Driven as a real subprocess (the actual __main__
249
+ guard), because that guard is exactly what turns an internal raise into a
250
+ clean exit; an in-process patch of sys.exit could never catch a regression
251
+ where the guard itself is removed."""
252
+
253
+ def _run_script(self, env_extra, stdin_text="{}"):
254
+ import subprocess
255
+ env = dict(os.environ)
256
+ env.update(env_extra)
257
+ script = os.path.join(SCRIPTS_DIR, "orientation.py")
258
+ proc = subprocess.run(
259
+ [sys.executable, script],
260
+ input=stdin_text, capture_output=True, text=True, env=env, timeout=20,
261
+ )
262
+ return proc
263
+
264
+ def test_disabled_exits_zero_silent(self):
265
+ proc = self._run_script({"HINDSIGHT_ORIENTATION_ENABLED": "false"})
266
+ self.assertEqual(proc.returncode, 0)
267
+ self.assertEqual(proc.stdout.strip(), "")
268
+
269
+ def test_enabled_but_server_unreachable_exits_zero_with_cold_notice(self):
270
+ # Point at a closed port: the read fails, but boot must NOT block — exit
271
+ # 0 and a visible cold notice on stdout, never a hang or non-zero.
272
+ with tempfile.TemporaryDirectory() as tmp:
273
+ proc = self._run_script({
274
+ "HINDSIGHT_ORIENTATION_ENABLED": "true",
275
+ "HINDSIGHT_API_URL": "http://127.0.0.1:9", # discard port, refused
276
+ "HINDSIGHT_STATE_DIR": tmp,
277
+ })
278
+ self.assertEqual(proc.returncode, 0, proc.stderr)
279
+ self.assertIn("not yet built or refreshed", proc.stdout)
280
+
281
+
282
+ if __name__ == "__main__":
283
+ unittest.main()