switchroom 0.21.16 → 0.21.17

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.
@@ -210,6 +210,35 @@ def read_if_fresh(session_id: str, last_consumed_token: Optional[int]) -> tuple:
210
210
  return {"context": payload.get("context", ""), "telemetry": payload.get("telemetry", {})}, token
211
211
 
212
212
 
213
+ def invalidate(session_id: str) -> None:
214
+ """Drop any pending prefetch buffer + sentinel for ``session_id``.
215
+
216
+ Called at MUTATION sites — a rule/directive retire (``directive_verify``)
217
+ or a retain (``prefetch.run_prefetch`` before it re-recalls) — so a buffer
218
+ captured BEFORE the mutation can never be consumed AFTER it. This closes
219
+ the resurrection class the M3 red-team (R2, BLOCKER) flagged at the
220
+ M3/M4 intersection: a rule retired mid-session must not re-inject from a
221
+ buffer prefetched while the rule was still active. It is the read-after-
222
+ write ordering the producer already builds for the happy path, extended
223
+ to the mutation path — NOT a reliance on the buffer's TTL (timing, not
224
+ correctness).
225
+
226
+ The SENTINEL is removed FIRST so a partial invalidation still fails
227
+ closed: ``read_if_fresh`` checks the sentinel before the payload, so once
228
+ the sentinel is gone the reader returns ``None`` regardless of whether the
229
+ payload unlink also succeeded — an orphaned payload is never served as
230
+ fresh. Best-effort and idempotent: a missing file is a no-op, and the
231
+ function NEVER raises (a failed unlink must not break the turn).
232
+ """
233
+ for path in (_sentinel_path(session_id), _buffer_path(session_id)):
234
+ try:
235
+ os.remove(path)
236
+ except FileNotFoundError:
237
+ pass
238
+ except OSError:
239
+ pass
240
+
241
+
213
242
  def poll_for_sentinel(session_id: str, last_consumed_token: Optional[int], cap_ms: int) -> bool:
