switchroom 0.17.6 → 0.17.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/agent-scheduler/index.js +38 -4
  2. package/dist/auth-broker/index.js +302 -203
  3. package/dist/cli/notion-write-pretool.mjs +35 -2
  4. package/dist/cli/switchroom.js +1178 -576
  5. package/dist/host-control/main.js +148 -14
  6. package/dist/vault/approvals/kernel-server.js +140 -55
  7. package/dist/vault/broker/server.js +142 -57
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/bridge/bridge.ts +80 -1
  13. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  14. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  15. package/telegram-plugin/consolidation-legibility.ts +279 -0
  16. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  17. package/telegram-plugin/dist/gateway/gateway.js +2565 -610
  18. package/telegram-plugin/dist/server.js +86 -2
  19. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  20. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  21. package/telegram-plugin/gateway/gateway.ts +1376 -82
  22. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  23. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  24. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  25. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  26. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  27. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  28. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  29. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  30. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  31. package/telegram-plugin/memory-legibility.ts +217 -0
  32. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  33. package/telegram-plugin/scoped-approval.ts +59 -0
  34. package/telegram-plugin/silent-end.ts +78 -0
  35. package/telegram-plugin/subagent-watcher.ts +60 -6
  36. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  38. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  39. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  40. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  41. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  42. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  43. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  44. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  45. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  46. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  47. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  48. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  49. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  50. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  51. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  52. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  53. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  54. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  55. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  56. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  57. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  58. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  59. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  60. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  61. package/telegram-plugin/uat/assertions.ts +88 -4
  62. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  63. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  64. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  65. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  66. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  71. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  72. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  73. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  74. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  75. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  77. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  81. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  94. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  95. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  96. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  97. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  98. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  126. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  127. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  129. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,77 @@
1
+ """Switchroom gateway inbound-envelope grammar — the ONE place this vendored
2
+ Python knows about switchroom's ``<channel source="...">`` wire format.
3
+
4
+ SWITCHROOM DIVERGENCE (not upstream Hindsight). This module isolates the
5
+ coupling between the vendored Python hooks and the switchroom telegram-plugin
6
+ gateway's inbound envelope. It exists so a change to the TS-side envelope
7
+ grammar has ONE obvious Python counterpart to update — instead of the grammar
8
+ being buried inline inside directive_verify.py where a silent TS format change
9
+ could break a Python guard undetected (#2903 Fix 6.3).
10
+
11
+ The gateway prepends a leading ``<channel source="..." ...>`` envelope to every
12
+ SYNTHESIZED / non-human inbound it injects into the model (cron fires, reaction
13
+ dispatches, resume synthetics, vault-grant approvals, sub-agent handbacks,
14
+ obligation representations, …). See, on the TS side:
15
+
16
+ * telegram-plugin/gateway/*-inbound-builder.ts (and the reaction / resume /
17
+ vault-grant / subagent / obligation builders) — they emit
18
+ ``<channel source="<name>" ...>``.
19
+ * A genuine human message from the primary channel carries
20
+ ``source="telegram"``; interactive (non-gateway) sessions carry NO envelope
21
+ at all.
22
+
23
+ Contract pinned here (keep in lockstep with the gateway):
24
+ * ``CHANNEL_SOURCE_RE`` — extracts the ``source`` attribute from the leading
25
+ envelope. Handles both open (``<channel source="telegram" ...>``) and
26
+ self-closing (``<channel source="reaction"/>``) forms.
27
+ * ``HUMAN_INBOUND_SOURCES`` — the whitelist of ``source`` values that denote a
28
+ GENUINE human turn. A whitelist (not a blacklist) so any NEW synthetic
29
+ source the gateway adds is treated as machine-authored by default rather
30
+ than accidentally classified human.
31
+
32
+ If the gateway ever renames the envelope tag, changes the attribute name, or
33
+ adds a new human-facing channel, update THIS module (and its test,
34
+ tests/test_switchroom_envelope.py). Nothing else in the Python tree should hard
35
+ -code the ``<channel source=`` grammar.
36
+ """
37
+
38
+ import re
39
+
40
+ # Genuine human inbound channels. A "user" turn whose `<channel source="...">`
41
+ # envelope names anything else is a SYNTHESIZED / cron inbound built by the
42
+ # gateway — a machine turn, NOT a human correction.
43
+ HUMAN_INBOUND_SOURCES = frozenset({"telegram"})
44
+
45
+ # Pulls the source attribute out of the gateway's leading `<channel ...>`
46
+ # envelope. Handles both open (`<channel source="telegram" ...>`) and
47
+ # self-closing (`<channel source="reaction"/>`) forms.
48
+ CHANNEL_SOURCE_RE = re.compile(r"""<channel\b[^>]*\bsource=["']([^"']+)["']""")
49
+
50
+
51
+ def envelope_source(text) -> "str | None":
52
+ """Return the lower-cased ``source`` of the leading channel envelope, or
53
+ None when there is no envelope (interactive session). Pure regex."""
54
+ if not isinstance(text, str):
55
+ return None
56
+ m = CHANNEL_SOURCE_RE.search(text)
57
+ if not m:
58
+ return None
59
+ return m.group(1).strip().lower()
60
+
61
+
62
+ def is_synthetic_inbound(text) -> bool:
63
+ """True when this turn's opening message is a synthesized/cron inbound
64
+ rather than a genuine human message.
65
+
66
+ Detection is purely on the gateway-prepended ``<channel source="...">``
67
+ envelope: a ``source`` outside ``HUMAN_INBOUND_SOURCES`` (``cron``,
68
+ ``reaction``, ``resume_interrupted``, ``vault_grant_approved``,
69
+ ``subagent_handback``, ``obligation_represent``, …) marks a non-human turn.
70
+ No envelope (interactive session) → treated as human. Injected channel tags
71
+ in a human's own body are neutralised upstream by the channel-envelope
72
+ sanitiser, so the only real ``<channel source=`` is the gateway's.
73
+ """
74
+ source = envelope_source(text)
75
+ if source is None:
76
+ return False
77
+ return source not in HUMAN_INBOUND_SOURCES
@@ -597,6 +597,128 @@ def _is_trivial_stateless(ack_form, stripped):
597
597
  return False
