switchroom 0.17.6 → 0.18.3

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 (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { describe, it, expect } from "vitest";
36
36
  import { spinUp } from "../harness.js";
37
+ import type { ObservedMessage } from "../driver.js";
37
38
 
38
39
  // The prompt pushes the model into a tool-heavy state where it has
39
40
  // produced "an answer" internally but hasn't yet realised it must
@@ -77,12 +78,33 @@ describe("uat: silent-end recovery", () => {
77
78
  // fallback wording ("still working… (no update from agent
78
79
  // in N min)") that means the silent-end loop fired AND the
79
80
  // model didn't recover. Acceptable outcome — the user got
80
- // something — but a design-health alarm. Log it.
81
- if (/no update from agent/i.test(reply.text)) {
81
+ // something — but a design-health alarm. Log it AND assert the
82
+ // `exhausted` latch gave fire-once (transport-side), matching the
83
+ // channel twin exactly — the job-spec Prove-it claim is
84
+ // "asserted transport-side in a DM AND a supergroup alike", so the
85
+ // DM twin must make the same exactly-once assertion, not just warn.
86
+ if (/no update from agent|didn't send a reply/i.test(reply.text)) {
82
87
  console.warn(
83
88
  `[silent-end-recovery] reply was the framework fallback — `
84
89
  + `model never replied on its own. Reply text: ${JSON.stringify(reply.text.slice(0, 200))}`,
85
90
  );
91
+
92
+ // No SECOND fallback-shaped message should follow within a short
93
+ // window (the `exhausted` latch gives fire-once).
94
+ let secondFallback: ObservedMessage | null = null;
95
+ try {
96
+ secondFallback = await sc.expectMessage(
97
+ /no update from agent|didn't send a reply/i,
98
+ { from: "bot", timeout: 15_000 },
99
+ );
100
+ } catch {
101
+ // Timeout is the expected/good outcome — no second fallback fired.
102
+ }
103
+ expect(
104
+ secondFallback,
105
+ "the dark-turn/framework fallback fired MORE than once for the same turn "
106
+ + "(the `exhausted` latch should give fire-once)",
107
+ ).toBeNull();
86
108
  }
87
109
  } finally {
88
110
  await sc.tearDown();
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Ownership filter for the worktree-isolated cwds the subagent-watcher should
3
+ * additionally watch (deterministic-turn-liveness.md Known Gap 2 + the #2893
4
+ * ownership-predicate review fix + the #1116 / #2893 durable-identity fix).
5
+ *
6
+ * A sub-agent dispatched into a `switchroom worktree claim` cwd runs under a
7
+ * different project-dir slug than the agent's own `agentCwd`, so the #1116
8
+ * foreign-slug filter would skip it forever unless the watcher also watches
9
+ * the slugs of worktrees THIS agent owns. This helper derives that set from
10
+ * the host-global worktree registry, filtered by the agent's own identity.
11
+ *
12
+ * Identity resolution is two-tier (durable fix for the gap where a worktree
13
+ * worker whose identity can't be attributed gets NO live progress feed):
14
+ *
15
+ * 1. FAST PATH — `self` (`process.env.SWITCHROOM_AGENT_NAME`). Set
16
+ * authoritatively by compose env (compose.ts) AND hoisted in start.sh
17
+ * before the gateway fork, so this is present in the overwhelming
18
+ * majority of runs.
19
+ * 2. DURABLE FALLBACK — when `self` is unset/empty, derive the identity
20
+ * from `agentDir` (the agent's own directory, itself derived from
21
+ * `TELEGRAM_STATE_DIR` = `<agentDir>/telegram`, which the gateway
22
+ * already requires to be present before it even starts the watcher).
23
+ * The basename of `agentDir` is `resolve(agents_dir, <name>)`'s leaf —
24
+ * i.e. this agent's OWN name. This can only ever resolve to THIS
25
+ * agent's identity, never another agent's, so it cannot mis-attribute:
26
+ * a wrong basename matches zero registry records (fail-closed), it
27
+ * never matches a DIFFERENT owner. Env is just the fast path; ownership
28
+ * resolves correctly from durable config when env is missing.
29
+ *
30
+ * Fail-CLOSED, deliberately, and never mis-attributing:
31
+ *
32
+ * - Owner match ⇒ include, realpath'd. Claude Code mints the project slug
33
+ * off the process's PHYSICAL cwd, so a symlinked base (macOS `/tmp` →
34
+ * `/private/tmp`) would otherwise derive a slug that misses the physical
35
+ * one; realpath best-effort, falling back to the raw path.
36
+ * - Ownerless registry records (`ownerAgent` undefined) are NEVER matched,
37
+ * even with identity set — a naive `undefined === undefined` would leak
38
+ * every other agent's ownerless worktree (the #1116 leak this exists to
39
+ * prevent).
40
+ * - A registry read failure ⇒ `[]` (best-effort; never disturb the base
41
+ * agentCwd watch).
42
+ * - BOTH env AND agentDir-derived identity unavailable ⇒ `[]` (same
43
+ * fail-closed contract as before this fix — we never guess) but escalate
44
+ * the log from the #2893 one-shot warn to a clear ERROR naming that
45
+ * identity resolution fully failed, so the lost live feed is diagnosable.
46
+ * Never throws, never mis-attributes.
47
+ */
48
+ import { realpathSync } from "node:fs";
49
+ import { basename } from "node:path";
50
+
51
+ export interface WorktreeOwnershipRecord {
52
+ path: string;
53
+ ownerAgent?: string;
54
+ }
55
+
56
+ export interface OwnedWorktreeCwdsOptions {
57
+ /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
58
+ self: string | undefined;
59
+ /** The host-global registry read (`listRecords` from src/worktree/registry). */
60
+ listRecords: () => WorktreeOwnershipRecord[];
61
+ /**
62
+ * Durable, non-env fallback source for identity: the agent's OWN directory
63
+ * (`resolveAgentDirFromEnv()` in the gateway). When `self` is unset/empty,
64
+ * the identity is derived as `basename(agentDir)`. Omit to disable the
65
+ * fallback (the pre-fix, env-only behaviour — used by the kill-switch).
66
+ */
67
+ agentDir?: string | null;
68
+ /** Injectable for tests; defaults to `fs.realpathSync`. */
69
+ realpath?: (p: string) => string;
70
+ /**
71
+ * Injectable derivation of the agent name from `agentDir`. Defaults to
72
+ * `path.basename`. Returns "" when it cannot derive a usable name.
73
+ */
74
+ deriveName?: (agentDir: string) => string;
75
+ /** Escalated-failure sink (both identity sources unavailable). */
76
+ log?: (msg: string) => void;
77
+ }
78
+
79
+ // One-shot guard so the escalated "identity fully unresolved" ERROR is emitted
80
+ // ONCE per process rather than every rescan tick (the provider is re-invoked on
81
+ // every tick). Mirrors the #2893 one-shot-warn ethos; exported reset for tests.
82
+ let identityEscalated = false;
83
+ export function __resetIdentityEscalationForTests(): void {
84
+ identityEscalated = false;
85
+ }
86
+
87
+ function defaultDeriveName(agentDir: string): string {
88
+ if (!agentDir || agentDir.trim().length === 0) return "";
89
+ const leaf = basename(agentDir).trim();
90
+ return leaf;
91
+ }
92
+
93
+ export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
94
+ // Tier 1: env fast path. Tier 2: durable agentDir-derived fallback.
95
+ let resolved: string = opts.self != null ? opts.self : "";
96
+ if (resolved === "" && opts.agentDir != null && opts.agentDir !== "") {
97
+ const derive = opts.deriveName ?? defaultDeriveName;
98
+ resolved = derive(opts.agentDir) || "";
99
+ }
100
+
101
+ if (resolved === "") {
102
+ // Both env and durable config unavailable. Keep the historical
103
+ // fail-closed contract (return [] — never guess, never mis-attribute) but
104
+ // ESCALATE past the #2893 one-shot warn: name that identity resolution
105
+ // fully failed and the live worktree-worker feed is lost for this run.
106
+ if (!identityEscalated) {
107
+ identityEscalated = true;
108
+ opts.log?.(
109
+ "ERROR: worktree identity resolution FAILED — both " +
110
+ "SWITCHROOM_AGENT_NAME and the agentDir-derived fallback are " +
111
+ "unavailable. Worktree ownership cannot be attributed; a " +
112
+ "worktree-isolated background sub-agent will get NO live progress " +
113
+ "feed this run (its registry row is still reaped by the 1h safety " +
114
+ "net). This is a configuration fault, not a transient error.",
115
+ );
116
+ }
117
+ return [];
118
+ }
119
+
120
+ const rp = opts.realpath ?? realpathSync;
121
+ try {
122
+ return opts
123
+ .listRecords()
124
+ .filter((r) => r.ownerAgent === resolved)
125
+ .map((r) => {
126
+ try {
127
+ return rp(r.path);
128
+ } catch {
129
+ return r.path;
130
+ }
131
+ });
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
@@ -23,6 +23,15 @@
23
23
  }
24
24
  ],
25
25
  "Stop": [
26
+ {
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/directive_verify.py\"",
31
+ "timeout": 10
32
+ }
33
+ ]
34
+ },
26
35
  {
27
36
  "hooks": [
28
37
  {
@@ -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)