214
243
  """Clock-bounded poll for a fresh sentinel. Never busy-spins.
215
244
 
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook: Memory v2 M5 — orientation-at-boot (Surface B).
3
+
4
+ carve-M5.md §3. Injects the agent's cron-refreshed ``orientation`` mental
5
+ model into context at SessionStart AND re-seats it after every compaction —
6
+ "reflect once, serve from cache" (E-79), deterministically, zero tool call.
7
+
8
+ DARK BY DEFAULT (red-team kill switch). Entirely gated by
9
+ ``memoryOrientationEnabled`` (default False — carve §4, same discipline as
10
+ M4's ``memoryPrefetchEnabled``). When off this hook is a hard no-op: it reads
11
+ config, sees the flag off, and exits BEFORE any bank resolve or network call.
12
+ The flag is per-agent — a stripped/absent value fails to OFF (fail-safe), so
13
+ an un-flipped agent boots exactly as pre-M5.
14
+
15
+ MATCHER-LESS (load-bearing, carve §1/§3). Registered with NO matcher, so it
16
+ fires on every SessionStart source — startup, resume, clear, fork AND compact.
17
+ The compact firing is the free deterministic post-compaction re-seat that
18
+ answers E-88 ("nothing re-seats orientation today"): the hook re-reads the
19
+ model from the engine each time, so a mid-session refresh is picked up at the
20
+ next compaction with no session restart. Do NOT add a matcher.
21
+
22
+ FAIL-SAFE INVARIANTS (all asserted in tests/test_orientation_hook.py):
23
+ - Boot is NEVER blocked on the orientation read. Every failure path
24
+ (server down, no model, read error, read timeout, degraded/undated
25
+ model) degrades to a VISIBLE one-line cold notice + a refresh request,
26
+ and exits 0. Never a silently memoryless boot, never a stale briefing
27
+ presented as fresh.
28
+ - A stale (1.5×–3× cadence) model is injected WITH a visible prefix; a
29
+ degraded (>3×) or undated model is NOT injected — the cold notice is
30
+ emitted instead. Thresholds are per-tier (carve §5), not a fixed 36h.
31
+
32
+ STDOUT CONTRACT. A SessionStart hook's stdout IS added to the model's context
33
+ (unlike PreCompact); we emit ``{"hookSpecificOutput":{"hookEventName":
34
+ "SessionStart","additionalContext": "…"}}``. Exit code is 0 ALWAYS — a
35
+ non-zero exit BLOCKS the turn in Claude Code, and orientation must degrade to
36
+ a notice, never a hard stop (same doctrine as rules-sentinel-hook.sh).
37
+ """
38
+
39
+ import json
40
+ import os
41
+ import sys
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
+
45
+ from lib import orientation as orient
46
+ from lib.bank import derive_bank_id
47
+ from lib.client import HindsightClient
48
+ from lib.config import debug_log, load_config
49
+ from lib.daemon import get_api_url
50
+
51
+ #: Generous read-timeout for the model GET (carve §3). overlord's own-bank read
52
+ #: is ~270ms and klanker's ~4ms (m0/m5-0 probes), but the latency asymmetry is
53
+ #: unexplained and possibly bank-size-correlated, so leave headroom — and on a
54
+ #: timeout degrade to the cold notice, never a hung boot.
55
+ ORIENTATION_READ_TIMEOUT_S = 3
56
+
57
+
58
+ def _emit(additional_context: str) -> None:
59
+ """Print the SessionStart additionalContext envelope to stdout."""
60
+ print(
61
+ json.dumps(
62
+ {
63
+ "hookSpecificOutput": {
64
+ "hookEventName": "SessionStart",
65
+ "additionalContext": additional_context,
66
+ }
67
+ }
68
+ )
69
+ )
70
+
71
+
72
+ def enqueue_refresh(config: dict, bank_id: str, model_name: str) -> None:
73
+ """Signal that ``bank_id``'s orientation model needs a background refresh.
74
+
75
+ The cold/degraded path requests a refresh so a boot that found no usable
76
+ briefing does not stay briefing-less indefinitely (carve §3-4). This is the
77
+ SEAM the M5-C out-of-session tiered scheduler consumes; the scheduler is
78
+ cadence-driven regardless, so this is a best-effort nudge, never a blocking
79
+ dependency. Kept as a thin, override-able function so the cold-path tests
80
+ can assert the OUTCOME ("a refresh was requested") without a live scheduler.
81
+
82
+ Best-effort and total: any failure is swallowed — a boot must never fail
83
+ because a refresh request could not be recorded.
84
+ """
85
+ debug_log(
86
+ config,
87
+ f"orientation cold/degraded on bank '{bank_id}' model "
88
+ f"'{model_name}' — refresh requested",
89
+ )
90
+ try:
91
+ state_dir = os.environ.get("HINDSIGHT_STATE_DIR") or os.path.join(
92
+ os.path.expanduser("~"), ".hindsight"
93
+ )
94
+ os.makedirs(state_dir, exist_ok=True)
95
+ marker = os.path.join(state_dir, "orientation-refresh-pending.jsonl")
96
+ with open(marker, "a") as f:
97
+ f.write(json.dumps({"bank_id": bank_id, "model": model_name}) + "\n")
98
+ except Exception as e: # noqa: BLE001 — best-effort, never breaks boot
99
+ debug_log(config, f"orientation refresh-request write failed (ignored): {e}")
100
+
101
+
102
+ def _match_model(models: dict, name: str) -> dict | None:
103
+ """Find the mental model whose ``name`` matches the configured orientation name."""
104
+ items = models.get("items") if isinstance(models, dict) else None
105
+ if not isinstance(items, list):
106
+ return None
107
+ for item in items:
108
+ if isinstance(item, dict) and item.get("name") == name:
109
+ return item
110
+ return None
111
+
112
+
113
+ def run(hook_input: dict, config: dict) -> None:
114
+ """Core hook logic. Emits at most one additionalContext block; never raises."""
115
+ if not config.get("memoryOrientationEnabled", False):
116
+ debug_log(config, "memoryOrientationEnabled off — orientation no-op")
117
+ return
118
+
119
+ model_name = config.get("memoryOrientationModel", "orientation")
120
+ cadence_hours = int(config.get("memoryOrientationCadenceHours", 48) or 48)
121
+ bank_id = derive_bank_id(hook_input, config)
122
+
123
+ def _dbg(*a):
124
+ debug_log(config, *a)
125
+
126
+ # Resolve the engine REST URL WITHOUT starting the daemon (too slow for
127
+ # SessionStart). If the server is unreachable, degrade to the cold notice —
128
+ # we cannot read the model, and boot must not block.
129
+ try:
130
+ api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
131
+ client = HindsightClient(api_url, config.get("hindsightApiToken"))
132
+ except (RuntimeError, ValueError) as e:
133
+ debug_log(config, f"orientation: Hindsight unreachable ({e}) — cold notice")
134
+ enqueue_refresh(config, bank_id, model_name)
135
+ _emit(orient.cold_notice())
136
+ return
137
+
138
+ # Resolve the orientation model by NAME → id, then read its content +
139
+ # freshness watermark. Any read error/timeout is a cold notice, not a hang.
140
+ try:
141
+ models = client.list_mental_models(bank_id, timeout=ORIENTATION_READ_TIMEOUT_S)
142
+ match = _match_model(models, model_name)
143
+ except Exception as e: # noqa: BLE001 — degrade to cold, never block boot
144
+ debug_log(config, f"orientation: list models failed ({e}) — cold notice")
145
+ enqueue_refresh(config, bank_id, model_name)
146
+ _emit(orient.cold_notice())
147
+ return
148
+
149
+ if not match or not match.get("id"):
150
+ debug_log(config, f"orientation: no model named '{model_name}' — cold notice")
151
+ enqueue_refresh(config, bank_id, model_name)
152
+ _emit(orient.cold_notice())
153
+ return
154
+
155
+ try:
156
+ full = client.get_mental_model(
157
+ bank_id, match["id"], detail="full", timeout=ORIENTATION_READ_TIMEOUT_S
158
+ )
159
+ except Exception as e: # noqa: BLE001 — includes urllib timeout
160
+ debug_log(config, f"orientation: model read failed ({e}) — cold notice")
161
+ enqueue_refresh(config, bank_id, model_name)
162
+ _emit(orient.cold_notice())
163
+ return
164
+
165
+ content = (full or {}).get("content") or ""
166
+ last_refreshed_at = (full or {}).get("last_refreshed_at")
167
+ state, hours_ago = orient.classify_staleness(last_refreshed_at, cadence_hours)
168
+
169
+ if not content.strip() or state in ("degraded", "unknown"):
170
+ # Empty payload, or refreshed > 3× cadence ago / undated: never present
171
+ # stale-as-fresh. Cold notice + refresh request (carve §5).
172
+ debug_log(config, f"orientation: state={state}, empty={not content.strip()} — cold notice")
173
+ enqueue_refresh(config, bank_id, model_name)
174
+ _emit(orient.cold_notice())
175
+ return
176
+
177
+ debug_log(config, f"orientation: injecting (state={state}, {hours_ago}h ago)")
178
+ _emit(orient.render_orientation(content, state, hours_ago))
179
+
180
+
181
+ def main():
182
+ config = load_config()
183
+ try:
184
+ hook_input = json.load(sys.stdin)
185
+ except (json.JSONDecodeError, EOFError):
186
+ hook_input = {}
187
+ run(hook_input, config)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ try:
192
+ main()
193
+ except Exception as e: # noqa: BLE001 — a hook that exits non-zero blocks the turn
194
+ print(f"[Hindsight] Orientation SessionStart error: {e}", file=sys.stderr)
195
+ sys.exit(0)
@@ -80,6 +80,16 @@ def run_prefetch(hook_input: dict, config: dict) -> bool:
80
80
  debug_log(config, "Prefetch: task-notification turn, skipping")
