switchroom 0.21.14 → 0.21.16

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 (36) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +7 -2
  3. package/dist/auth-broker/index.js +7 -2
  4. package/dist/cli/notion-write-pretool.mjs +7 -2
  5. package/dist/cli/switchroom.js +2601 -1009
  6. package/dist/host-control/main.js +8 -3
  7. package/dist/vault/approvals/kernel-server.js +7 -2
  8. package/dist/vault/broker/server.js +7 -2
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +9 -0
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
  12. package/skills/mental-model-curator/SKILL.md +187 -56
  13. package/telegram-plugin/dist/gateway/gateway.js +11 -6
  14. package/vendor/hindsight-memory/hooks/hooks.json +10 -0
  15. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  16. package/vendor/hindsight-memory/scripts/lib/config.py +22 -0
  17. package/vendor/hindsight-memory/scripts/lib/directives.py +45 -7
  18. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
  19. package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
  20. package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
  21. package/vendor/hindsight-memory/scripts/recall.py +520 -5
  22. package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
  23. package/vendor/hindsight-memory/scripts/retain.py +167 -28
  24. package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_directives.py +52 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_query_timestamp.py +376 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -366,6 +366,7 @@ def select_retain_window(
366
366
  overlap_turns: int,
367
367
  all_messages: list,
368
368
  force: bool = False,
369
+ since_uuid: str | None = None,
369
370
  ) -> tuple:
370
371
  """Decide which messages to retain and whether to send as a full window.
371
372
 
@@ -396,6 +397,17 @@ def select_retain_window(
396
397
  the whole session even if per-turn windowing had an edge. This costs a
397
398
  full sweep only ONCE per session (at end), not per turn.
398
399
 
400
+ ``since_uuid`` (memory-RFC P1) makes that final sweep INCREMENTAL: when
401
+ ``force=True`` and a committed watermark uuid is supplied, only the
402
+ transcript tail after it is retained, instead of re-storing the whole
403
+ session on top of the per-window documents that already carry it. The value
404
+ is resolved by the caller (``run_retain`` reads the watermark, wrapped) and
405
+ passed in — this function stays pure and IO-free. ``since_uuid=None``
406
+ reproduces the whole-session sweep exactly, which is what the vendor
407
+ full-session path and the ``retainEveryNTurns==1`` path pass. The slice
408
+ degrades to the whole transcript on a missing/compacted anchor
409
+ (``watermark.tail_after``), so a forced sweep never emits an empty slice.
410
+
399
411
  Durability invariant (jtbd-memory-survives-restart UAT): the window
400
412
  always extends to the END of the transcript (``slice_last_turns_by_user_boundary``
401
413
  returns ``messages[start:]``), so the turn that just completed — the one
@@ -412,8 +424,21 @@ def select_retain_window(
412
424
  window_turns = max(retain_every_n, 1) + overlap_turns
413
425
  messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
414
426
  return messages_to_retain, True
427
+ # Forced (SessionEnd) chunked sweep with a committed watermark uuid: retain
428
+ # only the transcript tail after it, instead of re-storing the whole session
429
+ # on top of the N per-window documents that already carry the same content
430
+ # (memory-RFC P1). ``since_uuid`` is None on the vendor full-session path and
431
+ # at retainEveryNTurns==1 (the caller withholds it there — see run_retain),
432
+ # which preserves today's whole-session behaviour byte-for-byte.
433
+ #
434
+ # tail_after is PURE and IO-free (the watermark READ happens in run_retain,
435
+ # wrapped) and degrades to the whole transcript on a missing/compacted
436
+ # anchor, so this branch can never emit an empty slice into the retain seam.
437
+ if force and since_uuid is not None:
438
+ return watermark.tail_after(all_messages, since_uuid), True
415
439
  # Full session: vendor full-session mode, OR a forced (SessionEnd) chunked
416
- # sweep. Retain all messages, always as a full window.
440
+ # sweep with no watermark to slice against. Retain all messages, always as a
441
+ # full window.
417
442
  return list(all_messages), True
418
443
 
419
444
 
@@ -702,7 +727,7 @@ def build_retain_payload(
702
727
  }
703
728
 
704
729
 
705
- def run_retain(hook_input: dict, force: bool = False) -> dict:
730
+ def run_retain(hook_input: dict, force: bool = False, delta: bool = False) -> dict:
706
731
  """Run the auto-retain flow.