598
598
 
599
599
 
600
+ # Switchroom #2848 Stage B — deterministic directive-capture nudge.
601
+ #
602
+ # Stage A audit (issue #2848) measured a ~55% miss rate on durable
603
+ # corrections: the model is instructed (guidance-only, in
604
+ # profiles/default/CLAUDE.md.hbs) to persist standing rules with
605
+ # mcp__hindsight__create_directive, but does so inconsistently — capture
606
+ # is a per-agent lottery (the same broadcast correction was captured by
607
+ # one agent and silently dropped by two others). This adds DETERMINISTIC
608
+ # detection of correction / standing-rule-shaped inbound (pure regex — NO
609
+ # model callsite; the claude-native invariant forbids a classifier call)
610
+ # and appends a terse advisory nudge to the UserPromptSubmit
611
+ # additionalContext. The MODEL makes the judgment IN-SESSION and calls
612
+ # create_directive itself (visible in chat); the hook NEVER writes a
613
+ # directive on its own — a silent hook-side write would break
614
+ # chat-legibility and edge the no-self-escalation invariant.
615
+ #
616
+ # On by default (Stage A proved a real gap → defaults principle);
617
+ # operators opt out per-agent via memory.directive_capture_nudge=false in
618
+ # switchroom.yaml, which start.sh exports as
619
+ # HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE=false (recall.py falls back to the
620
+ # settings.json default, which switchroom pins on).
621
+ #
622
+ # Detection is intentionally inclusive (the nudge is cheap + advisory, so
623
+ # a modest false-positive rate is acceptable — the model just ignores it
624
+ # on a one-off), but a negative guard scrubs the obvious pleasantry
625
+ # shapes ("always happy to help", "never mind") BEFORE the positive
626
+ # match, so those don't fire on a bare "always"/"never".
627
+ _DIRECTIVE_NUDGE_NEGATIVE_RE = re.compile(
628
+ r"""(?ix)
629
+ (?:
630
+ never \s* mind
631
+ | nevermind
632
+ | always \s+ (?: happy | glad | welcome | here \s+ to \s+ help | open \s+ to )
633
+ | never \s+ (?: fails? | hurts? | too \s+ late | a \s+ dull )
634
+ | as \s+ always
635
+ | thanks? \s+ as \s+ always
636
+ | forever \s+ grateful
637
+ )
638
+ """
639
+ )
640
+
641
+ _DIRECTIVE_NUDGE_RE = re.compile(
642
+ r"""(?ix)
643
+ (?:
644
+ # --- explicit standing-rule framings ---
645
+ \b from \s+ now \s+ on \b
646
+ | \b going \s+ forward \b
647
+ | \b in \s+ (?: the \s+ )? future \b
648
+ | \b (?: as \s+ a \s+ )? general \s+ (?: rule | behaviou?r | policy | principle | agent \s+ behaviou?r ) \b
649
+ | \b as \s+ a \s+ rule \b
650
+ | \b i \s+ want \s+ you \s+ to \b
651
+ | \b you \s+ should \s+ (?: always | never ) \b
652
+ # --- always / never (pleasantries pre-scrubbed by the negative guard) ---
653
+ | \b always \b
654
+ | \b never \b
655
+ | \b no \s+ longer \b
656
+ | \b anymore \b
657
+ # --- prohibitions / behavioural corrections ---
658
+ | \b stop \s+ (?: doing | saying | using | calling | adding | making | being | sending | putting ) \b
659
+ | \b (?: do \s* n['’]? t | don['’]? t | do \s+ not ) \s+ (?: ever \s+ )?
660
+ (?: make | guess | assume | use | say | add | send | call | include | do | put | write | reply | respond | mention ) \b
661
+ | \b (?: do \s* n['’]? t | don['’]? t | do \s+ not ) \b [^.?!]{0,40} \b again \b
662
+ | \b (?: should \s* n['’]? t | shouldn['’]? t | should \s+ not ) \b
663
+ # --- preferences / identity ---
664
+ | \b (?: i | we ) \s+ (?: prefer | always \s+ want | never \s+ want ) \b
665
+ | \b i['’]? d \s+ prefer \b
666
+ | \b call \s+ me \b
667
+ # --- memory / reinforcement ---
668
+ | \b remember \s+ (?: to | that | this | always | never ) \b
669
+ | \b make \s+ sure \s+ (?: you | to | that | not | it ) \b
670
+ # --- explicit wrongness + restatement ---
671
+ | \b that['’]? s \s+ (?: wrong | not \s+ right | incorrect | not \s+ what ) \b
672
+ | \b (?: no ,? \s+ )? it['’]? s \s+ (?: actually | not ) \b
673
+ | \b (?: that['’]? s | it['’]? s ) \s+ (?: dead | deprecated | retired | closed ) \b
674
+ | \b we \s+ (?: now \s+ )? (?: use \s+ only | only \s+ use | no \s+ longer \s+ use ) \b
675
+ )
676
+ """
677
+ )
678
+
679
+ # Terse, advisory. The model decides IN-SESSION whether this is a durable
680
+ # rule and, if so, calls create_directive itself (chat-legible). Kept
681
+ # short so it costs a handful of tokens on a false positive.
682
+ _DIRECTIVE_CAPTURE_NUDGE = (
683
+ "<directive_capture_check>\n"
684
+ "The latest user message is correction- or standing-rule-shaped "
685
+ '(e.g. "always/never …", "from now on …", "stop doing …", a stated '
686
+ 'preference, or "that’s wrong, it’s …"). If it states a DURABLE rule '
687
+ "for how you should behave going forward — not a one-off request for "
688
+ "this task — persist it NOW with mcp__hindsight__create_directive "
689
+ "(verbatim, in the user’s own words) BEFORE you answer, so the "
690
+ "correction survives future sessions. UNLESS an equivalent active "
691
+ "directive already exists (see any <active_directives> block above) — "
692
+ "in that case it is already saved; do NOT create a duplicate. If it’s "
693
+ "only a one-off instruction, ignore this note and just answer.\n"
694
+ "</directive_capture_check>"
695
+ )
696
+
697
+
698
+ def looks_like_standing_rule(text) -> bool:
699
+ """Deterministic (regex-only) test for correction / standing-rule shape.
700
+
701
+ Pleasantry shapes ("always happy to help", "never mind") are scrubbed
702
+ BEFORE the positive match so they can't trip the bare always/never
703
+ signals. Returns False on empty / non-string input. No model call —
704
+ the model does the actual judgment in-session (issue #2848).
705
+ """
706
+ if not isinstance(text, str) or not text.strip():
707
+ return False
708
+ scrubbed = _DIRECTIVE_NUDGE_NEGATIVE_RE.sub(" ", text)
709
+ return bool(_DIRECTIVE_NUDGE_RE.search(scrubbed))
710
+
711
+
712
+ def _combine_context(base, nudge) -> str:
713
+ """Join the recall/directives context with the directive-capture nudge,
714
+ skipping empties. Either may be None/empty. The nudge is kept OUT of the
715
+ cached / last-recall context (it's transient per-inbound) and appended
716
+ only at emit time, so a cache hit re-derives it from the current prompt
717
+ rather than replaying a stale one."""
718
+ parts = [p for p in (base, nudge) if p]
719
+ return "\n\n".join(parts)
720
+
721
+
600
722
  def main():