81
81
  return False
82
82
 
83
+ # Step 0 — MUTATION INVALIDATION (red-team-M3 R2, BLOCKER). This turn is
84
+ # about to retain (step 1) — a memory mutation. Drop any buffer left from a
85
+ # prior turn BEFORE recalling, so that if this turn's fresh recall fails or
86
+ # returns empty (steps 2-3 below bail without overwriting), the consumer
87
+ # cannot resurrect the pre-mutation snapshot: with no sentinel it falls to
88
+ # the explicitly stale-marked LAST_RECALL_STATE / degraded notice instead of
89
+ # serving a stale buffer as fresh. A successful recall repopulates the buffer
90
+ # with the post-mutation snapshot at step 3. Never raises.
91
+ recall_buffer.invalidate(session_id)
92
+
83
93
  # Step 1 — delta retain (Fix A: content-derived document_id, never the
84
94
  # bare {session_id} document; never truncates).
85
95
  try:
@@ -157,6 +157,110 @@ def _topic_filter_mode() -> str:
157
157
  return "soft-preamble"
158
158
 
159
159
 
160
+ # ── Memory v2 M3 (Surface-A) — directive-injection flip guard ───────────────
161
+ #
162
+ # Once an agent has migrated its standing directives into the CLAUDE.md rules
163
+ # block (Memory v2 M1) AND flipped `injectDirectives` OFF, this hook stops
164
+ # injecting the `<active_directives>` block on every turn: the rules block
165
+ # already carries the same guarantees inline, so re-injecting the directives is
166
+ # ~pure token waste (the whole point of M3 — ~49M directive-tokens/30d saved
167
+ # fleet-wide once the heavy agents follow ziggy).
168
+ #
169
+ # `injectDirectives` defaults TRUE (lib/config.py DEFAULTS); an agent is only
170
+ # flipped when switchroom.yaml sets `memory.inject_directives: false`, which
171
+ # plumbs to `HINDSIGHT_INJECT_DIRECTIVES=false` (lib/config.py ENV_OVERRIDES).
172
+ #
173
+ # The flip is DELIBERATELY guarded on physical evidence, not the flag alone:
174
+ # suppression fires IFF the flag is off AND a non-empty rules block is actually
175
+ # present in CLAUDE.md. If the flag is off but no rules block exists (mis-
176
+ # sequenced flip, or M1 not yet run for this agent), we FAIL SAFE — keep
177
+ # injecting directives and surface a one-line degraded-canary notice — rather
178
+ # than strip every guardrail the agent has. A mis-sequenced flip is therefore
179
+ # loud and non-destructive, never silently memory-less. Suppress-only: this
180
+ # NEVER deletes a directive, it only stops re-injecting it. Format-locked to
181
+ # src/memory/rules-block.ts (a golden-fixture test guards against TS drift).
182
+ # See carve-M3.md §2/§4.
183
+ RULES_BLOCK_BEGIN_MARKER = "<!-- switchroom:rules:begin -->"
184
+ RULES_BLOCK_END_MARKER = "<!-- switchroom:rules:end -->"
185
+
186
+ # A rendered rule line looks like `- **R-01** (source: X, added Y): text`. The
187
+ # literal `(none)` placeholder an empty block renders must NOT count as a rule
188
+ # (that is exactly the empty-block case we fail-safe on). Anchored, no
189
+ # unbounded alternation.
190
+ _RULES_BLOCK_RULE_LINE_RE = re.compile(r"^- \*\*R-", re.MULTILINE)
191
+
192
+ # config.py DEFAULTS key + settings.json field name for the flip flag.
193
+ INJECT_DIRECTIVES_CONFIG_KEY = "injectDirectives"
194
+
195
+ _DIRECTIVE_FLIP_DEGRADED_NOTICE = (
196
+ "⚠️ memory config: injectDirectives is OFF for this agent but no CLAUDE.md "
197
+ "rules block was found — still injecting <active_directives> this turn to "
198
+ "avoid a guardrail-less turn. Run the M1 rules-block migration (or "
199
+ "re-enable injectDirectives) to clear this notice."
200
+ )
201
+
202
+
203
+ def _recall_project_dir(hook_input: dict) -> str | None:
204
+ """Resolve the agent's project directory (where CLAUDE.md lives).
205
+
206
+ Prefers `CLAUDE_PROJECT_DIR` (set by Claude Code for hook subprocesses);
207
+ falls back to the hook payload's `cwd`. Returns None when neither is a
208
+ usable directory, so callers treat "can't locate the project dir" the same
209
+ as "no rules block" — fail-safe, keep injecting.
210
+ """
211
+ for candidate in (os.environ.get("CLAUDE_PROJECT_DIR"), hook_input.get("cwd")):
212
+ if candidate and os.path.isdir(candidate):
213
+ return candidate
214
+ return None
215
+
216
+
217
+ def rules_block_present(project_dir: str | None) -> bool:
218
+ """True iff CLAUDE.md in `project_dir` carries a NON-EMPTY rules block.
219
+
220
+ Non-empty means: the `switchroom:rules:begin/end` markers are both present
221
+ AND at least one rendered rule line (`- **R-…`) sits between them. The
222
+ `(none)` placeholder an empty block renders does NOT count. Any IO error →
223
+ False (fail-safe: absence of proof of a rules block keeps injection on).
224
+ """
225
+ if not project_dir:
226
+ return False
227
+ path = os.path.join(project_dir, "CLAUDE.md")
228
+ try:
229
+ with open(path, encoding="utf-8") as fh:
230
+ text = fh.read()
231
+ except OSError:
232
+ return False
233
+ begin = text.find(RULES_BLOCK_BEGIN_MARKER)
234
+ if begin == -1:
235
+ return False
236
+ end = text.find(RULES_BLOCK_END_MARKER, begin)
237
+ if end == -1:
238
+ return False
239
+ inner = text[begin + len(RULES_BLOCK_BEGIN_MARKER):end]
240
+ return _RULES_BLOCK_RULE_LINE_RE.search(inner) is not None
241
+
242
+
243
+ def directive_injection_decision(config: dict, hook_input: dict) -> tuple[bool, str | None]:
244
+ """Decide whether to inject the `<active_directives>` block this turn.
245
+
246
+ Returns `(inject, degraded_notice)`:
247
+ * flag ON (default) → (True, None) inject normally
248
+ * flag OFF and rules block present → (False, None) SUPPRESS (M3 flip)
249
+ * flag OFF but NO rules block present → (True, notice) FAIL SAFE: still
250
+ inject AND surface a one-line degraded-canary notice, so a mis-
251
+ sequenced flip is loud instead of silently guardrail-less.
252
+
253
+ Central to all three recall emit surfaces (main path, prefetch fast path,
254
+ task-notification directives-only path) so a flipped agent cannot leak
255
+ directives on a cache hit. Suppress-only — never deletes a directive.
256
+ """
257
+ if config.get(INJECT_DIRECTIVES_CONFIG_KEY, True):
258
+ return True, None
259
+ if rules_block_present(_recall_project_dir(hook_input)):
260
+ return False, None
261
+ return True, _DIRECTIVE_FLIP_DEGRADED_NOTICE
262
+
263
+
160
264
  def _filter_by_active_topic(results: list, active_thread_id: str | None) -> tuple[list, int]:
