switchroom 0.17.5 → 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 (158) hide show
  1. package/dist/agent-scheduler/index.js +39 -5
  2. package/dist/auth-broker/index.js +386 -208
  3. package/dist/cli/notion-write-pretool.mjs +36 -3
  4. package/dist/cli/switchroom.js +1185 -585
  5. package/dist/host-control/main.js +149 -15
  6. package/dist/vault/approvals/kernel-server.js +141 -56
  7. package/dist/vault/broker/server.js +143 -58
  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/auth-snapshot-format.ts +50 -2
  13. package/telegram-plugin/bridge/bridge.ts +80 -1
  14. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  15. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  16. package/telegram-plugin/consolidation-legibility.ts +279 -0
  17. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  18. package/telegram-plugin/dist/gateway/gateway.js +2597 -645
  19. package/telegram-plugin/dist/server.js +86 -2
  20. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  21. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  22. package/telegram-plugin/gateway/auth-command.ts +1 -1
  23. package/telegram-plugin/gateway/gateway.ts +1414 -119
  24. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  26. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  27. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  28. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  29. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  30. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  31. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  32. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  33. package/telegram-plugin/memory-legibility.ts +217 -0
  34. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  35. package/telegram-plugin/scoped-approval.ts +59 -0
  36. package/telegram-plugin/silent-end.ts +78 -0
  37. package/telegram-plugin/subagent-watcher.ts +60 -6
  38. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  40. package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -0
  41. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  42. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  43. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  44. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  45. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  46. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  48. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  49. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  50. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  51. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  52. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  53. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  54. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  55. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  56. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  57. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  58. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  59. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  60. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  61. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  62. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  63. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  64. package/telegram-plugin/uat/assertions.ts +88 -4
  65. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  66. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  71. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  72. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  74. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  75. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  76. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  77. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  97. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  98. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  99. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  100. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  101. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  129. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  130. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  131. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  132. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  157. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  158. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env python3