601
723
  config = load_config()
602
724
 
@@ -665,6 +787,22 @@ def main():
665
787
  debug_log(config, f"Prompt is trivial/stateless ({_ack_form!r}), skipping recall")
666
788
  return
667
789
 
790
+ # Switchroom #2848 Stage B — directive-capture nudge. Deterministic
791
+ # (regex) detection of correction / standing-rule-shaped inbound; when
792
+ # it fires we append a terse advisory to the additionalContext telling
793
+ # the model to persist the rule with create_directive if it IS durable.
794
+ # Computed on `_stripped` (the channel-envelope-stripped text) so the
795
+ # `<channel …>` wrapper never trips detection. Runs AFTER the ack/
796
+ # trivial-skip gates (those return early and genuinely need no nudge)
797
+ # and leaves the recall/directive path below untouched — it only adds
798
+ # to whatever additionalContext that path emits. On by default;
799
+ # HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE=false (or memory.directive_capture_nudge:
800
+ # false) turns it off. No model callsite (claude-native invariant).
801
+ nudge_block = None
802
+ if config.get("directiveCaptureNudge", True) and looks_like_standing_rule(_stripped):
803
+ nudge_block = _DIRECTIVE_CAPTURE_NUDGE
804
+ debug_log(config, "Directive-capture nudge: inbound looks like a standing rule")
805
+
668
806
  session_id = hook_input.get("session_id") or ""