161
265
  """When hard-filter mode is on AND we know the active thread, drop
162
266
  any memory whose stored metadata.thread_id is set to a different
@@ -471,9 +575,17 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
471
575
  debug_log(config, f"Prefetch buffer: directive fetch failed: {exc}")
472
576
  directives_block = None
473
577
 
578
+ # Memory v2 M3 (Surface-A) — apply the directive-injection flip decision on
579
+ # the fast path too, so a flipped agent does not leak `<active_directives>`
580
+ # on a prefetch/cache hit. Suppress iff flag off AND rules block present;
581
+ # else keep injecting (and carry the degraded-canary notice, if any).
582
+ _inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
583
+ if not _inject_directives:
584
+ directives_block = None
585
+
474
586
  if payload is not None:
475
587
  memories_block = payload.get("context") or ""
476
- parts = [b for b in (directives_block, memories_block) if b]
588
+ parts = [b for b in (_dir_notice, directives_block, memories_block) if b]
477
589
  if not parts:
478
590
  return False
479
591
  _emit_cached_context("\n\n".join(parts))
@@ -486,13 +598,13 @@ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool
486
598
  last = read_state(LAST_RECALL_STATE) or {}
487
599
  stale_memories = last.get("memories_context") or ""
488
600
  stale_block = stale_recall_notice(stale_memories)
489
- parts = [b for b in (directives_block, stale_block) if b]
601
+ parts = [b for b in (_dir_notice, directives_block, stale_block) if b]
490
602
  if parts:
491
603
  _emit_cached_context("\n\n".join(parts))
492
604
  return True
493
605
 
494
- if directives_block:
495
- _emit_cached_context(directives_block)
606
+ if directives_block or _dir_notice:
607
+ _emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
496
608
  return True
497
609
 
498
610
  # Nothing fresh, nothing stale, nothing cached — say so explicitly
@@ -534,8 +646,15 @@ def _emit_directives_only(config: dict, hook_input: dict) -> None:
534
646
  debug_log(config, f"Task-notification skip: directive fetch failed: {exc}")
535
647
  return
536
648
 
537
- if directives_block:
538
- _emit_cached_context(directives_block)
649
+ # Memory v2 M3 (Surface-A) — apply the flip decision here too, so a flipped
650
+ # agent's directives-only fast path (task-notification junk gate) also
651
+ # suppresses `<active_directives>` once the rules block carries them.
652
+ _inject_directives, _dir_notice = directive_injection_decision(config, hook_input)
653
+ if not _inject_directives:
654
+ directives_block = None
655
+
656
+ if directives_block or _dir_notice:
657
+ _emit_cached_context("\n\n".join([b for b in (_dir_notice, directives_block) if b]))
539
658
 
540
659
 
541
660
  def _is_demoted_memory(memory) -> bool:
@@ -2829,6 +2948,16 @@ def main():
2829
2948
  bank_errored = any(bt.get("errored") for bt in bank_timings)
2830
2949
 
2831
2950
  directives_block = format_active_directives_block(directives) if directives else None
2951
+
2952
+ # Memory v2 M3 (Surface-A) — directive-injection flip decision. Suppress the
2953
+ # `<active_directives>` block iff this agent is flipped (injectDirectives
2954
+ # OFF) AND a non-empty CLAUDE.md rules block physically carries the same
2955
+ # guarantees; else keep injecting and carry a degraded-canary notice. This
2956
+ # is the primary (synchronous) surface; the two fast paths above mirror it.
2957
+ _inject_directives, directive_flip_notice = directive_injection_decision(config, hook_input)
2958
+ if not _inject_directives:
2959
+ directives_block = None
2960
+
2832
2961
  if directives_block:
2833
2962
  debug_log(config, f"Injecting {len(directives)} active directives")
2834
2963
 
@@ -3267,12 +3396,13 @@ def main():
3267
3396
  # is precisely the turn on which the agent must not assume it remembers.
3268
3397
  # #3837: so is a set the score floor withheld entirely.
3269
3398
  if not directives_block and not memories_block and not transcript_fallback_block:
3270
- if degraded_block or withheld_block or nudge_block or profile_nudge_block:
3399
+ if degraded_block or withheld_block or nudge_block or profile_nudge_block or directive_flip_notice:
3271
3400
  _emit_cached_context(
3272
3401
  "\n\n".join(
3273
3402
  [
3274
3403
  b
3275
3404
  for b in (
3405
+ directive_flip_notice,
3276
3406
  degraded_block,
3277
3407
  withheld_block,
3278
3408
  nudge_block,
@@ -3346,14 +3476,17 @@ def main():
3346
3476
  "hookSpecificOutput": {
3347
3477
  "hookEventName": "UserPromptSubmit",
3348
3478
  "additionalContext": _combine_context(
3479
+ directive_flip_notice,
3349
3480
  _combine_context(
3350
3481
  _combine_context(
3351
- _combine_context(degraded_block, withheld_block),
3352
- context_message,
3482
+ _combine_context(
3483
+ _combine_context(degraded_block, withheld_block),
3484
+ context_message,
3485
+ ),
3486
+ nudge_block,
3353
3487
  ),
3354
- nudge_block,
3488
+ profile_nudge_block,
3355
3489
  ),
3356
- profile_nudge_block,
3357
3490
  ),
3358
3491
  }
3359
3492
  }
@@ -73,7 +73,16 @@ def build_hooks(plugin_root: str) -> dict:
73
73
  "timeout": 5,
74
74
  }
75
75
  ]
76
- }
76
+ },
77
+ {
78
+ "hooks": [
79
+ {
80
+ "type": "command",
81
+ "command": f'python3 "{plugin_root}/scripts/orientation.py"',
82
+ "timeout": 8,
83
+ }
84
+ ]
85
+ },
77
86
  ],
78
87
  "SessionEnd": [
79
88
  {
@@ -0,0 +1,9 @@
1
+ <!-- switchroom:rules:begin -->
2
+ Standing rules — sanctioned via the `memory rule` tool. Do not hand-edit;
3
+ edits made outside the tool break the tamper sentinel below.
4
+
5
+ - **R-01** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Always end replies to the user with a one-line summary.
6
+ - **R-02** (source: reflect-directive, added 2026-08-17T00:00:00.000Z): Never send email without explicit user approval.
7
+
8
+ <!-- switchroom:rules:sentinel sha256=533c23603bb3b72930c8c79b4f4970481b0ce7caf13c3e2bb63953b652b8a0ae rules=2 -->
9
+ <!-- switchroom:rules:end -->