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
@@ -11144,6 +11144,10 @@ var init_schema = __esm(() => {
11144
11144
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
11145
11145
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
11146
11146
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
11147
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
11148
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
11149
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
11150
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
11147
11151
  disposition: exports_external.object({
11148
11152
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
11149
11153
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -11589,6 +11593,7 @@ var init_schema = __esm(() => {
11589
11593
  profile: exports_external.string().optional(),
11590
11594
  directive_capture_nudge: exports_external.boolean().optional(),
11591
11595
  profile_capture_nudge: exports_external.boolean().optional(),
11596
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
11592
11597
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
11593
11598
  observation_scopes: ObservationScopesSchema,
11594
11599
  observation_scope_strategy: ObservationScopeStrategySchema,
@@ -21593,7 +21598,7 @@ function allocateAgentUid(name) {
21593
21598
  }
21594
21599
 
21595
21600
  // src/build-info.ts
21596
- var VERSION = "0.21.16";
21601
+ var VERSION = "0.21.18";
21597
21602
 
21598
21603
  // src/setup/hindsight-recall-passthrough.ts
21599
21604
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
@@ -4584,6 +4584,10 @@ var init_schema = __esm(() => {
4584
4584
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
4585
4585
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
4586
4586
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
4587
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
4588
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
4589
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
4590
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
4587
4591
  disposition: exports_external.object({
4588
4592
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
4589
4593
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -5029,6 +5033,7 @@ var init_schema = __esm(() => {
5029
5033
  profile: exports_external.string().optional(),
5030
5034
  directive_capture_nudge: exports_external.boolean().optional(),
5031
5035
  profile_capture_nudge: exports_external.boolean().optional(),
5036
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
5032
5037
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
5033
5038
  observation_scopes: ObservationScopesSchema,
5034
5039
  observation_scope_strategy: ObservationScopeStrategySchema,
@@ -4180,6 +4180,10 @@ var init_schema = __esm(() => {
4180
4180
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
4181
4181
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
4182
4182
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
4183
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
4184
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
4185
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
4186
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
4183
4187
  disposition: exports_external.object({
4184
4188
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
4185
4189
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -4625,6 +4629,7 @@ var init_schema = __esm(() => {
4625
4629
  profile: exports_external.string().optional(),
4626
4630
  directive_capture_nudge: exports_external.boolean().optional(),
4627
4631
  profile_capture_nudge: exports_external.boolean().optional(),
4632
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
4628
4633
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
4629
4634
  observation_scopes: ObservationScopesSchema,
4630
4635
  observation_scope_strategy: ObservationScopeStrategySchema,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.16",
4
+ "version": "0.21.18",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -1198,6 +1198,17 @@ export HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE={{hindsightDirectiveCaptureNudge}}
1198
1198
  {{#if hindsightProfileCaptureNudge}}
1199
1199
  export HINDSIGHT_PROFILE_CAPTURE_NUDGE={{hindsightProfileCaptureNudge}}
1200
1200
  {{/if}}
1201
+ # Memory v2 M3 Surface-A — directive-injection switch (memory.inject_directives
1202
+ # cascade). On by default (plugin settings.json): recall.py injects the
1203
+ # <active_directives> block every turn. Export only when the operator overrode
1204
+ # it; set false to SUPPRESS injection for this agent (a flipped M3 canary) — its
1205
+ # standing rules then come from the live rules block instead. recall.py keeps
1206
+ # the suppression fail-safe: it drops injection ONLY when a non-empty rules
1207
+ # block is physically present in CLAUDE.md, else it keeps injecting and emits a
1208
+ # degraded-canary notice. Flip via `switchroom memory flip`, never a bare edit.
1209
+ {{#if hindsightInjectDirectives}}
1210
+ export HINDSIGHT_INJECT_DIRECTIVES={{hindsightInjectDirectives}}
1211
+ {{/if}}
1201
1212
  # Per-row observation scope (memory.observation_scopes cascade). Stamped on
1202
1213
  # every retain — Stop hook, sidechain, boot reconcile, queue drain, backfill.
1203
1214
  # "shared" makes consolidation pool this agent's observations into ONE global
@@ -21798,6 +21798,10 @@ var init_schema = __esm(() => {
21798
21798
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry \u2014 e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced \u2014 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded \u2014 each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
21799
21799
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
21800
21800
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded \u2014 the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false \u21d2 byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded \u2014 each agent's flip is a deliberate M3 rollout step)."),
21801
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false \u2014 dark build (unset \u21d2 OFF, applied by the " + "scaffold resolver, NOT a Zod .default() \u2014 a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false \u21d2 byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded \u2014 a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 \u00a77). " + "Cascade: override (per-agent wins over default)."),
21802
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset \u21d2 0, applied by the scaffold resolver, not a Zod " + ".default() \u2014 same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 \u00a72/\u00a74). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
21803
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset \u21d2 48, applied by the scaffold resolver, not a Zod " + ".default() \u2014 a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 \u00a76, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5\u00d7 cadence, cold degrade at " + "3\u00d7) \u2014 so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
21804
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE \u2014 " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent \u2014 its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md \u2014 a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off \u2014 enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier \u2014 each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
21801
21805
  disposition: exports_external.object({
21802
21806
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
21803
21807
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -22243,6 +22247,7 @@ var init_schema = __esm(() => {
22243
22247
  profile: exports_external.string().optional(),
22244
22248
  directive_capture_nudge: exports_external.boolean().optional(),
22245
22249
  profile_capture_nudge: exports_external.boolean().optional(),
22250
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
22246
22251
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
22247
22252
  observation_scopes: ObservationScopesSchema,
22248
22253
  observation_scope_strategy: ObservationScopeStrategySchema,
@@ -105871,10 +105876,10 @@ function startOutboxSweep(deps) {
105871
105876
  }
105872
105877
 
105873
105878
  // ../src/build-info.ts
105874
- var VERSION2 = "0.21.16";
105875
- var COMMIT_SHA = "d6c1267a";
105876
- var COMMIT_DATE = "2026-08-17T09:16:43Z";
105877
- var LATEST_PR = 4763;
105879
+ var VERSION2 = "0.21.18";
105880
+ var COMMIT_SHA = "ae063418";
105881
+ var COMMIT_DATE = "2026-08-18T03:14:53Z";
105882
+ var LATEST_PR = 4772;
105878
105883
  var COMMITS_AHEAD_OF_TAG = 0;
105879
105884
 
105880
105885
  // gateway/boot-version.ts
@@ -82,6 +82,12 @@ else
82
82
  uat/load-env.test.ts
83
83
  uat/uat-driver.test.ts
84
84
  uat/runners/
85
+ # M3 directive-flip UAT — deterministic (no-model, no-network) gate half.
86
+ # Same rationale as uat/runners/: vitest excludes all of uat/**, and these
87
+ # import `vitest` (bun runs them via its vitest-compat shim) — so they run
88
+ # on the BUN side. Directory match (trailing slash) never widens into
89
+ # uat/scenarios/. Fenced by scripts/check-test-runner-coverage.mjs.
90
+ uat/flip/
85
91
  )
86
92
  fi
87
93
 
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Unit suite for the UAT allowlist add/remove helper. Runs under `bun test`
3
+ * (this tree is vitest-excluded) via the `uat/flip/` entry in
4
+ * telegram-plugin/scripts/bun-test-ci.sh.
5
+ *
6
+ * Everything is hermetic: a tmp `agentsDir` is built per-test and torn down,
7
+ * and the clock is injected so `expiresAt`/`--expired` are deterministic. No
8
+ * real ~/.switchroom is touched.
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
12
+ import {
13
+ mkdtempSync,
14
+ mkdirSync,
15
+ writeFileSync,
16
+ readFileSync,
17
+ existsSync,
18
+ rmSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import {
23
+ addToAllowlist,
24
+ removeFromAllowlist,
25
+ sweepAllowlist,
26
+ runAllowlistCli,
27
+ accessFilePath,
28
+ receiptPath,
29
+ DEFAULT_TTL_MS,
30
+ type AllowlistReceipt,
31
+ } from "./allowlist.js";
32
+
33
+ let agentsDir: string;
34
+ let root: string;
35
+
36
+ function seedAccess(agent: string, obj: unknown): void {
37
+ const p = accessFilePath(agentsDir, agent);
38
+ mkdirSync(join(p, ".."), { recursive: true });
39
+ writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
40
+ }
41
+
42
+ function readAccess(agent: string): Record<string, unknown> {
43
+ return JSON.parse(readFileSync(accessFilePath(agentsDir, agent), "utf8"));
44
+ }
45
+
46
+ function readReceipt(agent: string): AllowlistReceipt {
47
+ return JSON.parse(readFileSync(receiptPath(agentsDir, agent), "utf8"));
48
+ }
49
+
50
+ function mkAgentDir(agent: string): void {
51
+ mkdirSync(join(agentsDir, agent, "telegram"), { recursive: true });
52
+ }
53
+
54
+ beforeEach(() => {
55
+ root = mkdtempSync(join(tmpdir(), "sr-uat-allowlist-"));
56
+ agentsDir = join(root, "agents");
57
+ mkdirSync(agentsDir, { recursive: true });
58
+ });
59
+
60
+ afterEach(() => {
61
+ rmSync(root, { recursive: true, force: true });
62
+ });
63
+
64
+ describe("addToAllowlist", () => {
65
+ it("appends the id, preserves sibling keys, and writes a receipt first", () => {
66
+ seedAccess("ziggy", {
67
+ dmPolicy: "allowlist",
68
+ allowFrom: ["111"],
69
+ groups: { "-100": { allowFrom: ["222"] } },
70
+ voice_in: true,
71
+ });
72
+ const now = Date.parse("2026-08-18T00:00:00.000Z");
73
+ const r = addToAllowlist("ziggy", "999", { agentsDir, now: () => now });
74
+
75
+ expect(r.added).toBe(true);
76
+ expect(r.preexisting).toBe(false);
77
+
78
+ const access = readAccess("ziggy");
79
+ expect(access.allowFrom).toEqual(["111", "999"]);
80
+ // sibling keys survive the whole-object rewrite.
81
+ expect(access.groups).toEqual({ "-100": { allowFrom: ["222"] } });
82
+ expect(access.voice_in).toBe(true);
83
+ expect(access.dmPolicy).toBe("allowlist");
84
+
85
+ const rcpt = readReceipt("ziggy");
86
+ expect(rcpt).toMatchObject({ agent: "ziggy", userId: "999", preexisting: false });
87
+ expect(rcpt.addedAt).toBe("2026-08-18T00:00:00.000Z");
88
+ expect(Date.parse(rcpt.expiresAt) - now).toBe(DEFAULT_TTL_MS);
89
+ });
90
+
91
+ it("creates access.json when none exists yet", () => {
92
+ mkAgentDir("newbie");
93
+ const r = addToAllowlist("newbie", "42", { agentsDir });
94
+ expect(r.added).toBe(true);
95
+ expect(readAccess("newbie").allowFrom).toEqual(["42"]);
96
+ });
97
+
98
+ it("is a no-op with preexisting:true when the id is already allowlisted", () => {
99
+ seedAccess("ziggy", { allowFrom: ["999"] });
100
+ const r = addToAllowlist("ziggy", "999", { agentsDir });
101
+ expect(r.added).toBe(false);
102
+ expect(r.preexisting).toBe(true);
103
+ // access.json unchanged; receipt records preexisting so revert won't strip it.
104
+ expect(readAccess("ziggy").allowFrom).toEqual(["999"]);
105
+ expect(readReceipt("ziggy").preexisting).toBe(true);
106
+ });
107
+
108
+ it("a second add is a no-op preserving the original receipt", () => {
109
+ seedAccess("ziggy", { allowFrom: [] });
110
+ const first = addToAllowlist("ziggy", "999", { agentsDir, now: () => 1000 });
111
+ const second = addToAllowlist("ziggy", "999", { agentsDir, now: () => 5000 });
112
+ expect(first.added).toBe(true);
113
+ expect(second.added).toBe(false);
114
+ // original addedAt preserved (not re-stamped to 5000).
115
+ expect(readReceipt("ziggy").addedAt).toBe(new Date(1000).toISOString());
116
+ });
117
+ });
118
+
119
+ describe("removeFromAllowlist", () => {
120
+ it("reverts an id the UAT added and clears the receipt", () => {
121
+ seedAccess("ziggy", { allowFrom: ["111"] });
122
+ addToAllowlist("ziggy", "999", { agentsDir });
123
+ expect(readAccess("ziggy").allowFrom).toEqual(["111", "999"]);
124
+
125
+ const r = removeFromAllowlist("ziggy", { agentsDir });
126
+ expect(r.reverted).toBe(true);
127
+ expect(r.receiptCleared).toBe(true);
128
+ expect(readAccess("ziggy").allowFrom).toEqual(["111"]);
129
+ expect(existsSync(receiptPath(agentsDir, "ziggy"))).toBe(false);
130
+ });
131
+
132
+ it("does NOT strip a preexisting id, but clears the receipt", () => {
133
+ seedAccess("ziggy", { allowFrom: ["999"] });
134
+ addToAllowlist("ziggy", "999", { agentsDir }); // preexisting
135
+ const r = removeFromAllowlist("ziggy", { agentsDir });
136
+ expect(r.reverted).toBe(false);
137
+ expect(r.reason).toBe("preexisting");
138
+ expect(r.receiptCleared).toBe(true);
139
+ expect(readAccess("ziggy").allowFrom).toEqual(["999"]);
140
+ expect(existsSync(receiptPath(agentsDir, "ziggy"))).toBe(false);
141
+ });
142
+
143
+ it("no-ops with no-receipt when there is nothing to revert", () => {
144
+ seedAccess("ziggy", { allowFrom: ["111"] });
145
+ const r = removeFromAllowlist("ziggy", { agentsDir });
146
+ expect(r.reverted).toBe(false);
147
+ expect(r.reason).toBe("no-receipt");
148
+ expect(readAccess("ziggy").allowFrom).toEqual(["111"]);
149
+ });
150
+
151
+ it("is idempotent — a second remove after revert is a no-op", () => {
152
+ seedAccess("ziggy", { allowFrom: [] });
153
+ addToAllowlist("ziggy", "999", { agentsDir });
154
+ removeFromAllowlist("ziggy", { agentsDir });
155
+ const second = removeFromAllowlist("ziggy", { agentsDir });
156
+ expect(second.reverted).toBe(false);
157
+ expect(second.reason).toBe("no-receipt");
158
+ });
159
+
160
+ it("handles a receipt whose id was already manually removed", () => {
161
+ seedAccess("ziggy", { allowFrom: [] });
162
+ addToAllowlist("ziggy", "999", { agentsDir });
163
+ // someone else stripped the id out of band.
164
+ seedAccess("ziggy", { allowFrom: [] });
165
+ const r = removeFromAllowlist("ziggy", { agentsDir });
166
+ expect(r.reverted).toBe(false);
167
+ expect(r.reason).toBe("not-present");
168
+ expect(r.receiptCleared).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe("sweepAllowlist", () => {
173
+ it("reverts every agent's receipt and is idempotent on a clean second pass", () => {
174
+ seedAccess("a", { allowFrom: [] });
175
+ seedAccess("b", { allowFrom: ["7"] });
176
+ addToAllowlist("a", "999", { agentsDir });
177
+ addToAllowlist("b", "888", { agentsDir });
178
+
179
+ const first = sweepAllowlist({ agentsDir });
180
+ expect(first.map((r) => r.agent).sort()).toEqual(["a", "b"]);
181
+ expect(first.every((r) => r.reverted)).toBe(true);
182
+ expect(readAccess("a").allowFrom).toEqual([]);
183
+ expect(readAccess("b").allowFrom).toEqual(["7"]);
184
+
185
+ const second = sweepAllowlist({ agentsDir });
186
+ expect(second).toEqual([]);
187
+ });
188
+
189
+ it("with expiredOnly, leaves un-expired receipts alone", () => {
190
+ seedAccess("a", { allowFrom: [] });
191
+ seedAccess("b", { allowFrom: [] });
192
+ const t0 = Date.parse("2026-08-18T00:00:00.000Z");
193
+ addToAllowlist("a", "111", { agentsDir, now: () => t0, ttlMs: 1000 });
194
+ addToAllowlist("b", "222", { agentsDir, now: () => t0, ttlMs: 60 * 60 * 1000 });
195
+
196
+ // 2s later: a's receipt (ttl 1s) is expired, b's (ttl 1h) is not.
197
+ const swept = sweepAllowlist({ agentsDir, expiredOnly: true, now: () => t0 + 2000 });
198
+ expect(swept.map((r) => r.agent)).toEqual(["a"]);
199
+ expect(readAccess("a").allowFrom).toEqual([]);
200
+ // b untouched.
201
+ expect(readAccess("b").allowFrom).toEqual(["222"]);
202
+ expect(existsSync(receiptPath(agentsDir, "b"))).toBe(true);
203
+ });
204
+
205
+ it("returns empty on a missing agents dir", () => {
206
+ expect(sweepAllowlist({ agentsDir: join(root, "nope") })).toEqual([]);
207
+ });
208
+ });
209
+
210
+ describe("runAllowlistCli", () => {
211
+ it("add then remove --sweep round-trips to zero residue", () => {
212
+ seedAccess("ziggy", { allowFrom: ["1"] });
213
+ expect(runAllowlistCli(["add", "ziggy", "999", "--agents-dir", agentsDir])).toBe(0);
214
+ expect(readAccess("ziggy").allowFrom).toEqual(["1", "999"]);
215
+
216
+ expect(runAllowlistCli(["remove", "--sweep", "--agents-dir", agentsDir])).toBe(0);
217
+ expect(readAccess("ziggy").allowFrom).toEqual(["1"]);
218
+ expect(existsSync(receiptPath(agentsDir, "ziggy"))).toBe(false);
219
+ });
220
+
221
+ it("add without a userId is a usage error (exit 1)", () => {
222
+ expect(runAllowlistCli(["add", "ziggy", "--agents-dir", agentsDir])).toBe(1);
223
+ });
224
+
225
+ it("unknown command is exit 1; bare invocation is exit 0", () => {
226
+ expect(runAllowlistCli(["frobnicate"])).toBe(1);
227
+ expect(runAllowlistCli([])).toBe(0);
228
+ });
229
+ });