669
807
 
670
808
  # Switchroom #303 — push a "📚 recalling memories" status to the
@@ -771,7 +909,10 @@ def main():
771
909
  cached_context = None
772
910
  if cached_context is not None:
773
911
  debug_log(config, f"Recall cache HIT (key={cache_key[:12]}…) — skipping API call")
774
- _emit_cached_context(cached_context)
912
+ # #2848 — append the nudge to the cached context at emit time
913
+ # (the cache stores nudge-free context; the nudge is re-derived
914
+ # from the current prompt, so a hit can't replay a stale one).
915
+ _emit_cached_context(_combine_context(cached_context, nudge_block))
775
916
  _write_recall_log({
776
917
  "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
777
918
  "session_id": (session_id or "")[:32],
@@ -790,6 +931,7 @@ def main():
790
931
  "active_thread_id": active_thread_id,
791
932
  "active_topic_alias": active_topic_alias,
792
933
  "topic_filter_mode": _topic_filter_mode(),
934
+ "directive_nudge": bool(nudge_block),
793
935
  })
794
936
  return
795
937
  debug_log(config, f"Recall cache MISS (key={cache_key[:12]}…)")
@@ -1025,8 +1167,12 @@ def main():
1025
1167
  update_placeholder(placeholder_chat_id, "💭 thinking")
1026
1168
 
1027
1169
  # If neither block has content, there's nothing to inject — exit