707
732
 
708
733
  Returns a status dict::
@@ -717,6 +742,22 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
717
742
  process (the SessionStart drainer, see ``drain_pending.py`` and
718
743
  ``lib/pending.py``). ``status="skipped"`` is the normal early-exit
719
744
  cases (auto-retain disabled, empty transcript, throttled chunk).
745
+
746
+ ``delta`` (M4 P-RET, per-turn delta retain — called ONLY from
747
+ ``prefetch.py``, itself gated on the ``memoryPrefetchEnabled`` kill
748
+ switch, default off): bypasses ``retainEveryNTurns`` entirely (the
749
+ caller already decided this turn fires) and slices ONLY the
750
+ transcript tail after the committed watermark. Ignored when
751
+ ``force=True`` (SessionEnd always takes the force path below).
752
+
753
+ **Document-id safety invariant (red-team M4 Finding A, BINDING):** a
754
+ delta retain is ALWAYS posted under a distinct, content-derived
755
+ ``document_id`` — the SAME strategy the ``retainEveryNTurns>1``
756
+ chunked path already uses — and NEVER under the bare ``{session_id}``
757
+ id the full-session/n==1 path reserves. Posting a tail slice under
758
+ ``{session_id}`` would UPSERT-OVERWRITE the whole session's stored
759
+ memory with just the latest turn on every successful turn (see
760
+ ``retain.py`` §4.3 note below and ``tests/test_retain_delta.py``).
720
761
  """
721
762
  # /private-mode enforcement (switchroom privacy PR1) — FIRST, before
722
763
  # load_config(). An OPEN private interval means the operator is discussing
@@ -785,35 +826,109 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
785
826
  retain_every_n = max(1, config.get("retainEveryNTurns", 1))
786
827
  retain_full_window = False
787
828
  messages_to_retain = all_messages
788
-
789
- # Respect retainEveryNTurns in both modes, unless force=True (SessionEnd final retain)
790
- if retain_every_n > 1 and not force:
791
- turn_count = increment_turn_count(session_id)
792
- if turn_count % retain_every_n != 0:
793
- next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
794
- debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
795
- return {"status": "skipped", "reason": "throttled"}
796
-
797
- # Window selection is decoupled from the throttle above — see
798
- # select_retain_window() for the switchroom-divergence rationale
799
- # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
800
- overlap_turns = config.get("retainOverlapTurns", 0)
801
- messages_to_retain, retain_full_window = select_retain_window(
802
- retain_mode, retain_every_n, overlap_turns, all_messages, force=force
803
- )
804
- if retain_mode == "chunked" and not force:
805
- window_turns = max(retain_every_n, 1) + overlap_turns
806
- debug_log(
807
- config,
808
- f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
809
- )
810
- elif retain_mode == "chunked" and force:
829
+ since_uuid = None
830
+ # True only for the per-turn delta path (M4 P-RET) — threads a distinct,
831
+ # content-derived document_id below (Finding A) instead of the
832
+ # retainEveryNTurns>1 / n==1 strategy.
833
+ delta_document_id_override = False
834
+
835
+ if delta and not force:
836
+ # M4 P-RET — per-turn delta retain, called ONLY from prefetch.py
837
+ # (itself gated on memoryPrefetchEnabled, default off). Bypasses the
838
+ # retainEveryNTurns throttle entirely: the caller already decided
839
+ # this turn fires, every turn, superseding the P5 cadence on a
840
+ # flipped agent (config comment mandated at the switchroom.yaml
841
+ # seam — see P-CFG). Slices ONLY the transcript tail after the
842
+ # committed watermark; the §4.3 HAZARD discipline applies here too —
843
+ # any watermark-read failure MUST degrade to the full window, never
844
+ # raise, never emit an empty slice.
845
+ try:
846
+ _wm = watermark.load(session_id)
847
+ since_uuid = _wm.get("last_uuid") if _wm else None
848
+ except Exception as e: # noqa: BLE001 - degrade, never raise into this seam
849
+ print(
850
+ "[Hindsight] watermark read failed for delta retain; "
851
+ f"falling back to full-window slice: {e}",
852
+ file=sys.stderr,
853
+ )
854
+ since_uuid = None
855
+ messages_to_retain = watermark.tail_after(all_messages, since_uuid)
856
+ retain_full_window = True
857
+ delta_document_id_override = True
811
858
  debug_log(
812
859
  config,
813
- f"Chunked retain, forced full-session sweep (SessionEnd): {len(all_messages)} messages",
860
+ f"Delta retain firing (since_uuid={since_uuid!r}, "
861
+ f"{len(messages_to_retain)}/{len(all_messages)} messages)",
814
862
  )
815
863
  else:
816
- debug_log(config, f"Full session retain: {len(all_messages)} messages")
864
+ # Respect retainEveryNTurns in both modes, unless force=True (SessionEnd final retain)
865
+ if retain_every_n > 1 and not force:
866
+ turn_count = increment_turn_count(session_id)
867
+ if turn_count % retain_every_n != 0:
868
+ next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
869
+ debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
870
+ return {"status": "skipped", "reason": "throttled"}
871
+
872
+ # Window selection is decoupled from the throttle above — see
873
+ # select_retain_window() for the switchroom-divergence rationale
874
+ # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
875
+ overlap_turns = config.get("retainOverlapTurns", 0)
876
+
877
+ # Incremental SessionEnd sweep (memory-RFC P1): a forced chunked sweep at
878
+ # retainEveryNTurns>1 retains only the transcript tail after the committed
879
+ # watermark, not the whole session on top of the N per-window documents that
880
+ # already carry it. The watermark READ is filesystem IO on a JSON file that
881
+ # could be truncated / permission-broken / absent, so it lives HERE (not in
882
+ # the pure select_retain_window) inside a catch-all try/except.
883
+ #
884
+ # §4.3 HAZARD: this is the one seam where a raise DELETES a turn rather than
885
+ # degrading it. Every failure mode below MUST degrade to since_uuid=None,
886
+ # which reproduces today's whole-session sweep exactly. The except catches
887
+ # Exception (not a named subset) on purpose — any raise here is worse than a
888
+ # wrong value. The n==1 and full-session paths deliberately never compute a
889
+ # watermark UNLESS the M4 delta mechanism is enabled for this agent
890
+ # (``memoryPrefetchEnabled`` — red-team Finding A2): with the mechanism off
891
+ # (the fleet default) the document id is {session_id} and a tail slice
892
+ # would TRUNCATE the stored document (§4.2), so they keep the full-session
893
+ # sweep. With it ON, a per-turn delta retain has already been posting
894
+ # content-derived slice documents and advancing this same watermark every
895
+ # turn, so the force sweep MUST also go incremental — otherwise it
896
+ # blindly re-posts the whole session under {session_id} on top of the
897
+ # per-turn delta documents (redundant storage, not the destructive
898
+ # truncation Finding A guards, but still wrong for cadence-1 agents).
899
+ if force and retain_mode == "chunked" and (
900
+ retain_every_n > 1 or config.get("memoryPrefetchEnabled")
901
+ ):
902
+ try:
903
+ _wm = watermark.load(session_id)
904
+ since_uuid = _wm.get("last_uuid") if _wm else None
905
+ except Exception as e: # noqa: BLE001 - degrade, never raise into this seam
906
+ print(
907
+ "[Hindsight] watermark read failed for incremental sweep; "
908
+ f"falling back to full-session sweep: {e}",
909
+ file=sys.stderr,
910
+ )
911
+ since_uuid = None
912
+
913
+ messages_to_retain, retain_full_window = select_retain_window(
914
+ retain_mode, retain_every_n, overlap_turns, all_messages, force=force,
915
+ since_uuid=since_uuid,
916
+ )
917
+ if retain_mode == "chunked" and not force:
918
+ window_turns = max(retain_every_n, 1) + overlap_turns
919
+ debug_log(
920
+ config,
921
+ f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
922
+ )
923
+ elif retain_mode == "chunked" and force:
924
+ _swept = "incremental (tail after watermark)" if since_uuid is not None else "full-session"
925
+ debug_log(
926
+ config,
927
+ f"Chunked retain, forced {_swept} sweep (SessionEnd): "
928
+ f"{len(messages_to_retain)}/{len(all_messages)} messages",
929
+ )
930
+ else:
931
+ debug_log(config, f"Full session retain: {len(all_messages)} messages")
817
932
 
818
933
  # Resolve API URL
819
934
  def _dbg(*a):
@@ -853,7 +968,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
853
968
  # - Full-session mode / n==1: unchanged — a stable ``session_id`` base with
854
969
  # a compaction chunk counter so a shrinking transcript doesn't overwrite
855
970
  # the pre-compaction document with a shorter one.
856
- if retain_mode == "chunked" and retain_every_n > 1:
971
+ if delta_document_id_override:
972
+ # M4 P-RET (red-team Finding A, BINDING): a delta retain ALWAYS gets
973
+ # a content-derived slice id — mirroring the retainEveryNTurns>1
974
+ # strategy — and must NEVER fall through to the bare {session_id}
975
+ # id below, which the full-session/n==1 path reserves and which a
976
+ # tail-slice POST would silently truncate.
977
+ document_id = None # build_retain_payload computes the content-derived id
978
+ elif retain_mode == "chunked" and retain_every_n > 1:
857
979
  document_id = None # build_retain_payload computes the content-derived id
858
980
  else:
859
981
  chunk_index, compacted = track_retention(session_id, len(all_messages))
@@ -953,6 +1075,23 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
953
1075
 
954
1076
 
955
1077
  def main():
1078
+ # M4 P-WIRE — when the prefetch mechanism is on for this agent,
1079
+ # `prefetch.py`'s OWN Stop-hook entry already performs a delta retain
1080
+ # every turn (see `prefetch.run_prefetch`, step 1). Running THIS Stop
1081
+ # hook's own (non-delta) retain as well would double-retain the same
1082
+ # turn: two independent POSTs, and at `retainEveryNTurns > 1` two
1083
+ # independent `increment_turn_count` calls drifting the cadence
1084
+ # throttle out of sync with itself. Skip here — flag OFF (the default,
1085
+ # every agent today) leaves this function's behaviour byte-identical to
1086
+ # pre-M4; this check is the ONLY new code on that path.
1087
+ try:
1088
+ _cfg = load_config()
1089
+ if _cfg.get("memoryPrefetchEnabled", False):
1090
+ debug_log(_cfg, "Stop retain: memoryPrefetchEnabled, delegating to prefetch.py")
1091
+ return
1092
+ except Exception: # pragma: no cover - defensive, never block the legacy path
1093
+ pass
1094
+
956
1095
  try:
957
1096
  hook_input = json.load(sys.stdin)
958
1097
  except (json.JSONDecodeError, EOFError):
@@ -0,0 +1,98 @@
1
+ """Switchroom — `retainToolCalls` must have an env channel, default unchanged.
2
+
3
+ RFC memory-redesign P4. `retainToolCalls` (whether retain stores tool_use
4
+ inputs + tool_result content) had a DEFAULTS entry of True but NO entry in
5
+ `ENV_OVERRIDES` and no scaffold stamp, so an operator could not reach it: env
6
+ is the TOP of the plugin's config precedence chain (DEFAULTS -> settings.json
7
+ -> ~/.hindsight/claude-code.json -> env), and without an env key
8
+ `HINDSIGHT_RETAIN_TOOL_CALLS` could not steer the plugin at all — including a
9
+ docker-exec'd retain/backfill that does not inherit the supervised settings.
10
+
11
+ This PR ships the SETTER ONLY. The invariants under test:
12
+
13
+ 1. `HINDSIGHT_RETAIN_TOOL_CALLS` is wired in `ENV_OVERRIDES` and maps to the
14
+ `retainToolCalls` config key.
15
+ 2. The shipped default is unchanged — `True` — so absent any override the
16
+ fleet behaves byte-identically (the load resolves True with no env set).
17
+ 3. Setting the env `false` actually LANDS as Python `False` over the True
18
+ default (proving `false` is reachable, which is the whole point of the
19
+ knob), and `true`/`1`/`yes` resolve back to True.
20
+
21
+ Stdlib-only.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import unittest
27
+ from unittest import mock
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 lib.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
34
+
35
+ ENV_NAME = "HINDSIGHT_RETAIN_TOOL_CALLS"
36
+ CONFIG_KEY = "retainToolCalls"
37
+
38
+
39
+ def _load_with(env):
40
+ """load_config() with a hermetic environment (no plugin/user settings)."""
41
+ with mock.patch.dict(os.environ, env, clear=True):
42
+ os.environ["CLAUDE_PLUGIN_ROOT"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
43
+ os.environ["HOME"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
44
+ return load_config()
45
+
46
+
47
+ class RetainToolCallsHasAChannel(unittest.TestCase):
48
+ def test_env_name_is_wired(self):
49
+ self.assertIn(
50
+ ENV_NAME,
51
+ ENV_OVERRIDES,
52
+ f"{ENV_NAME} exported nowhere / never read — the drift P4 closes",
53
+ )
54
+
55
+ def test_env_name_maps_to_the_expected_config_key(self):
56
+ self.assertEqual(ENV_OVERRIDES[ENV_NAME][0], CONFIG_KEY)
57
+
58
+ def test_env_name_is_typed_bool(self):
59
+ self.assertIs(ENV_OVERRIDES[ENV_NAME][1], bool)
60
+
61
+ def test_target_key_exists_in_defaults(self):
62
+ self.assertIn(CONFIG_KEY, DEFAULTS)
63
+
64
+
65
+ class DefaultIsUnchanged(unittest.TestCase):
66
+ """The critical P4 invariant: default stays True, byte-identical fleet."""
67
+
68
+ def test_shipped_default_is_true(self):
69
+ self.assertIs(DEFAULTS[CONFIG_KEY], True)
70
+
71
+ def test_no_env_reproduces_the_true_default(self):
72
+ cfg = _load_with({})
73
+ self.assertIs(cfg[CONFIG_KEY], True)
74
+
75
+
76
+ class ValuesActuallyLand(unittest.TestCase):
77
+ """The setter must be reachable: env `false` lands as Python False."""
78
+
79
+ def test_false_env_overrides_the_true_default(self):
80
+ cfg = _load_with({ENV_NAME: "false"})
81
+ self.assertIs(cfg[CONFIG_KEY], False)
82
+ self.assertNotEqual(cfg[CONFIG_KEY], DEFAULTS[CONFIG_KEY])
83
+
84
+ def test_zero_and_no_also_resolve_false(self):
85
+ for falsey in ("0", "no", "False"):
86
+ with self.subTest(value=falsey):
87
+ cfg = _load_with({ENV_NAME: falsey})
88
+ self.assertIs(cfg[CONFIG_KEY], False)
89
+
90
+ def test_true_env_resolves_true(self):
91
+ for truthy in ("true", "1", "yes", "TRUE"):
92
+ with self.subTest(value=truthy):
93
+ cfg = _load_with({ENV_NAME: truthy})
94
+ self.assertIs(cfg[CONFIG_KEY], True)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
@@ -243,6 +243,58 @@ class FormatActiveDirectivesBlockTests(unittest.TestCase):
243
243
  # The in-prompt footer is still emitted (agent-facing signal).
244
244
  self.assertIn("(+4 more, omitted)", out)
245
245
 
246
+ def test_overflow_footer_is_loud_and_operator_directed(self):
247
+ """memory-RFC P7 — the in-prompt overflow notice must be LOUD and tell
248
+ the agent to surface the drop to the operator this turn.
249
+
250
+ The pre-P7 block emitted only the quiet `(+N more, omitted)`
251
+ parenthetical, which reaches the agent but neither states that explicit
252
+ instructions were dropped nor directs the agent to relay it. This
253
+ asserts the OUTCOME of the loud in-turn channel: the rendered block, on
254
+ overflow, names the dropped count, the total, the cap, states the loss
255
+ in unmistakable terms, and instructs the agent to tell the operator.
256
+ A test that only checked `(+N more, omitted)` would still pass on the
257
+ pre-P7 code, so it would not guard this change; these assertions fail
258
+ without it.
259
+ """
260
+ total = MAX_DIRECTIVES + 3
261
+ directives = [
262
+ _directive(f"d{i}", f"c{i}", priority=total - i) for i in range(total)
263
+ ]
264
+ with patch("sys.stderr", new=StringIO()):
265
+ out = format_active_directives_block(directives)
266
+ # A loud, explicitly-labelled overflow banner (not a lone parenthetical).
267
+ self.assertIn("DIRECTIVE OVERFLOW", out)
268
+ # Names the loss in unmistakable terms, with count/total/cap.
269
+ self.assertIn("DROPPED", out)
270
+ self.assertIn("3 of", out) # omitted of total
271
+ self.assertIn(str(total), out)
272
+ self.assertIn(f"MAX_DIRECTIVES={MAX_DIRECTIVES}", out)
273
+ # Directs the agent to surface it to the OPERATOR this turn — the
274
+ # in-turn operator-visible channel P7 adds.
275
+ self.assertIn("operator", out.lower())
276
+ self.assertIn("ACTION", out)
277
+ # The literal marker is retained for the recall_log/doctor cross-checks
278
+ # and the dedup parser.
279
+ self.assertIn("(+3 more, omitted)", out)
280
+
281
+ def test_no_overflow_banner_when_under_cap(self):
282
+ """The loud banner must appear ONLY on overflow — an under-cap block is
283
+ unchanged and carries neither the banner nor the action directive."""
284
+ directives = [_directive(f"d{i}", f"c{i}", priority=5 - i) for i in range(3)]
285
+ with patch("sys.stderr", new=StringIO()) as fake_err:
286
+ out = format_active_directives_block(directives)
287
+ self.assertNotIn("DIRECTIVE OVERFLOW", out)
288
+ self.assertNotIn("ACTION", out)
289
+ self.assertNotIn("more, omitted", out)
290
+ self.assertEqual(fake_err.getvalue(), "")
291
+
292
+ def test_max_directives_is_unchanged_by_p7(self):
293
+ """P7 is the loud-channel PR only. Raising MAX_DIRECTIVES is a separate,
294
+ later change (RFC §5 P7: order is non-negotiable). Pin the constant so a
295
+ cap bump cannot ride in on this change unnoticed."""
296
+ self.assertEqual(MAX_DIRECTIVES, 30)
297
+
246
298
  def test_count_omitted_directives_matches_the_rendered_footer(self):
247
299
  """The recall_log's `directives_omitted` number must equal what the
248
300
  block actually dropped — it is the operator-visible record of it."""