2
+ """Post-turn directive-capture verification hook for the Stop event.
3
+
4
+ Switchroom #2848 Stage C — deterministic correction capture (hindsight
5
+ synthesis-layers RFC, Phase 3 "corrections stick").
6
+
7
+ Stage B (recall.py) appends an advisory nudge to the UserPromptSubmit
8
+ context when the inbound looks correction-shaped, then trusts the model to
9
+ call ``mcp__hindsight__create_directive`` itself. That closes part of the
10
+ ~55% miss rate Stage A measured, but capture still relies on the model
11
+ CHOOSING to act on the nudge — a purely advisory path. When the model
12
+ silently ignores the nudge, a durable correction is lost the same way it was
13
+ before Stage B.
14
+
15
+ This hook closes the residual gap for the HIGH-CONFIDENCE case. On Stop it:
16
+
17
+ 1. Re-reads the transcript, isolates the human turn that opened this turn,
18
+ and tests it against a NARROW, high-precision "durable standing rule"
19
+ regex (a strict subset of Stage B's inclusive detector — see
20
+ ``looks_like_durable_directive``). Bare "always"/"never"/"stop …" and
21
+ other one-off-prone shapes are deliberately EXCLUDED here; only explicit
22
+ standing-rule framings ("from now on", "as a rule", "you should always",
23
+ "call me …", "remember to …", "don't … again") qualify.
24
+ 2. Scans the assistant messages of the turn for an actual
25
+ ``create_directive`` tool call.
26
+ 3. If the turn stated a durable rule but recorded NO directive, it BLOCKS
27
+ the stop ONCE (Claude Code ``{"decision":"block"}``) with a terse reason
28
+ telling the model to persist the rule now — or, if on reflection it was
29
+ genuinely a one-off, to just finish. ``stop_hook_active`` gates the
30
+ block to fire at most once per turn, so it can never loop and never
31
+ override the model's second, explicit judgment.
32
+
33
+ Why this stays invariant-clean (same reasoning as Stage B):
34
+ * NO model callsite here — detection is pure regex (claude-native).
35
+ * NO silent hook-side write — the hook never calls the Hindsight API; the
36
+ MODEL authors the directive verbatim and the call is visible in chat
37
+ (chat-legibility / no-self-escalation). The block is a re-prompt, not a
38
+ write.
39
+ * Guarded against spam — the durable regex is high-precision, the block
40
+ fires once, and the reason explicitly authorizes "one-off → don't create,
41
+ just finish". A false positive costs one bounded model continuation.
42
+
43
+ Gated by the same knob as Stage B: ``directiveCaptureNudge`` (switchroom
44
+ default on; operators opt out per-agent via
45
+ ``memory.directive_capture_nudge=false`` →
46
+ ``HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE``). Disabling the nudge disables this
47
+ verification too — they are one deterministic-capture feature.
48
+
49
+ Exit codes:
50
+ 0 — always (graceful degradation; a raise here must never wedge a turn).
51
+ """
52
+
53
+ import json
54
+ import os
55
+ import re
56
+ import sys
57
+
58
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
59
+
60
+ from lib.config import debug_log, load_config # noqa: E402
61
+ from lib.directives import ( # noqa: E402
62
+ parse_active_directives_block,
63
+ rule_already_captured,
64
+ )
65
+
66
+ # Reuse Stage B's pleasantry scrub so "as always" / "always happy to help"
67
+ # can't trip the high-confidence detector either. Imported lazily-safe: if
68
+ # recall.py fails to import for any reason we fall back to a no-op scrub so
69
+ # this hook still degrades gracefully rather than wedging Stop.
70
+ try:
71
+ from recall import _DIRECTIVE_NUDGE_NEGATIVE_RE as _NEGATIVE_RE
72
+ from recall import looks_like_standing_rule as _looks_like_standing_rule
73
+ except Exception: # pragma: no cover - defensive import guard
74
+ _NEGATIVE_RE = re.compile(r"(?!x)x") # matches nothing → scrub is a no-op
75
+
76
+ def _looks_like_standing_rule(_text): # type: ignore
77
+ # If recall.py can't be imported, fall back to the high-precision
78
+ # detector alone rather than wedging the hook. (Never expected in
79
+ # practice — recall.py ships in the same plugin tree.)
80
+ return True
81
+
82
+
83
+ # High-confidence, high-precision "durable standing rule" detector. Fires only
84
+ # on explicit standing-rule / preference / identity framings that read as
85
+ # durable on their face. Bare "always"/"never"/"stop …"/"don't …" (without a
86
+ # standing frame), and pure world-fact corrections ("we no longer use X" —
87
+ # recall/retain handle those, they aren't behavioural directives), are
88
+ # intentionally omitted: the blocking re-prompt is more intrusive than Stage
89
+ # B's advisory nudge, so its trigger set is narrower and directive-shaped only.
90
+ #
91
+ # looks_like_durable_directive() additionally AND-gates this against
92
+ # recall.py's inclusive looks_like_standing_rule, so the durable trigger set is
93
+ # a GUARANTEED SUBSET of Stage B's nudge trigger set — Stage C can never block
94
+ # on a turn Stage B wouldn't even have nudged on.
95
+ _DURABLE_DIRECTIVE_RE = re.compile(
96
+ r"""(?ix)
97
+ (?:
98
+ # --- explicit temporal / standing-rule framings ---
99
+ \b from \s+ now \s+ on \b
100
+ | \b going \s+ forwards? \b
101
+ | \b in \s+ (?: the \s+ )? future \b
102
+ | \b as \s+ a \s+ (?: general \s+ )?
103
+ (?: rule | policy | principle | convention | standard | default | habit ) \b
104
+ # --- directed standing behaviour ("you should always", "please never") ---
105
+ | \b you \s+ (?: should | must ) \s+ (?: always | never ) \b
106
+ | \b i \s+ want \s+ you \s+ to \s+ (?: always | never ) \b
107
+ | \b please \s+ (?: always | never ) \b
108
+ # --- durable preferences / identity ---
109
+ | \b i \s* ['’]? d \s+ (?: really \s+ )? prefer \b
110
+ | \b (?: i | we ) \s+ prefer \s+ (?: that \s+ )? you \b
111
+ | \b call \s+ me \b
112
+ # --- memory / reinforcement ---
113
+ | \b remember \s+ (?: to | that | always | never ) \b
114
+ # --- prohibitions with a durable frame ---
115
+ | \b (?: do \s* n['’]? t | don['’]? t | dont | do \s+ not )
116
+ \b [^.?!]{0,40} \b again \b
117
+ )
118
+ """
119
+ )
120
+
121
+ # Terse, bounded. Fed back to the model on a single blocked Stop. It must
122
+ # offer an explicit escape hatch (one-off → just finish) so a false positive
123
+ # is cheap and never forces a spurious directive.
124
+ _VERIFY_BLOCK_REASON = (
125
+ "<directive_capture_verify>\n"
126
+ "The user's last message stated a DURABLE, standing rule for how you "
127
+ "should behave going forward (e.g. \"from now on …\", \"as a rule …\", "
128
+ "\"you should always …\", \"call me …\", \"remember to …\", \"don't … "
129
+ "again\"), but this turn is ending without recording it — so the "
130
+ "correction will NOT survive the next session.\n"
131
+ "If it is genuinely a durable rule, call "
132
+ "mcp__hindsight__create_directive NOW (verbatim, in the user's own "
133
+ "words), then briefly confirm you have saved it.\n"
134
+ "UNLESS an equivalent active directive already exists (see the "
135
+ "<active_directives> block for this turn) — in that case it is already "
136
+ "saved; do NOT create a duplicate, just finish.\n"
137
+ "If, on reflection, it was only a one-off instruction for this task, do "
138
+ "NOT create a directive — just finish your reply normally.\n"
139
+ "This verification fires once per turn.\n"
140
+ "</directive_capture_verify>"
141
+ )
142
+
143
+ # Guard: skip any "user" content that is actually hook-injected context
144
+ # (recall's memories/nudge blocks), not a human message.
145
+ _INJECTED_MARKERS = (
146
+ "<directive_capture_check>",
147
+ "<directive_capture_verify>",
148
+ "<hindsight_memories>",
149
+ # recall.py injects the bank's active directives as a top-of-prompt block;
150
+ # it is hook-injected context, not a human turn (#2903 Fix 6.2).
151
+ "<active_directives>",
152
+ "Relevant memories from past conversations",
153
+ )
154
+
155
+ # SWITCHROOM DIVERGENCE (#2903 Fix 6.3): the `<channel source="...">` envelope
156
+ # grammar and the human-source whitelist used to be hard-coded inline here,
157
+ # silently coupling this vendored Python guard to the switchroom gateway's TS
158
+ # wire format. Extracted to lib/switchroom_envelope.py so a TS-side envelope
159
+ # change has ONE obvious Python counterpart to update (and its own test) rather
160
+ # than breaking this guard undetected. `is_synthetic_inbound` is re-exported for
161
+ # backward compatibility with existing tests/callers.
162
+ from lib.switchroom_envelope import is_synthetic_inbound # noqa: E402,F401
163
+
164
+
165
+ def looks_like_durable_directive(text) -> bool:
166
+ """High-precision test for an explicit, durable standing rule.
167
+
168
+ Narrower than recall.py's ``looks_like_standing_rule`` — see the module
169
+ docstring. Pleasantries are scrubbed first (shared Stage B negative
170
+ guard). Returns False on empty / non-string input. Pure regex; no model
171
+ call.
172
+ """
173
+ if not isinstance(text, str) or not text.strip():
174
+ return False
175
+ scrubbed = _NEGATIVE_RE.sub(" ", text)
176
+ if not _DURABLE_DIRECTIVE_RE.search(scrubbed):
177
+ return False
178
+ # Subset gate: only act where Stage B would also have nudged.
179
+ return bool(_looks_like_standing_rule(text))
180
+
181
+
182
+ def _message_text(content) -> str:
183
+ """Extract the human-authored text from a message's content.
184
+
185
+ Handles the plain-string shape and the Claude Code list shape
186
+ ``[{type:"text", text:...}, {type:"tool_use"/"tool_result", ...}]``.
187
+ Only ``text`` parts are joined — tool_result / tool_use parts are
188
+ ignored, so a role="user" tool-result message yields "" (correctly not a
189
+ human turn).
190
+ """
191
+ if isinstance(content, str):
192
+ return content
193
+ if isinstance(content, list):
194
+ parts = []
195
+ for p in content:
196
+ if isinstance(p, dict) and p.get("type") == "text":
197
+ t = p.get("text")
198
+ if isinstance(t, str):
199
+ parts.append(t)
200
+ return "\n".join(parts)
201
+ return ""
202
+
203
+
204
+ def _is_injected(text: str) -> bool:
205
+ return any(marker in text for marker in _INJECTED_MARKERS)
206
+
207
+
208
+ def find_last_human_turn(messages: list) -> tuple:
209
+ """Return ``(index, text)`` of the most recent genuine human turn.
210
+
211
+ Skips role="user" entries that are tool_result-only (no text) or
212
+ hook-injected context blocks. Returns ``(None, "")`` when there is no
213
+ human message.
214
+ """
215
+ for i in range(len(messages) - 1, -1, -1):
216
+ msg = messages[i]
217
+ if not isinstance(msg, dict) or msg.get("role") != "user":
218
+ continue
219
+ text = _message_text(msg.get("content"))
220
+ if not text.strip():
221
+ continue # tool_result-only user message
222
+ if _is_injected(text):
223
+ continue # recall/nudge injection, not the human
224
+ return i, text
225
+ return None, ""
226
+
227
+
228
+ def _directive_call_ids(content) -> list:
229
+ """Return the tool_use ids of any create_directive calls in a message's
230
+ content (empty list if none)."""
231
+ ids = []
232
+ if not isinstance(content, list):
233
+ return ids
234
+ for p in content:
235
+ if not isinstance(p, dict) or p.get("type") != "tool_use":
236
+ continue
237
+ name = p.get("name", "")
238
+ if isinstance(name, str) and "create_directive" in name:
239
+ # Track the id so we can pair it with its tool_result and reject a
240
+ # call whose write ERRORED. A call with no id still counts as a
241
+ # (best-effort) attempt — see _directive_call_present.
242
+ ids.append(p.get("id"))
243
+ return ids
244
+
245
+
246
+ def _directive_call_present(content) -> bool:
247
+ """True if a message's content contains a create_directive tool_use."""
248
+ return len(_directive_call_ids(content)) > 0
249
+
250
+
251
+ # SWITCHROOM DIVERGENCE (#2903, Fix 1.3): a create_directive tool_use whose
252
+ # tool_result came back with an error must NOT count as "recorded". A hindsight
253
+ # tools/call returns HTTP 200 + is_error:true on failure (engine down / renamed
254
+ # arg / isError envelope); without this the verifier would see the call, treat
255
+ # the correction as captured, and never fire its one bounded re-prompt — the
256
+ # same false-success class that made chat show "📌 remembered" for a failed
257
+ # write. We scan subsequent user messages for the matching tool_result id and
258
+ # treat is_error:true (or an error-shaped text result) as NOT-recorded.
259
+ def _errored_tool_use_ids(messages: list) -> set:
260
+ """Collect tool_use ids whose tool_result reported an error."""
261
+ errored = set()
262
+ for msg in messages:
263
+ if not isinstance(msg, dict):
264
+ continue
265
+ content = msg.get("content")
266
+ if not isinstance(content, list):
267
+ continue
268
+ for p in content:
269
+ if not isinstance(p, dict) or p.get("type") != "tool_result":
270
+ continue
271
+ if p.get("is_error") is True:
272
+ tid = p.get("tool_use_id")
273
+ if tid is not None:
274
+ errored.add(tid)
275
+ return errored
276
+
277
+
278
+ def collect_active_directive_contents(messages: list) -> list:
279
+ """Gather the CONTENT strings of every active directive injected into this
280
+ turn's context.
281
+
282
+ recall.py injects an ``<active_directives>`` block (the bank's currently
283
+ active directives) into the UserPromptSubmit context; it shows up in the
284
+ transcript as an injected user message. We parse those back out so the
285
+ verifier can tell whether a restated rule is ALREADY stored — in which case
286
+ the model correctly declines to re-create it and we must NOT block (#2903
287
+ Fix 6.2). Pure string parsing; no API call.
288
+ """
289
+ contents: list = []
290
+ for msg in messages:
291
+ if not isinstance(msg, dict):
292
+ continue
293
+ text = _message_text(msg.get("content"))
294
+ if "<active_directives>" in text:
295
+ contents.extend(parse_active_directives_block(text))
296
+ return contents
297
+
298
+
299
+ def directive_recorded_after(messages: list, start_index: int) -> bool:
300
+ """True if any assistant turn after ``start_index`` called create_directive
301
+ with a SUCCESSFUL result. A call whose tool_result errored does not count
302
+ (SWITCHROOM DIVERGENCE #2903, Fix 1.3) — so the verifier still re-prompts
303
+ once for a durable rule whose write failed."""
304
+ errored = _errored_tool_use_ids(messages)
305
+ for msg in messages[start_index + 1:]:
306
+ if not isinstance(msg, dict) or msg.get("role") != "assistant":
307
+ continue
308
+ ids = _directive_call_ids(msg.get("content"))
309
+ if not ids:
310
+ continue
311
+ # Recorded only if at least one create_directive call did NOT error.
312
+ # A call with a None id (older/testing shape carrying no id) has no
313
+ # pairable result, so treat it as a successful attempt (prior behaviour).
314
+ for tid in ids:
315
+ if tid is None or tid not in errored:
316
+ return True
317
+ return False
318
+
319
+
320
+ def read_transcript(transcript_path: str) -> list:
321
+ """Read a JSONL transcript into a list of message dicts (role/content).
322
+
323
+ Mirrors retain.py.read_transcript: supports the nested Claude Code shape
324
+ ``{type, message:{role, content}}`` and the flat testing shape
325
+ ``{role, content}``.
326
+ """
327
+ if not transcript_path or not os.path.isfile(transcript_path):
328
+ return []
329
+ messages = []
330
+ try:
331
+ with open(transcript_path, encoding="utf-8") as f:
332
+ for line in f:
333
+ line = line.strip()
334
+ if not line:
335
+ continue
336
+ try:
337
+ entry = json.loads(line)
338
+ except json.JSONDecodeError:
339
+ continue
340
+ if entry.get("type") in ("user", "assistant"):
341
+ msg = entry.get("message", {})
342
+ if isinstance(msg, dict) and msg.get("role"):
343
+ messages.append(msg)
344
+ elif "role" in entry and "content" in entry:
345
+ messages.append(entry)
346
+ except OSError:
347
+ pass
348
+ return messages
349
+
350
+
351
+ def evaluate(hook_input: dict, config: dict) -> str | None:
352
+ """Core decision. Returns a block reason string, or None to allow stop.
353
+
354
+ None → the turn is allowed to end (no-op). A non-empty string → block the
355
+ stop once and feed the string back to the model.
356
+ """
357
+ # Same knob as Stage B — disabling the nudge disables this verification.
358
+ if not config.get("directiveCaptureNudge", True):
359
+ debug_log(config, "Directive-capture verify: feature disabled, allowing stop")
360
+ return None
361
+
362
+ # #2873/#2903 Fix 6.2 — the BLOCK is separately gated: an operator can keep
363
+ # the advisory Stage B nudge while dropping the more intrusive Stop block.
364
+ if not config.get("directiveCaptureVerify", True):
365
+ debug_log(config, "Directive-capture verify: block disabled (nudge-only), allowing stop")
366
+ return None
367
+
368
+ # Loop / one-off guard: if we already blocked once this turn, respect the
369
+ # model's second judgment and never re-block.
370
+ if hook_input.get("stop_hook_active"):
371
+ debug_log(config, "Directive-capture verify: stop_hook_active, not re-blocking")
372
+ return None
373
+
374
+ messages = read_transcript(hook_input.get("transcript_path", ""))
375
+ if not messages:
376
+ return None
377
+
378
+ idx, text = find_last_human_turn(messages)
379
+ if idx is None:
380
+ return None
381
+
382
+ # Non-interactive turn guard: cron / synthesized-inbound turns (resume,
383
+ # reaction, vault-grant, subagent-handback, obligation-represent, …) are
384
+ # machine turns, not human corrections. Never block Stop to nag capture on
385
+ # them — that's spurious. (See is_synthetic_inbound.)
386
+ if is_synthetic_inbound(text):
387
+ debug_log(config, "Directive-capture verify: synthetic/cron inbound, allowing stop")
388
+ return None
389
+
390
+ if not looks_like_durable_directive(text):
391
+ return None
392
+
393
+ if directive_recorded_after(messages, idx):
394
+ debug_log(config, "Directive-capture verify: create_directive already called, allowing stop")
395
+ return None
396
+
397
+ # Dedup (#2903 Fix 6.2): if the restated rule is already covered by an
398
+ # active directive injected into this turn's <active_directives> block, the
399
+ # model CORRECTLY declined to re-create a duplicate — blocking here would
400
+ # nag it to double-store. Allow stop.
401
+ existing = collect_active_directive_contents(messages)
402
+ if existing and rule_already_captured(text, existing):
403
+ debug_log(
404
+ config,
405
+ "Directive-capture verify: rule already covered by an active directive, allowing stop",
406
+ )
407
+ return None
408
+
409
+ debug_log(
410
+ config,
411
+ "Directive-capture verify: durable rule stated, no create_directive call — blocking once",
412
+ )
413
+ return _VERIFY_BLOCK_REASON
414
+
415
+
416
+ def main():
417
+ try:
418
+ hook_input = json.load(sys.stdin)
419
+ except (json.JSONDecodeError, EOFError):
420
+ # No input → nothing to verify. Allow stop.
421
+ return
422
+ try:
423
+ config = load_config()
424
+ except Exception:
425
+ return
426
+ try:
427
+ reason = evaluate(hook_input, config)
428
+ except Exception as e: # never wedge a turn on a verify bug
429
+ debug_log(config, f"Directive-capture verify error (allowing stop): {e}")
430
+ return
431
+ if reason:
432
+ # Claude Code Stop-hook block contract: emit decision=block + reason;
433
+ # the model continues the turn with `reason` as feedback.
434
+ print(json.dumps({"decision": "block", "reason": reason}))
435
+
436
+
437
+ if __name__ == "__main__":
438
+ try:
439
+ main()
440
+ except Exception as e: # absolute backstop — Stop must never hard-fail
441
+ print(f"[Hindsight] Unexpected error in directive_verify: {e}", file=sys.stderr)
442
+ try:
443
+ sys.exit(2 if load_config().get("debug") else 0)
444
+ except Exception:
445
+ sys.exit(0)
@@ -37,6 +37,33 @@ DEFAULTS = {
37
37
  # scores, so this is the switchroom-side quality filter — see #475.
38
38
  "recallMinOverlap": 0.0,
39
39
  "recallTypes": ["world", "experience"],
40
+ # Switchroom #2848 Stage B/C — deterministic directive capture.
41
+ # When on (switchroom default; pinned true in the copied plugin
42
+ # settings.json by applyHindsightSettingsOverrides), TWO deterministic
43
+ # hooks share this knob:
44
+ # * Stage B (recall.py, UserPromptSubmit): regex-detects correction /
45
+ # standing-rule-shaped inbound and appends a terse advisory to the
46
+ # turn's additionalContext telling the model to persist the rule with
47
+ # create_directive if it IS durable.
48
+ # * Stage C (directive_verify.py, Stop): after the turn, re-checks the
49
+ # human turn against a HIGH-PRECISION durable-rule regex and, if the
50
+ # model recorded no create_directive call, blocks the stop ONCE to
51
+ # re-prompt capture (closes the "model ignored the nudge" gap).
52
+ # Both are pure detection — no model callsite, no silent hook-side write;
53
+ # the model authors the directive in-session (chat-legible). Operators opt
54
+ # out per-agent via memory.directive_capture_nudge=false →
55
+ # HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE (disables BOTH hooks).
56
+ "directiveCaptureNudge": True,
57
+ # Switchroom #2873/#2903 Fix 6.2 — the BLOCKING half (Stage C
58
+ # directive_verify.py Stop hook) split out from the advisory nudge. When
59
+ # True (default) the verifier may block the stop once to re-prompt capture;
60
+ # when False the Stage B nudge still fires but the Stop hook NEVER blocks
61
+ # (advisory-only mode). Lets an operator keep the gentle nudge while dropping
62
+ # the more intrusive block. Gated UNDER directiveCaptureNudge: turning the
63
+ # nudge off disables both regardless of this knob. Operators opt out
64
+ # per-agent via memory.directive_capture_verify=false →
65
+ # HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
66
+ "directiveCaptureVerify": True,
40
67
  "recallContextTurns": 1,
41
68
  "recallMaxQueryChars": 800,
42
69
  "recallRoles": ["user", "assistant"],
@@ -119,6 +146,16 @@ ENV_OVERRIDES = {
119
146
  # from agents.<name>.memory.recall.skip_trivial only on override; the
120
147
  # switchroom default is on (recall.py falls back to True).
121
148
  "HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
149
+ # Switchroom #2848 Stage B: directive-capture nudge on/off. Set by
150
+ # start.sh from agents.<name>.memory.directive_capture_nudge only when
151
+ # the operator overrode it; the switchroom default is on (settings.json
152
+ # pins true; recall.py falls back to True).
153
+ "HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE": ("directiveCaptureNudge", bool),
154
+ # Switchroom #2873/#2903 Fix 6.2: the Stage C block on/off, independent of
155
+ # the Stage B nudge. Set by start.sh from
156
+ # agents.<name>.memory.directive_capture_verify only when the operator
157
+ # overrode it; the switchroom default is on.
158
+ "HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
122
159
  "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
123
160
  "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
124
161
  # Upstream 962140eef — recall tag filters. The tags env var accepts JSON
@@ -16,6 +16,7 @@ stderr. We never raise to the caller — directives are nice-to-have on the
16
16
  recall path; a directive-fetch failure must not kill the recall block.
17
17
  """
18
18
 
19
+ import re
19
20
  import sys
20
21
  from typing import Optional
21
22
 
@@ -117,3 +118,90 @@ def format_active_directives_block(directives: list, max_directives: int = MAX_D
117
118
 
118
119
  lines.append("</active_directives>")
119
120
  return "\n".join(lines)
121
+
122
+
123
+ # --- Directive dedup (switchroom #2903 Fix 6.2) --------------------------------
124
+ #
125
+ # A user restating a rule that is ALREADY an active directive should not get
126
+ # re-nudged, and the Stage C verifier must not BLOCK the turn where the model
127
+ # correctly declines to re-create the duplicate. The verifier can see the
128
+ # directives that were injected THIS turn (recall.py emits the
129
+ # <active_directives> block into the prompt), so it reads them back out of the
130
+ # transcript and treats a rule already covered there as "already captured".
131
+ #
132
+ # Matching is a deterministic lexical-overlap heuristic (no model call, no API
133
+ # call — the verifier is on the Stop critical path). It is intentionally
134
+ # lenient: a false "already captured" only means we skip a re-prompt (the rule
135
+ # is genuinely already stored in that case), whereas a false "not captured"
136
+ # re-blocks a turn the model correctly finished. So we err toward treating a
137
+ # strong token overlap as a duplicate.
138
+
139
+ # Parses the numbered "N. [P<pri>] <name>: <content>" body lines out of a
140
+ # rendered <active_directives> block (see format_active_directives_block).
141
+ _ACTIVE_DIRECTIVE_LINE_RE = re.compile(
142
+ r"^\s*\d+\.\s*\[P-?\d+\]\s*[^:]*:\s*(?P<content>.+?)\s*$"
143
+ )
144
+
145
+ # Low-signal words stripped before overlap scoring so "always"/"you"/"please"
146
+ # framing doesn't inflate similarity between two unrelated rules.
147
+ _DEDUP_STOPWORDS = frozenset(
148
+ {
149
+ "the", "a", "an", "to", "of", "and", "or", "for", "in", "on", "at",
150
+ "is", "are", "be", "you", "your", "i", "we", "me", "my", "it", "that",
151
+ "this", "with", "as", "so", "do", "dont", "don", "not", "never",
152
+ "always", "please", "should", "must", "want", "from", "now", "on",
153
+ "going", "forward", "forwards", "future", "rule", "remember", "call",
154
+ "use", "using", "make", "sure", "when", "if", "just", "will", "can",
155
+ }
156
+ )
157
+
158
+
159
+ def _dedup_tokens(text: str) -> set:
160
+ """Normalize text into a set of significant lower-case word tokens."""
161
+ if not isinstance(text, str):
162
+ return set()
163
+ words = re.findall(r"[a-z0-9]+", text.lower())
164
+ return {w for w in words if w not in _DEDUP_STOPWORDS and len(w) > 1}
165
+
166
+
167
+ def parse_active_directives_block(text: str) -> list:
168
+ """Extract the directive CONTENT strings from a rendered
169
+ <active_directives> block (as produced by format_active_directives_block).
170
+
171
+ Returns [] when the block is absent or malformed. Pure string parsing.
172
+ """
173
+ if not isinstance(text, str) or "<active_directives>" not in text:
174
+ return []
175
+ # Isolate the block body between the tags (tolerate missing close tag).
176
+ body = text.split("<active_directives>", 1)[1]
177
+ body = body.split("</active_directives>", 1)[0]
178
+ contents = []
179
+ for line in body.splitlines():
180
+ m = _ACTIVE_DIRECTIVE_LINE_RE.match(line)
181
+ if m:
182
+ contents.append(m.group("content").strip())
183
+ return contents
184
+
185
+
186
+ def rule_already_captured(
187
+ rule_text: str, directive_contents: list, threshold: float = 0.6
188
+ ) -> bool:
189
+ """True when ``rule_text`` is lexically well-covered by an EXISTING active
190
+ directive in ``directive_contents``.
191
+
192
+ Coverage = |rule_tokens ∩ directive_tokens| / |rule_tokens| for the
193
+ best-matching directive. A high coverage ratio means the restated rule adds
194
+ (almost) no new significant words over one already stored — i.e. a
195
+ duplicate. Deterministic; no model/API call.
196
+ """
197
+ rule_tokens = _dedup_tokens(rule_text)
198
+ if not rule_tokens:
199
+ return False
200
+ for content in directive_contents:
201
+ d_tokens = _dedup_tokens(content)
202
+ if not d_tokens:
203
+ continue
204
+ covered = len(rule_tokens & d_tokens) / len(rule_tokens)
205
+ if covered >= threshold:
206
+ return True
207
+ return False