switchroom 0.21.16 → 0.21.18

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 (43) hide show
  1. package/dist/agent-scheduler/index.js +5 -0
  2. package/dist/auth-broker/index.js +5 -0
  3. package/dist/cli/notion-write-pretool.mjs +5 -0
  4. package/dist/cli/switchroom.js +1492 -1136
  5. package/dist/host-control/main.js +6 -1
  6. package/dist/vault/approvals/kernel-server.js +5 -0
  7. package/dist/vault/broker/server.js +5 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +11 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +9 -4
  11. package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
  12. package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
  13. package/telegram-plugin/uat/flip/allowlist.ts +349 -0
  14. package/telegram-plugin/uat/flip/gate.test.ts +153 -0
  15. package/telegram-plugin/uat/flip/gate.ts +232 -0
  16. package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
  17. package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
  18. package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
  19. package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
  20. package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
  21. package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
  22. package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
  23. package/telegram-plugin/uat/flip/recall-log.ts +178 -0
  24. package/telegram-plugin/uat/flip/report.ts +95 -0
  25. package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
  26. package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
  27. package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
  28. package/telegram-plugin/uat/runners/scorer.ts +1 -1
  29. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  30. package/vendor/hindsight-memory/scripts/directive_verify.py +43 -1
  31. package/vendor/hindsight-memory/scripts/lib/client.py +35 -0
  32. package/vendor/hindsight-memory/scripts/lib/config.py +47 -0
  33. package/vendor/hindsight-memory/scripts/lib/orientation.py +248 -0
  34. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +29 -0
  35. package/vendor/hindsight-memory/scripts/orientation.py +195 -0
  36. package/vendor/hindsight-memory/scripts/prefetch.py +10 -0
  37. package/vendor/hindsight-memory/scripts/recall.py +144 -11
  38. package/vendor/hindsight-memory/scripts/setup_hooks.py +10 -1
  39. package/vendor/hindsight-memory/scripts/tests/fixtures/rules-block.golden.md +9 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_orientation_hook.py +283 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_orientation_logic.py +176 -0
  42. package/vendor/hindsight-memory/scripts/tests/test_prefetch_invalidation.py +329 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_recall_directive_suppression.py +328 -0
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * M3 directive-flip UAT — Tier-2 behavioural probe runner.
4
+ *
5
+ * The model-in-the-loop half of the flip gate: drives a real Telegram
6
+ * user-account (the same mtcute `Driver` the rest of `uat/` uses) against a
7
+ * TARGET agent and, per probe, verifies the agent still HONOURS its migrated
8
+ * guardrails in live conversation. Deterministic scoring only — every reply is
9
+ * regex-matched against the probe's `passPattern` (see `probe-scoring.ts`); NO
10
+ * LLM judge.
11
+ *
12
+ * Per probe: DM the benign prompt → `expectMessage` for the agent's answer →
13
+ * score → repeat k times with ≥`spacingMs` between sends (default 30s, which
14
+ * dominates the gateway's coalescing gap and keeps us under the user-account
15
+ * flood cap). Folds k attempts into GREEN (3/3) / AMBER (2/3) / RED (≤1/3), and
16
+ * writes one `flip/results/<agent>.<phase>.json` conforming to
17
+ * `Tier2ProbeResults`. A separate baseline + postflip run produce two files a
18
+ * caller diffs with `detectRegressions` (postflip rate < baseline rate).
19
+ *
20
+ * SAFETY: this runner only ever SENDS benign questions. It never flips an
21
+ * agent, never edits config, and the probe suites are authored to contain no
22
+ * actionable instruction with tool side-effects. Point it only at internal
23
+ * test agents.
24
+ *
25
+ * Targeting mirrors `runners/agent-self-sufficiency.ts`: `--agent name:@bot`
26
+ * (repeatable) or `UAT_FLEET="name:@bot,..."`. Auth env (via repo-root `.env`,
27
+ * loaded by `loadUatEnv`): TELEGRAM_API_ID, TELEGRAM_API_HASH,
28
+ * TELEGRAM_UAT_DRIVER_SESSION.
29
+ *
30
+ * Usage:
31
+ * bun telegram-plugin/uat/flip/tier2-probe-runner.ts \
32
+ * --agent test-harness:@meken_switchroom_test_bot \
33
+ * --phase baseline
34
+ *
35
+ * # smoke: one benign probe, single repeat
36
+ * bun telegram-plugin/uat/flip/tier2-probe-runner.ts \
37
+ * --agent test-harness:@meken_switchroom_test_bot --phase baseline --k 1 --smoke
38
+ */
39
+
40
+ import { mkdirSync, writeFileSync } from "node:fs";
41
+ import path from "node:path";
42
+ import { fileURLToPath } from "node:url";
43
+ import { Driver, type ObservedMessage } from "../driver.js";
44
+ import { loadUatEnv } from "../load-env.js";
45
+ import { expectMessage, isAnswer } from "../assertions.js";
46
+ import { loadProbeSuite, type ProbeSpec, type ProbeSuite } from "./probe-suite.js";
47
+ import {
48
+ foldPhase,
49
+ foldProbe,
50
+ scoreAttempt,
51
+ } from "./probe-scoring.js";
52
+ import type { Tier2ProbeAttempt, Tier2ProbeOutcome, Tier2ProbeResults, ProbePhase } from "./gate.js";
53
+
54
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
55
+
56
+ // ─── CLI / env parsing ──────────────────────────────────────────────────────
57
+
58
+ interface AgentTarget {
59
+ name: string;
60
+ botUsername: string;
61
+ }
62
+
63
+ interface CliConfig {
64
+ agents: AgentTarget[];
65
+ phase: ProbePhase;
66
+ /** Repeats per probe. Default 3. */
67
+ k: number;
68
+ /** Minimum gap between sends of the same probe, ms. Default 30_000. */
69
+ spacingMs: number;
70
+ /** Per-reply observation deadline, ms. Default 120_000. */
71
+ replyTimeoutMs: number;
72
+ /** Output directory for `<agent>.<phase>.json`. Default `<flip>/results`. */
73
+ outDir: string;
74
+ /** Explicit suite path override (single-agent runs). */
75
+ suitePath?: string;
76
+ /** Smoke mode: run only the FIRST probe of the suite, k forced to its value
77
+ * (typically 1). Proves transport without a full behavioural sweep. */
78
+ smoke: boolean;
79
+ }
80
+
81
+ function fail(msg: string): never {
82
+ process.stderr.write(`[tier2] ${msg}\n`);
83
+ process.exit(2);
84
+ }
85
+
86
+ function parseCli(argv: readonly string[]): CliConfig {
87
+ const agents = new Map<string, AgentTarget>();
88
+ let phase: ProbePhase = (process.env.UAT_FLIP_PHASE as ProbePhase) || "baseline";
89
+ let k = Number.parseInt(process.env.UAT_PROBE_K ?? "3", 10);
90
+ let spacingMs = Number.parseInt(process.env.UAT_PROBE_SPACING_MS ?? "30000", 10);
91
+ let replyTimeoutMs = Number.parseInt(process.env.UAT_PROBE_TIMEOUT_MS ?? "120000", 10);
92
+ let outDir = process.env.UAT_PROBE_OUT_DIR ?? path.join(HERE, "results");
93
+ let suitePath: string | undefined;
94
+ let smoke = false;
95
+
96
+ const envFleet = process.env.UAT_FLEET;
97
+ if (envFleet) {
98
+ for (const tok of envFleet.split(",")) {
99
+ const [name, bot] = tok.split(":").map((s) => s.trim());
100
+ if (name && bot) agents.set(name, { name, botUsername: bot });
101
+ }
102
+ }
103
+
104
+ for (let i = 0; i < argv.length; i++) {
105
+ const tok = argv[i]!;
106
+ const next = (): string => {
107
+ const v = argv[++i];
108
+ if (v === undefined) fail(`${tok}: missing value`);
109
+ return v;
110
+ };
111
+ switch (tok) {
112
+ case "--agent": {
113
+ const v = next();
114
+ const [name, bot] = v.split(":").map((s) => s.trim());
115
+ if (!name || !bot) fail(`--agent expects "<name>:@<bot-username>"; got "${v}"`);
116
+ agents.set(name, { name, botUsername: bot });
117
+ break;
118
+ }
119
+ case "--phase": {
120
+ const v = next();
121
+ if (v !== "baseline" && v !== "postflip") fail(`--phase must be baseline|postflip; got "${v}"`);
122
+ phase = v;
123
+ break;
124
+ }
125
+ case "--k":
126
+ k = Number.parseInt(next(), 10);
127
+ break;
128
+ case "--spacing-ms":
129
+ spacingMs = Number.parseInt(next(), 10);
130
+ break;
131
+ case "--reply-timeout-ms":
132
+ replyTimeoutMs = Number.parseInt(next(), 10);
133
+ break;
134
+ case "--out-dir":
135
+ outDir = next();
136
+ break;
137
+ case "--suite":
138
+ suitePath = next();
139
+ break;
140
+ case "--smoke":
141
+ smoke = true;
142
+ break;
143
+ case "--help":
144
+ case "-h":
145
+ printHelp();
146
+ process.exit(0);
147
+ break;
148
+ default:
149
+ if (tok.startsWith("--")) fail(`unknown flag: ${tok}`);
150
+ }
151
+ }
152
+
153
+ if (agents.size === 0) {
154
+ fail('no agent targeted. Pass --agent <name>:@<bot> or set UAT_FLEET. Tier-2 probes only ever target internal TEST agents.');
155
+ }
156
+ if (!Number.isFinite(k) || k < 1) fail(`--k must be a positive integer; got ${k}`);
157
+ if (suitePath && agents.size > 1) {
158
+ fail("--suite is a single-agent override; pass exactly one --agent with it");
159
+ }
160
+
161
+ return {
162
+ agents: [...agents.values()],
163
+ phase,
164
+ k,
165
+ spacingMs,
166
+ replyTimeoutMs,
167
+ outDir,
168
+ ...(suitePath ? { suitePath } : {}),
169
+ smoke,
170
+ };
171
+ }
172
+
173
+ function printHelp(): void {
174
+ process.stdout.write(`M3 directive-flip Tier-2 behavioural probe runner
175
+
176
+ Required env (or fail loud):
177
+ TELEGRAM_API_ID, TELEGRAM_API_HASH, TELEGRAM_UAT_DRIVER_SESSION
178
+
179
+ Flags:
180
+ --agent NAME:@BOT Target agent. Repeatable. (INTERNAL TEST AGENTS ONLY.)
181
+ --phase baseline|postflip Which flip phase this run records. Default baseline.
182
+ --k N Repeats per probe. Default 3.
183
+ --spacing-ms N Min gap between sends of a probe. Default 30000.
184
+ --reply-timeout-ms N Per-reply deadline. Default 120000.
185
+ --out-dir DIR Results dir. Default <flip>/results.
186
+ --suite PATH Suite override (single --agent). Default probes/<agent>.probes.json.
187
+ --smoke Run only the first probe (transport check).
188
+
189
+ Env equivalents: UAT_FLEET, UAT_FLIP_PHASE, UAT_PROBE_K, UAT_PROBE_SPACING_MS,
190
+ UAT_PROBE_TIMEOUT_MS, UAT_PROBE_OUT_DIR
191
+ `);
192
+ }
193
+
194
+ // ─── Live probe execution ─────────────────────────────────────────────────────
195
+
196
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, Math.max(0, ms)));
197
+
198
+ /**
199
+ * Send one probe prompt and wait for the agent's answer. Uses the prescribed
200
+ * seam: `sendText` then `expectMessage(driver, botId, matcher, {timeout})`,
201
+ * where the matcher is `isAnswer` so worker-feed / activity-card surfaces and
202
+ * the driver's own echo are excluded. Returns a scored {@link Tier2ProbeAttempt}.
203
+ */
204
+ async function runOneAttempt(
205
+ driver: Driver,
206
+ botUserId: number,
207
+ driverUserId: number,
208
+ spec: ProbeSpec,
209
+ timeoutMs: number,
210
+ ): Promise<Tier2ProbeAttempt> {
211
+ const startedAt = Date.now();
212
+ try {
213
+ await driver.sendText(botUserId, spec.prompt);
214
+ } catch (err) {
215
+ return scoreAttempt(spec, "", Date.now() - startedAt, "error", `send failed: ${(err as Error).message}`);
216
+ }
217
+ try {
218
+ const answer: ObservedMessage = await expectMessage(
219
+ driver,
220
+ botUserId,
221
+ (m) => isAnswer(m, driverUserId),
222
+ { timeout: timeoutMs, senderFilter: { notUserId: driverUserId } },
223
+ );
224
+ return scoreAttempt(spec, answer.text, Date.now() - startedAt, "reply");
225
+ } catch (err) {
226
+ const msg = (err as Error).message;
227
+ const kind = /within \d+ms/.test(msg) ? "timeout" : "error";
228
+ return scoreAttempt(spec, "", Date.now() - startedAt, kind, msg);
229
+ }
230
+ }
231
+
232
+ async function runAgent(
233
+ driver: Driver,
234
+ driverUserId: number,
235
+ target: AgentTarget,
236
+ suite: ProbeSuite,
237
+ suiteLabel: string,
238
+ cli: CliConfig,
239
+ ): Promise<Tier2ProbeResults> {
240
+ process.stdout.write(`\n[tier2] ─── agent: ${target.name} (${target.botUsername}) phase=${cli.phase} ───\n`);
241
+ const botUserId = await driver.resolveBotUserId(target.botUsername);
242
+ process.stdout.write(`[tier2] resolved ${target.botUsername} → bot_user_id=${botUserId}\n`);
243
+
244
+ const probes = cli.smoke ? suite.probes.slice(0, 1) : suite.probes;
245
+ if (cli.smoke) process.stdout.write(`[tier2] SMOKE mode: running only "${probes[0]?.id}"\n`);
246
+
247
+ const outcomes: Tier2ProbeOutcome[] = [];
248
+ for (const spec of probes) {
249
+ const attempts: Tier2ProbeAttempt[] = [];
250
+ for (let rep = 0; rep < cli.k; rep++) {
251
+ const a = await runOneAttempt(driver, botUserId, driverUserId, spec, cli.replyTimeoutMs);
252
+ attempts.push(a);
253
+ const glyph = a.pass ? "✓" : a.outcome === "timeout" ? "·" : "✗";
254
+ process.stdout.write(
255
+ `[tier2] ${glyph} ${spec.id} rep ${rep + 1}/${cli.k} (${a.outcome}, ${a.durationMs}ms)\n`,
256
+ );
257
+ // Space repeats of the SAME probe by ≥ spacingMs (respect coalescing +
258
+ // flood limits). No wait after the final repeat of the final probe.
259
+ const isLast = rep === cli.k - 1 && spec === probes[probes.length - 1];
260
+ if (!isLast) await sleep(cli.spacingMs);
261
+ }
262
+ const folded = foldProbe(spec, attempts);
263
+ process.stdout.write(`[tier2] → ${spec.id}: ${folded.verdict} (${folded.passCount}/${folded.k})\n`);
264
+ outcomes.push(folded);
265
+ }
266
+
267
+ return foldPhase(target.name, cli.phase, suiteLabel, outcomes);
268
+ }
269
+
270
+ // ─── Main ──────────────────────────────────────────────────────────────────
271
+
272
+ async function main(): Promise<void> {
273
+ loadUatEnv();
274
+ const cli = parseCli(process.argv.slice(2));
275
+
276
+ const apiId = Number.parseInt(process.env.TELEGRAM_API_ID ?? "", 10);
277
+ if (!Number.isFinite(apiId)) fail("TELEGRAM_API_ID missing or non-integer — see telegram-plugin/uat/SETUP.md");
278
+ const apiHash = process.env.TELEGRAM_API_HASH ?? "";
279
+ if (!apiHash) fail("TELEGRAM_API_HASH missing — see SETUP.md");
280
+ const session = process.env.TELEGRAM_UAT_DRIVER_SESSION ?? "";
281
+ if (!session) fail("TELEGRAM_UAT_DRIVER_SESSION missing — run `bun run uat:login` first (SETUP.md §4)");
282
+
283
+ // Resolve + validate every suite BEFORE connecting, so a bad suite fails
284
+ // without spending a live session.
285
+ const plans = cli.agents.map((target) => {
286
+ const suitePath = cli.suitePath ?? path.join(HERE, "probes", `${target.name}.probes.json`);
287
+ const suite = loadProbeSuite(suitePath);
288
+ if (suite.agent !== target.name) {
289
+ fail(`suite ${suitePath} declares agent "${suite.agent}" but target is "${target.name}"`);
290
+ }
291
+ return { target, suite, suiteLabel: path.basename(suitePath) };
292
+ });
293
+
294
+ process.stdout.write(`[tier2] connecting to Telegram as the UAT driver account...\n`);
295
+ const driver = new Driver({ apiId, apiHash, session });
296
+ await driver.connect();
297
+ const driverUserId = await driver.getMyUserId();
298
+ process.stdout.write(`[tier2] driver user_id=${driverUserId}\n`);
299
+
300
+ mkdirSync(cli.outDir, { recursive: true });
301
+ const written: string[] = [];
302
+ try {
303
+ for (const plan of plans) {
304
+ const results = await runAgent(driver, driverUserId, plan.target, plan.suite, plan.suiteLabel, cli);
305
+ const outPath = path.join(cli.outDir, `${plan.target.name}.${cli.phase}.json`);
306
+ writeFileSync(outPath, `${JSON.stringify(results, null, 2)}\n`, "utf-8");
307
+ written.push(outPath);
308
+ process.stdout.write(
309
+ `[tier2] wrote ${outPath} — phase ${results.pass ? "PASS" : "FAIL"} ` +
310
+ `(${(results.probes ?? []).filter((p) => p.verdict === "GREEN").length}/${(results.probes ?? []).length} GREEN)\n`,
311
+ );
312
+ }
313
+ } finally {
314
+ await driver.disconnect();
315
+ }
316
+
317
+ process.stdout.write(`\n[tier2] done. wrote ${written.length} result file(s):\n`);
318
+ for (const w of written) process.stdout.write(` ${w}\n`);
319
+ }
320
+
321
+ // Only run when invoked directly (not when imported by a test).
322
+ if (import.meta.main) {
323
+ main().catch((err) => {
324
+ process.stderr.write(`[tier2] fatal: ${(err as Error).stack ?? err}\n`);
325
+ process.exit(1);
326
+ });
327
+ }
@@ -60,7 +60,7 @@ export function scoreReply(
60
60
  * whitespace. Permissive on purpose — the scorer's regex matches
61
61
  * against words, not formatting.
62
62
  */
63
- function stripMarkdown(s: string): string {
63
+ export function stripMarkdown(s: string): string {
64
64
  return s
65
65
  .replace(/```[\s\S]*?```/g, " ")
66
66
  .replace(/`([^`]+)`/g, "$1")
@@ -10,6 +10,15 @@
10
10
  "timeout": 30
11
11
  }
12
12
  ]
13
+ },
14
+ {
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/orientation.py\"",
19
+ "timeout": 8
20
+ }
21
+ ]
13
22
  }
14
23
  ],
15
24
  "UserPromptSubmit": [
@@ -64,6 +64,7 @@ from lib.directives import ( # noqa: E402
64
64
  parse_active_directives_block,
65
65
  rule_already_captured,
66
66
  )
67
+ from lib import recall_buffer # noqa: E402
67
68
 
68
69
  # Reuse Stage B's pleasantry scrub so "as always" / "always happy to help"
69
70
  # can't trip the high-confidence detector either. Imported lazily-safe: if
@@ -387,6 +388,37 @@ def invalidate_cache_on_directive_write(messages: list, config: dict) -> None:
387
388
  debug_log(config, f"Directives cache invalidation skipped (error): {e}")
388
389
 
389
390
 
391
+ def invalidate_prefetch_buffer_on_directive_write(messages: list, config: dict, session_id: str) -> None:
392
+ """Drop this session's pending M4 prefetch buffer if this turn wrote a
393
+ directive (create/update/delete/retire).
394
+
395
+ red-team-M3 R2 (BLOCKER): the A4 cache invalidation above keeps the fresh
396
+ <active_directives> block current, but the M4 prefetch buffer carries a
397
+ RECALLED memories block captured at a prior turn's Stop — and a rule/
398
+ directive that was just retired can survive in that snapshot as recalled
399
+ text. Without this, a buffer prefetched while the rule was active would
400
+ re-inject the retired rule on the next turn (the exact §4.4 resurrection
401
+ failure), and `run_prefetch` bailing on an empty recall would leave that
402
+ stale buffer to be served on later turns too. Deleting the buffer + sentinel
403
+ here forces the consumer to fall to the synchronous, always-current path
404
+ (or the explicitly stale-marked fallback) instead of resurrecting it.
405
+
406
+ Only fires when `memoryPrefetchEnabled` is on (the buffer only exists then)
407
+ — a cheap no-op statting two absent files otherwise, so gated to avoid it.
408
+ Self-guarded; never raises (a Stop hook must not wedge a turn).
409
+ """
410
+ try:
411
+ if not messages or not config.get("memoryPrefetchEnabled", False):
412
+ return
413
+ idx, _text = find_last_human_turn(messages)
414
+ start = idx if idx is not None else -1
415
+ if turn_contains_directive_write(messages, start):
416
+ recall_buffer.invalidate(session_id or "unknown")
417
+ debug_log(config, "Prefetch buffer invalidated — turn wrote a directive (R2 resurrection guard)")
418
+ except Exception as e: # pragma: no cover - defensive; Stop must not wedge
419
+ debug_log(config, f"Prefetch-buffer invalidation skipped (error): {e}")
420
+
421
+
390
422
  def read_transcript(transcript_path: str) -> list:
391
423
  """Read a JSONL transcript into a list of message dicts (role/content).
392
424
 
@@ -508,6 +540,7 @@ def main():
508
540
  # and this is not an already-blocked re-fire.
509
541
  ttl = config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS)
510
542
  cache_on = isinstance(ttl, (int, float)) and ttl > 0
543
+ prefetch_on = bool(config.get("memoryPrefetchEnabled", False))
511
544
  verify_maybe = (
512
545
  config.get("directiveCaptureNudge", True)
513
546
  and config.get("directiveCaptureVerify", True)
@@ -515,7 +548,7 @@ def main():
515
548
  )
516
549
 
517
550
  messages: list = []
518
- if cache_on or verify_maybe:
551
+ if cache_on or verify_maybe or prefetch_on:
519
552
  messages = read_transcript(hook_input.get("transcript_path", ""))
520
553
 
521
554
  # A4: invalidate the directives cache when this turn wrote a directive, so
@@ -524,6 +557,15 @@ def main():
524
557
  if cache_on:
525
558
  invalidate_cache_on_directive_write(messages, config)
526
559
 
560
+ # R2 (BLOCKER): invalidate this session's M4 prefetch buffer when this turn
561
+ # wrote/retired a directive, so a stale pre-retire snapshot can never
562
+ # resurrect the retired rule on a later turn. Self-guarded no-op when
563
+ # prefetch is off.
564
+ if prefetch_on:
565
+ invalidate_prefetch_buffer_on_directive_write(
566
+ messages, config, hook_input.get("session_id") or "unknown"
567
+ )
568
+
527
569
  try:
528
570
  reason = evaluate(hook_input, config, messages=messages)
529
571
  except Exception as e: # never wedge a turn on a verify bug
@@ -519,3 +519,38 @@ class HindsightClient:
519
519
  if retain_mission:
520
520
  updates["retain_mission"] = retain_mission
521
521
  return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
522
+
523
+ def list_mental_models(self, bank_id: str, timeout: int = 5) -> dict:
524
+ """List the mental models for a bank (Memory v2 M5 — orientation-at-boot).
525
+
526
+ The orientation SessionStart hook knows the orientation model by NAME
527
+ (the configured `memoryOrientationModel`), not by its `mm-…` id, so it
528
+ lists the bank's models and matches on name → id before reading content
529
+ (carve-M5 §0d/§3). Read-only GET on the ungated engine REST surface;
530
+ m5-0-probe measured the own-bank read at ~4ms on klanker.
531
+
532
+ REST: ``GET /v1/default/banks/{bank_id}/mental-models``. Returns the raw
533
+ response dict, expected to carry an ``items`` list where each item has at
534
+ least ``id`` and ``name``.
535
+ """
536
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/mental-models"
537
+ return self._request("GET", path, timeout=timeout)
538
+
539
+ def get_mental_model(
540
+ self, bank_id: str, model_id: str, detail: str = "full", timeout: int = 5
541
+ ) -> dict:
542
+ """Read one mental model's content + freshness watermark (M5).
543
+
544
+ REST: ``GET /v1/default/banks/{bank_id}/mental-models/{model_id}?detail=full``
545
+ (m5-0-probe Q3/Q4 proved this returns ``content`` + the
546
+ ``last_refreshed_at`` watermark the staleness guard keys on, ~4ms warm).
547
+ Read-only; ungated. The caller wraps this in a generous read-timeout and
548
+ degrades to the cold notice on any error — a slow/failed read must never
549
+ block boot (carve §3 fail-safe).
550
+ """
551
+ bank = urllib.parse.quote(bank_id, safe="")
552
+ model = urllib.parse.quote(model_id, safe="")
553
+ path = f"/v1/default/banks/{bank}/mental-models/{model}"
554
+ if detail:
555
+ path = f"{path}?{urllib.parse.urlencode({'detail': detail})}"
556
+ return self._request("GET", path, timeout=timeout)
@@ -130,6 +130,20 @@ DEFAULTS = {
130
130
  # opt out per-agent via memory.profile_capture_nudge=false →
131
131
  # HINDSIGHT_PROFILE_CAPTURE_NUDGE.
132
132
  "profileCaptureNudge": True,
133
+ # Switchroom Memory v2 M3 Surface-A — directive-injection switch. When True
134
+ # (default), recall.py injects the `<active_directives>` block on every
135
+ # UserPromptSubmit at all three emit sites (main + prefetch/cache fast
136
+ # paths). When False (a flipped M3 canary), that injection is SUPPRESSED —
137
+ # the agent's standing rules come from the live rules block instead, which
138
+ # is the change that collapses the always-on directive-token spend (E-41).
139
+ # Suppression is fail-safe and re-checked every turn: it fires ONLY when a
140
+ # non-empty rules block is physically present in CLAUDE.md; a False flag
141
+ # with an empty/absent block keeps injecting AND emits a degraded-canary
142
+ # notice, so a zero-standing-rules turn is unreachable. Operators flip a
143
+ # canary per-agent via memory.inject_directives=false →
144
+ # HINDSIGHT_INJECT_DIRECTIVES; the ordered flip (rules_block live → migrate
145
+ # → this flag off) is enforced by `switchroom memory flip`.
146
+ "injectDirectives": True,
133
147
  # Switchroom #2873/#2903 Fix 6.2 — the BLOCKING half (Stage C
134
148
  # directive_verify.py Stop hook) split out from the advisory nudge. When
135
149
  # True (default) the verifier may block the stop once to re-prompt capture;
@@ -140,6 +154,23 @@ DEFAULTS = {
140
154
  # per-agent via memory.directive_capture_verify=false →
141
155
  # HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
142
156
  "directiveCaptureVerify": True,
157
+ # Memory v2 M5 — orientation-at-boot (Surface B). All four fail-safe by
158
+ # default so a stripped/absent key (the Zod-strip footgun, carve §1) boots
159
+ # exactly as pre-M5. `memoryOrientationEnabled` is the per-agent kill switch,
160
+ # default OFF (dark build — the orientation SessionStart hook no-ops before
161
+ # any bank resolve or network call when off). `memoryOrientationModel` is the
162
+ # mental-model NAME the hook resolves to an id (the agent's OWN bank).
163
+ # `memoryOrientationCadenceHours` is the per-agent refresh cadence tier
164
+ # (klanker/overlord 24, everyone else 48) the staleness guard's per-tier
165
+ # thresholds (1.5×/3×) key on. `memoryOrientationReinjectTurns` is the
166
+ # epic's optional per-turn re-inject knob, default 0 = SessionStart+compaction
167
+ # only (the cheap path; N>0 is the ~55M/30d-at-every-turn expensive variant,
168
+ # which is why it defaults off). Delivered per-agent via the scaffold
169
+ # settings.json stamp and overridable via the HINDSIGHT_ORIENTATION_* env.
170
+ "memoryOrientationEnabled": False,
171
+ "memoryOrientationModel": "orientation",
172
+ "memoryOrientationCadenceHours": 48,
173
+ "memoryOrientationReinjectTurns": 0,
143
174
  # Switchroom hindsight-leverage A4 — TTL (seconds) for the directives-list
144
175
  # cache on the recall critical path (see lib/directives.py). The list is
145
176
  # re-fetched at most once per TTL window for no-write turns; in-session
@@ -510,11 +541,27 @@ ENV_OVERRIDES = {
510
541
  # it; the switchroom default is on (settings.json pins true; recall.py falls
511
542
  # back to True).
512
543
  "HINDSIGHT_PROFILE_CAPTURE_NUDGE": ("profileCaptureNudge", bool),
544
+ # Switchroom Memory v2 M3 Surface-A: directive-injection switch on/off. Set
545
+ # by start.sh from agents.<name>.memory.inject_directives only when the
546
+ # operator overrode it; the switchroom default is on (settings.json pins
547
+ # true; recall.py falls back to True). False SUPPRESSES the
548
+ # <active_directives> injection for a flipped canary, fail-safe on a live
549
+ # rules block (see recall.py directive_injection_decision).
550
+ "HINDSIGHT_INJECT_DIRECTIVES": ("injectDirectives", bool),
513
551
  # Switchroom #2873/#2903 Fix 6.2: the Stage C block on/off, independent of
514
552
  # the Stage B nudge. Set by start.sh from
515
553
  # agents.<name>.memory.directive_capture_verify only when the operator
516
554
  # overrode it; the switchroom default is on.
517
555
  "HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
556
+ # Memory v2 M5 — orientation-at-boot. Env override channel for the four
557
+ # orientation knobs (settings.json carries the per-agent value; env wins for
558
+ # a docker-exec'd hook or an agent `env:` map). `memoryOrientationEnabled`
559
+ # is the per-agent kill switch (default off); the model NAME, cadence tier,
560
+ # and reinject count follow. See DEFAULTS above and carve-M5 §4.
561
+ "HINDSIGHT_ORIENTATION_ENABLED": ("memoryOrientationEnabled", bool),
562
+ "HINDSIGHT_ORIENTATION_MODEL": ("memoryOrientationModel", str),
563
+ "HINDSIGHT_ORIENTATION_CADENCE_HOURS": ("memoryOrientationCadenceHours", int),
564
+ "HINDSIGHT_ORIENTATION_REINJECT_TURNS": ("memoryOrientationReinjectTurns", int),
518
565
  # Switchroom hindsight-leverage A4 — directives-list cache TTL (seconds).
519
566
  # 0 disables the cache (rollback lever).
520
567
  "HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS": ("directivesCacheTtlSeconds", int),