1028
- # silently to avoid emitting an empty hookSpecificOutput.
1170
+ # silently to avoid emitting an empty hookSpecificOutput. #2848: unless
1171
+ # the directive-capture nudge fired, in which case emit the nudge alone
1172
+ # (a correction with no memories/directives still needs the reminder).
1029
1173
  if not directives_block and not memories_block:
1174
+ if nudge_block:
1175
+ _emit_cached_context(nudge_block)
1030
1176
  return
1031
1177
 
1032
1178
  # Compose final context. Directives block goes ABOVE memories so the
@@ -1092,13 +1238,16 @@ def main():
1092
1238
  "source_topics": source_topic_summary,
1093
1239
  "topic_filter_mode": topic_filter_mode,
1094
1240
  "topic_dropped": topic_dropped,
1241
+ "directive_nudge": bool(nudge_block),
1095
1242
  })
1096
1243
 
1097
- # Output JSON for Claude Code hook system
1244
+ # Output JSON for Claude Code hook system. #2848: append the
1245
+ # directive-capture nudge (if it fired) at emit time — it's kept out of
1246
+ # the cached / last-recall context above so it can't go stale.
1098
1247
  output = {
1099
1248
  "hookSpecificOutput": {
1100
1249
  "hookEventName": "UserPromptSubmit",
1101
- "additionalContext": context_message,
1250
+ "additionalContext": _combine_context(context_message, nudge_block),
1102
1251
  }
1103
1252
  }
1104
1253
  json.dump(output, sys.stdout)
@@ -147,6 +147,23 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
147
147
 
148
148
  debug_log(config, f"Retain hook_input keys: {list(hook_input.keys())} force={force}")
149
149
 
150
+ # Blocked-Stop double-fire guard (switchroom #2848 Phase 3): when
151
+ # directive_verify.py blocks a Stop, the model continues and the Stop
152
+ # event fires AGAIN, this time carrying ``stop_hook_active: true``.
153
+ # Retaining on that second fire would (a) POST a duplicate transcript
154
+ # document and (b) at retainEveryNTurns>1 call increment_turn_count a
155
+ # second time for the SAME logical turn — drifting the cadence throttle.
156
+ # Skip the re-fire entirely BEFORE any store or turn-count increment.
157
+ # A forced SessionEnd sweep (force=True) is a distinct hook event and
158
+ # must always flush, so it is exempt.
159
+ if not force and hook_input.get("stop_hook_active"):
160
+ debug_log(
161
+ config,
162
+ "Stop re-fire (stop_hook_active=true) — skipping retain to avoid "
163
+ "duplicate document / turn-count drift",
164
+ )
165
+ return {"status": "skipped", "reason": "stop_hook_active"}
166
+
150
167
  session_id = hook_input.get("session_id", "unknown")
151
168
  transcript_path = hook_input.get("transcript_path", "")
152
169
 
@@ -44,6 +44,15 @@ def build_hooks(plugin_root: str) -> dict:
44
44
  }
45
45
  ],
46
46
  "Stop": [
47
+ {
48
+ "hooks": [
49
+ {
50
+ "type": "command",
51
+ "command": f'python3 "{plugin_root}/scripts/directive_verify.py"',
52
+ "timeout": 10,
53
+ }
54
+ ]
55
+ },
47
56
  {
48
57
  "hooks": [
49
58
  {
@@ -0,0 +1,185 @@
1
+ """Switchroom #2848 Stage B — unit tests for the directive-capture nudge.
2
+
3
+ Stage A audit measured a ~55% miss rate on durable corrections: agents are
4
+ told (guidance-only) to persist standing rules with
5
+ `mcp__hindsight__create_directive`, but capture is a per-agent lottery. Stage B
6
+ adds DETERMINISTIC (regex) detection of correction / standing-rule-shaped
7
+ inbound in recall.py's UserPromptSubmit hook and appends a terse advisory nudge
8
+ to the turn's additionalContext — the model does the actual judgment in-session
9
+ (no model callsite; claude-native invariant).
10
+
11
+ These tests pin:
12
+ * True positives — the audit's real MISSES + the issue's listed shapes.
13
+ * True negatives — pleasantries and questions must NOT fire (guard against
14
+ "always happy to help" / "never mind" tripping the bare always/never).
15
+ * The nudge is APPENDED to context without disturbing recall output
16
+ (_combine_context), and the nudge string is the create_directive advisory.
17
+ * The trivial-stateless recall gate is unaffected — a correction is never
18
+ trivial-skipped, and greetings still are (no regression on recall.py:582).
19
+
20
+ Stdlib-only.
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import unittest
26
+
27
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
28
+ if SCRIPTS_DIR not in sys.path:
29
+ sys.path.insert(0, SCRIPTS_DIR)
30
+
31
+ from recall import ( # noqa: E402
32
+ _DIRECTIVE_CAPTURE_NUDGE,
33
+ _combine_context,
34
+ _is_trivial_stateless,
35
+ looks_like_standing_rule,
36
+ )
37
+
38
+
39
+ # Correction / standing-rule-shaped inbound that MUST fire the nudge.
40
+ # The first block is drawn verbatim (paraphrased) from Stage A's clear MISSES
41
+ # — the exact corrections the guidance-only path silently dropped.
42
+ CORRECTIONS = [
43
+ # --- Stage A clear misses ---
44
+ "you should always hyperlink linear issues when mentioning",
45
+ "i also want you to always provide a direct link to open the email",
46
+ "i want this behaviour of validate, dont assume as a general agent behaviour",
47
+ "don't make stuff up, investigate and validate",
48
+ "validate not assume, don't guess",
49
+ "We shouldn't publish confidential Buildkite data, so don't use names",
50
+ "The repo mekenthompson/switchroom is closed/dead. We use only switchroom/switchroom now",
51
+ # --- Stage A true captures (must also detect — the loop should fire on these) ---
52
+ "i never use them",
53
+ "We don't use streaming reply anymore",
54
+ "i always want you to help better format articles for readability",
55
+ # --- the issue's listed standing-rule shapes ---
56
+ "from now on, prefer British spelling",
57
+ "going forward, use GFM syntax",
58
+ "call me Ken, not Kenneth",
59
+ "stop doing that",
60
+ "stop using em-dashes",
61
+ "don't do that again",
62
+ "remember to close the loop with a summary",
63
+ "make sure you cite the file and line",
64
+ "make sure not to leak the token",
65
+ "no, it's actually the blue one",
66
+ "that's wrong",
67
+ "that's not what I asked for",
68
+ "I'd prefer you keep replies short",
69
+ "we prefer concise answers",
70
+ "in the future, ping me before deploying",
71
+ ]
72
+
73
+ # Pleasantries, one-off questions, acks — must NOT fire (false positives cost
74
+ # tokens + noise; the negative guard scrubs pleasantry shapes first).
75
+ NON_CORRECTIONS = [
76
+ "always happy to help!",
77
+ "I'm always glad to assist",
78
+ "never mind, forget it",
79
+ "nevermind that",
80
+ "thanks as always",
81
+ "as always, appreciate it",
82
+ "can you check the deploy status",
83
+ "what time is it",
84
+ "how do I fix this bug",
85
+ "tell me about the project",
86
+ "the CI issue is now closed", # 'closed' alone, not "it's/that's closed"
87
+ "please run the tests",
88
+ "what's the weather like",
89
+ # The <channel …> envelope wrapper on its own must never trigger.
90
+ '<channel user="ken" chat_id="123">',
91
+ ]
92
+
93
+
94
+ class TestDirectiveCaptureDetection(unittest.TestCase):
95
+ def test_corrections_fire_the_nudge(self):
96
+ for p in CORRECTIONS:
97
+ with self.subTest(prompt=p):
98
+ self.assertTrue(
99
+ looks_like_standing_rule(p),
100
+ f"expected standing-rule detection for {p!r}",
101
+ )
102
+
103
+ def test_pleasantries_and_questions_do_not_fire(self):
104
+ for p in NON_CORRECTIONS:
105
+ with self.subTest(prompt=p):
106
+ self.assertFalse(
107
+ looks_like_standing_rule(p),
108
+ f"FALSE POSITIVE: nudge would fire for {p!r}",
109
+ )
110
+
111
+ def test_empty_and_non_string_are_false(self):
112
+ for bad in ("", " ", None, 123, [], {}):
113
+ with self.subTest(value=bad):
114
+ self.assertFalse(looks_like_standing_rule(bad))
115
+
116
+ def test_pleasantry_scrub_does_not_mask_a_real_rule(self):
117
+ # A message that opens with a pleasantry AND states a real rule must
118
+ # still fire — the negative guard scrubs only the pleasantry span.
119
+ self.assertTrue(
120
+ looks_like_standing_rule(
121
+ "always happy to help — but from now on use British spelling"
122
+ )
123
+ )
124
+ self.assertTrue(
125
+ looks_like_standing_rule("never mind that, but always cite the file")
126
+ )
127
+
128
+
129
+ class TestNudgeAppendedWithoutDisturbingRecall(unittest.TestCase):
130
+ def test_nudge_string_is_the_create_directive_advisory(self):
131
+ self.assertIn("create_directive", _DIRECTIVE_CAPTURE_NUDGE)
132
+ self.assertIn("directive_capture_check", _DIRECTIVE_CAPTURE_NUDGE)
133
+
134
+ def test_nudge_carries_the_dedup_caveat(self):
135
+ # #2903 Fix 6.2: the nudge must tell the model NOT to re-create a rule
136
+ # that already exists as an active directive.
137
+ self.assertIn("already exists", _DIRECTIVE_CAPTURE_NUDGE)
138
+ self.assertIn("active_directives", _DIRECTIVE_CAPTURE_NUDGE)
139
+
140
+ def test_combine_appends_nudge_after_recall_block(self):
141
+ base = "<hindsight_memories>\n…\n</hindsight_memories>"
142
+ out = _combine_context(base, _DIRECTIVE_CAPTURE_NUDGE)
143
+ # Recall block preserved verbatim, nudge appended after it.
144
+ self.assertTrue(out.startswith(base))
145
+ self.assertTrue(out.endswith(_DIRECTIVE_CAPTURE_NUDGE))
146
+ self.assertIn(base + "\n\n", out)
147
+
148
+ def test_combine_with_no_nudge_leaves_recall_output_untouched(self):
149
+ base = "<hindsight_memories>\n…\n</hindsight_memories>"
150
+ self.assertEqual(_combine_context(base, None), base)
151
+
152
+ def test_combine_with_only_nudge_emits_nudge_alone(self):
153
+ self.assertEqual(
154
+ _combine_context("", _DIRECTIVE_CAPTURE_NUDGE), _DIRECTIVE_CAPTURE_NUDGE
155
+ )
156
+ self.assertEqual(
157
+ _combine_context(None, _DIRECTIVE_CAPTURE_NUDGE), _DIRECTIVE_CAPTURE_NUDGE
158
+ )
159
+
160
+ def test_combine_empty_is_empty(self):
161
+ self.assertEqual(_combine_context(None, None), "")
162
+ self.assertEqual(_combine_context("", ""), "")
163
+
164
+
165
+ class TestTrivialSkipUnaffected(unittest.TestCase):
166
+ """The nudge must not perturb the trivial-stateless recall gate
167
+ (recall.py:582). Corrections carry standing-rule signals and are never
168
+ stateless, so they must NOT be trivial-skipped; greetings still are."""
169
+
170
+ def test_corrections_are_never_trivial_skipped(self):
171
+ for p in CORRECTIONS:
172
+ with self.subTest(prompt=p):
173
+ self.assertFalse(
174
+ _is_trivial_stateless("", p),
175
+ f"correction {p!r} was trivial-skipped — recall gate regressed",
176
+ )
177
+
178
+ def test_greetings_still_trivial_skip(self):
179
+ for p in ("hi", "hello", "what time is it", "good morning"):
180
+ with self.subTest(prompt=p):
181
+ self.assertTrue(_is_trivial_stateless("", p))
182
+
183
+
184
+ if __name__ == "__main__":
185
+ unittest.main()