switchroom 0.20.9 → 0.20.11

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 (59) hide show
  1. package/bin/handoff-briefing.sh +57 -5
  2. package/bin/working-state-reload-hook.sh +262 -0
  3. package/dist/agent-scheduler/index.js +65 -2
  4. package/dist/auth-broker/index.js +204 -24
  5. package/dist/cli/notion-write-pretool.mjs +65 -2
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  7. package/dist/cli/self-improve-stop.mjs +889 -7
  8. package/dist/cli/skill-validate-pretool.mjs +82 -3
  9. package/dist/cli/switchroom.js +3699 -2110
  10. package/dist/host-control/main.js +67 -4
  11. package/dist/vault/approvals/kernel-server.js +66 -3
  12. package/dist/vault/broker/server.js +66 -3
  13. package/package.json +1 -1
  14. package/profiles/_base/start.sh.hbs +49 -0
  15. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  16. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  17. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  19. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  20. package/profiles/default/CLAUDE.md.hbs +3 -5
  21. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  22. package/telegram-plugin/dist/gateway/gateway.js +1414 -918
  23. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  24. package/telegram-plugin/gateway/auth-command.ts +11 -1
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  26. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  27. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  28. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  29. package/telegram-plugin/gateway/gateway.ts +85 -90
  30. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  31. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  32. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  33. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  34. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  35. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  36. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  37. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  38. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  39. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  40. package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
  41. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -5
  43. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  44. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  45. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  46. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  47. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  48. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  49. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  50. package/telegram-plugin/throttle-tier.ts +12 -3
  51. package/telegram-plugin/turn-flush-safety.ts +97 -0
  52. package/vendor/hindsight-memory/CHANGELOG.md +31 -0
  53. package/vendor/hindsight-memory/hooks/hooks.json +2 -1
  54. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  55. package/vendor/hindsight-memory/scripts/session_start.py +35 -8
  56. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  57. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
@@ -34,9 +34,32 @@
34
34
  # - AGENT_DIR — output destination (if HANDOFF_BRIEFING_STDOUT!=1)
35
35
  #
36
36
  # Usage:
37
- # handoff-briefing.sh [--stdout]
37
+ # handoff-briefing.sh [--stdout] [--lean]
38
38
  #
39
39
  # The --stdout flag overrides HANDOFF_BRIEFING_STDOUT=1.
40
+ #
41
+ # LEAN MODE (--lean, alias --mode=compaction)
42
+ # -------------------------------------------
43
+ # The compaction re-seat path (bin/working-state-reload-hook.sh, wired as the
44
+ # SessionStart(compact) hook) invokes this script with --lean so BOTH legacy-
45
+ # and gateway-briefing agents share ONE assembler at the compaction boundary —
46
+ # no copy of the sqlite/recall logic lives in the hook. Lean mode differs from
47
+ # the boot briefing in three deliberate ways:
48
+ #
49
+ # 1. It emits ONLY the recent-Telegram-tail + Hindsight-recall sections.
50
+ # Daily-memory (Source 3) and the "You just restarted at …" header are
51
+ # SKIPPED. Rationale is token duplication, not latency: Claude Code's
52
+ # native compaction summary already preserves the recent turns, so
53
+ # re-injecting the full boot briefing on every compaction of a long
54
+ # session partially triples coverage.
55
+ # 2. It IGNORES the SWITCHROOM_PENDING_* env (boot-time pending-turn scope).
56
+ # Those name the surface that was mid-turn at the PREVIOUS boot; at a
57
+ # compaction hours into a live session they are stale and would brief the
58
+ # WRONG chat surface. Lean mode zeroes them so the python scoper derives
59
+ # the single most-recently-active (chat_id, thread_id) straight from the
60
+ # DB (scope_source=db-latest).
61
+ # 3. It forces stdout and never touches AGENT_DIR (no output file), so it is
62
+ # safe to call from the hook regardless of whether AGENT_DIR is exported.
40
63
 
41
64
  set -u
42
65
 
@@ -71,12 +94,32 @@ HINDSIGHT_TIMEOUT="${HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT:-3}"
71
94
  TARGET_CHAT_ID="${SWITCHROOM_PENDING_CHAT_ID:-}"
72
95
  TARGET_THREAD_ID="${SWITCHROOM_PENDING_THREAD_ID:-}"
73
96
 
74
- # Determine output mode
97
+ # Determine output + lean mode. Parse every arg (order-independent) so
98
+ # `--lean`, `--stdout`, or both, work regardless of position.
75
99
  STDOUT_MODE=0
76
- if [ "${HANDOFF_BRIEFING_STDOUT:-}" = "1" ] || [ "${1:-}" = "--stdout" ]; then
100
+ LEAN_MODE=0
101
+ for _arg in "$@"; do
102
+ case "$_arg" in
103
+ --stdout) STDOUT_MODE=1 ;;
104
+ --lean|--mode=compaction) LEAN_MODE=1 ;;
105
+ *) : ;;
106
+ esac
107
+ done
108
+ if [ "${HANDOFF_BRIEFING_STDOUT:-}" = "1" ]; then
77
109
  STDOUT_MODE=1
78
110
  fi
79
111
 
112
+ # Lean (compaction) mode: force stdout, and ZERO the pending-turn env scope so
113
+ # the python scoper falls through to db-latest (see LEAN MODE note in header).
114
+ # This is the load-bearing correctness fix for a mid-session compaction: the
115
+ # SWITCHROOM_PENDING_* surface is the previous boot's, not the currently-active
116
+ # chat. Clearing them here (not in the hook) keeps the single scoping code path.
117
+ if [ "$LEAN_MODE" = "1" ]; then
118
+ STDOUT_MODE=1
119
+ TARGET_CHAT_ID=""
120
+ TARGET_THREAD_ID=""
121
+ fi
122
+
80
123
  # ── Source 1: Recent Telegram messages ─────────────────────────────────────────
81
124
  TELEGRAM_SECTION=""
82
125
  if [ -n "$TELEGRAM_STATE" ] && [ -d "$TELEGRAM_STATE" ]; then
@@ -362,7 +405,9 @@ fi
362
405
  # dropping today's memory.
363
406
  DAILY_SECTION=""
364
407
  TODAY=$(TZ="$_TZ_VAL" date +%Y-%m-%d 2>/dev/null || date +%Y-%m-%d 2>/dev/null || true)
365
- if [ -n "$TODAY" ] && [ -n "$WORKSPACE_DIR" ]; then
408
+ # Lean/compaction mode SKIPS daily memory (token duplication the native
409
+ # summary already carries recent context; see LEAN MODE note in header).
410
+ if [ "$LEAN_MODE" != "1" ] && [ -n "$TODAY" ] && [ -n "$WORKSPACE_DIR" ]; then
366
411
  DAILY_FILE="$WORKSPACE_DIR/memory/${TODAY}.md"
367
412
  if [ -f "$DAILY_FILE" ] && [ -s "$DAILY_FILE" ]; then
368
413
  DAILY_CONTENT=$(cat "$DAILY_FILE")
@@ -391,7 +436,14 @@ if [ -n "$OUTPUT_FILE" ]; then
391
436
  else
392
437
  # stdout / no-AGENT_DIR mode — buffered; print the whole briefing once.
393
438
  if [ -n "$STDOUT_BUFFER" ]; then
394
- printf '%s\n\n---\n\n%s\n' "$BRIEFING_HEADER" "$STDOUT_BUFFER"
439
+ if [ "$LEAN_MODE" = "1" ]; then
440
+ # Lean/compaction: no "You just restarted at …" boot header — this is a
441
+ # mid-conversation compaction, not a restart, and the hook emits its own
442
+ # <compact-recovery> framing. Print only the assembled sections.
443
+ printf '%s\n' "$STDOUT_BUFFER"
444
+ else
445
+ printf '%s\n\n---\n\n%s\n' "$BRIEFING_HEADER" "$STDOUT_BUFFER"
446
+ fi
395
447
  fi
396
448
  fi
397
449
 
@@ -0,0 +1,262 @@
1
+ #!/bin/bash
2
+ # working-state-reload-hook.sh — deliver post-compaction continuation into
3
+ # context immediately after context compaction.
4
+ #
5
+ # WHY THIS EXISTS
6
+ # ---------------
7
+ # Claude Code auto-compacts late in the context window. The native
8
+ # auto-summarizer produces a structured summary of intent/changes/pending
9
+ # work, but it is lossy: fast-moving detail an agent is actively juggling
10
+ # mid-task (a checklist, the current plan, in-flight IDs, the exact "where
11
+ # was I" scratch, recent phrasing) is exactly what a summary flattens or
12
+ # drops. Worse, the model resuming from a summary can read it as a FRESH
13
+ # start and re-greet the user, breaking a conversation the user experiences
14
+ # as unbroken.
15
+ #
16
+ # This hook closes that gap deterministically, in three layers:
17
+ # 1. It ALWAYS emits a short, static recovery/orientation block — for
18
+ # EVERY agent, whether or not it maintains a working-state file. This
19
+ # is the load-bearing default: it tells the model its context was just
20
+ # compacted mid-conversation, that the native summary is lossy, and
21
+ # which concrete recovery tools exist in this environment.
22
+ # 2. If the agent maintains a working-state file AND it is non-empty, the
23
+ # hook additionally appends that file verbatim (with its last-modified
24
+ # time, so a stale/forgotten file is visibly stale rather than silently
25
+ # steering).
26
+ # 3. It emits a LEAN briefing (P1) — a scoped recent Telegram tail + a
27
+ # Hindsight recall — so the compacted session gets fresh-boot PARITY:
28
+ # it picks up the actual conversation, not just the fact it was
29
+ # compacted. This is delegated to handoff-briefing.sh --lean (the SINGLE
30
+ # briefing assembler; no copy of the sqlite/recall logic here), so BOTH
31
+ # legacy- and gateway-briefing agents share one compaction re-seat path.
32
+ # Lean by design: it skips daily-memory/workspace re-render (token
33
+ # duplication — the native summary already keeps recent turns) and
34
+ # ignores SWITCHROOM_PENDING_* to brief the db-latest chat surface rather
35
+ # than a stale pending-turn one. Graceful: if history.db or Hindsight is
36
+ # unavailable it emits what it can (or nothing) and never fails the hook.
37
+ #
38
+ # It is wired as a SessionStart hook with matcher "compact" (see
39
+ # src/agents/scaffold.ts buildSettingsHooksBlock). Per Claude Code's hook
40
+ # contract:
41
+ # - SessionStart fires with source="compact" on auto OR manual compaction,
42
+ # mid-turn, right after the compaction boundary.
43
+ # - Text a SessionStart hook prints to stdout IS added to the model's
44
+ # context (unlike PreCompact stdout, which is NOT injected).
45
+ # So printing here re-seats orientation (and any working state) into context
46
+ # the instant the summary replaces the transcript — no marker file, no
47
+ # gateway round-trip, no waiting for the next user message.
48
+ #
49
+ # The matcher "compact" is load-bearing: it scopes this hook to compaction
50
+ # ONLY. A bare (matcher-less) SessionStart also fires on "startup", "resume",
51
+ # "clear", and "fork", which would inject the recovery block on every boot —
52
+ # noise, and prompt-cache churn. We rely on the matcher AND, belt-and-braces,
53
+ # re-check the `source` field from stdin below so a future Claude Code matcher
54
+ # regression can never turn this into an every-boot inject.
55
+ #
56
+ # THE WORKING-STATE FILE CONVENTION
57
+ # ---------------------------------
58
+ # $TELEGRAM_STATE_DIR/.working-state.md
59
+ # i.e. <agentDir>/telegram/.working-state.md (TELEGRAM_STATE_DIR is exported
60
+ # by start.sh as "<agentDir>/telegram"). An agent maintains this file itself
61
+ # as its durable scratch of "what I'm mid-way through". If the file is absent
62
+ # or empty — the common case for agents that don't use it — the hook simply
63
+ # skips the append; the static recovery block is still emitted. No file is
64
+ # ever created here.
65
+ #
66
+ # PERFORMANCE
67
+ # -----------
68
+ # The static recovery block and working-state append are local-only (a heredoc
69
+ # plus at most one `stat`/`cat` of a small file) — sub-second. The lean
70
+ # briefing (layer 3) adds one local SQLite read and ONE network hop to
71
+ # Hindsight, which handoff-briefing.sh caps (HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT,
72
+ # default 3s). Worst-case runtime is therefore a few seconds, dominated by that
73
+ # cap; the SessionStart(compact) hook's Claude Code timeout is set accordingly
74
+ # in src/agents/scaffold.ts. Latency here is a non-issue by design: compaction
75
+ # itself takes far longer, and the prompt cache is already invalidated by the
76
+ # summary replacing the transcript. (Contrast the hindsight session_start.py
77
+ # SessionStart hook, which times out at its 5s budget on every firing — a
78
+ # separate, independent context-loss cause tracked against the hindsight-memory
79
+ # plugin, NOT fixed here.)
80
+ #
81
+ # Failure modes are all silent: a hook that errors would surface on the issues
82
+ # card via run-hook.sh, but a missing/absent working-state file, an unreadable
83
+ # mtime, a missing history.db, or an unreachable Hindsight is never an error —
84
+ # the recovery block still emits and the hook exits 0.
85
+
86
+ set -u
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Defensive source guard. The matcher "compact" in settings.json already
90
+ # scopes Claude Code to fire this hook only on compaction, but we re-verify
91
+ # the source from the hook's stdin JSON so a matcher regression (or a manual
92
+ # mis-wire) can never cause this to inject on a normal startup/resume/clear/
93
+ # fork boot. If stdin carries a `source` and it is not "compact", exit
94
+ # silently. If there is no stdin (e.g. a unit test invoking the script
95
+ # directly), fall through and trust the matcher.
96
+ # ---------------------------------------------------------------------------
97
+ if ! [ -t 0 ]; then
98
+ STDIN_JSON=$(cat 2>/dev/null || true)
99
+ if [ -n "${STDIN_JSON:-}" ]; then
100
+ SOURCE=""
101
+ if command -v jq >/dev/null 2>&1; then
102
+ SOURCE=$(printf '%s' "$STDIN_JSON" | jq -r '.source // empty' 2>/dev/null || true)
103
+ else
104
+ SOURCE=$(printf '%s' "$STDIN_JSON" \
105
+ | grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' \
106
+ | head -1 \
107
+ | sed 's/.*"source"[[:space:]]*:[[:space:]]*"//;s/"$//' 2>/dev/null || true)
108
+ fi
109
+ if [ -n "$SOURCE" ] && [ "$SOURCE" != "compact" ]; then
110
+ exit 0
111
+ fi
112
+ fi
113
+ fi
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Static recovery/orientation block. ALWAYS emitted on a compaction start,
117
+ # for EVERY agent — this is the load-bearing default. Plain stdout from a
118
+ # SessionStart hook IS added to the model's context by Claude Code, so this
119
+ # re-orients the model the instant the native summary replaces the transcript.
120
+ # Deterministic heredoc string: no network, no CLI fork.
121
+ # ---------------------------------------------------------------------------
122
+ cat <<'EOF'
123
+ <compact-recovery source="switchroom working-state-reload hook">
124
+ Your context was just COMPACTED mid-conversation. This is NOT a fresh start:
125
+ you are CONTINUING a conversation the user experiences as unbroken. The native
126
+ summary above is lossy — it flattens or drops fast-moving detail (in-flight
127
+ IDs, the exact "where was I", recent phrasing). Do not greet the user or act
128
+ as if starting over; pick up where the conversation left off.
129
+
130
+ Re-orient using the recovery tools in THIS environment before continuing:
131
+ - Telegram chat history: the get_recent_messages MCP tool
132
+ (mcp__switchroom-telegram__get_recent_messages) to re-read what was just
133
+ being discussed.
134
+ - Hindsight memory: recall / reflect (mcp__hindsight__recall,
135
+ mcp__hindsight__reflect) for facts and decisions from earlier sessions.
136
+ - Workspace files for durable task state.
137
+ </compact-recovery>
138
+ EOF
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Resolve the working-state file. Primary: $TELEGRAM_STATE_DIR (exported by
142
+ # start.sh for telegram-plugin agents). Fallback: derive the conventional
143
+ # telegram state dir from the agent name, so the hook still works if invoked
144
+ # in a context where TELEGRAM_STATE_DIR is not exported. If neither resolves,
145
+ # skip the working-state append — the recovery block above already emitted,
146
+ # and the lean briefing below still runs.
147
+ #
148
+ # NOTE: this is a GUARDED block (not an early `exit`), because the lean
149
+ # post-compaction briefing further down must run for EVERY compaction,
150
+ # including the common case of an agent that keeps no working-state file.
151
+ # ---------------------------------------------------------------------------
152
+ STATE_DIR="${TELEGRAM_STATE_DIR:-}"
153
+ if [ -z "$STATE_DIR" ]; then
154
+ AGENT_NAME="${SWITCHROOM_AGENT_NAME:-}"
155
+ if [ -n "$AGENT_NAME" ] && [ -n "${HOME:-}" ]; then
156
+ STATE_DIR="$HOME/.switchroom/agents/$AGENT_NAME/telegram"
157
+ fi
158
+ fi
159
+
160
+ WORKING_STATE_FILE=""
161
+ if [ -n "$STATE_DIR" ]; then
162
+ WORKING_STATE_FILE="$STATE_DIR/.working-state.md"
163
+ fi
164
+
165
+ # Append the working state only when the file resolves AND is non-empty.
166
+ if [ -n "$WORKING_STATE_FILE" ] && [ -s "$WORKING_STATE_FILE" ]; then
167
+ # -------------------------------------------------------------------------
168
+ # Resolve the working-state file's last-modified time so a stale, forgotten
169
+ # file is VISIBLY stale to the model rather than silently steering it. Try a
170
+ # portable sequence: GNU/busybox `stat -c %y`, then BSD/macOS `stat -f %Sm`,
171
+ # then GNU `date -r <file>`. If none work, omit the mtime — never fail the
172
+ # hook over it.
173
+ # -------------------------------------------------------------------------
174
+ MTIME=""
175
+ if MTIME=$(stat -c %y "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
176
+ :
177
+ elif MTIME=$(stat -f '%Sm' "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
178
+ :
179
+ elif MTIME=$(date -r "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
180
+ :
181
+ else
182
+ MTIME=""
183
+ fi
184
+
185
+ # -------------------------------------------------------------------------
186
+ # Append the working state verbatim, wrapped in its own delimiter block. The
187
+ # header line carries the mtime (when resolvable) so a stale file reads as
188
+ # stale.
189
+ # -------------------------------------------------------------------------
190
+ printf '%s\n' '<working-state source="switchroom working-state-reload hook">'
191
+ if [ -n "$MTIME" ]; then
192
+ printf '%s\n' 'The following is your working-state file ('"$WORKING_STATE_FILE"', last updated '"$MTIME"'),'
193
+ else
194
+ printf '%s\n' 'The following is your working-state file ('"$WORKING_STATE_FILE"'),'
195
+ fi
196
+ printf '%s\n' 'reloaded verbatim so in-flight task state survives the summarizer. It may'
197
+ printf '%s\n' 'be stale — reconcile it against the summary and the recovery tools above'
198
+ printf '%s\n' 'before trusting it, then continue.'
199
+ printf '%s\n' '---'
200
+ cat "$WORKING_STATE_FILE"
201
+ printf '\n%s\n' '</working-state>'
202
+ fi
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Lean post-compaction briefing (P1). ADDITIVE on source=compact, after the
206
+ # static recovery block and the optional working-state append. It re-seats the
207
+ # compacted session to fresh-boot PARITY: a scoped recent Telegram tail + a
208
+ # Hindsight recall, so the agent picks up the actual conversation rather than
209
+ # only being TOLD it was compacted.
210
+ #
211
+ # DRY: the assembly is delegated to handoff-briefing.sh --lean — the SINGLE
212
+ # briefing assembler. No copy of the sqlite/recall logic lives here. Lean mode
213
+ # emits ONLY the Telegram-tail + recall (daily-memory and the boot header are
214
+ # skipped — token duplication, the native summary already keeps recent turns),
215
+ # and IGNORES SWITCHROOM_PENDING_* to derive the db-latest surface (a stale
216
+ # pending-turn scope would brief the wrong chat at a mid-session compaction).
217
+ #
218
+ # SHARED PATH: this hook fires on SessionStart(compact) for EVERY agent,
219
+ # regardless of session_continuity.briefing mode (legacy vs gateway), so both
220
+ # modes get identical compaction re-seat through this one path.
221
+ #
222
+ # GRACEFUL: if the assembler script is not found, or emits nothing (no
223
+ # history.db, Hindsight unreachable), the block is simply omitted. The lean
224
+ # briefing NEVER fails the hook — the recovery block above already stands on
225
+ # its own. handoff-briefing.sh caps its only network hop (Hindsight) at a few
226
+ # seconds, so runtime is bounded.
227
+ # ---------------------------------------------------------------------------
228
+ BRIEFING_SCRIPT=""
229
+ _HOOK_DIR=$(dirname -- "$0" 2>/dev/null || true)
230
+ if [ -n "$_HOOK_DIR" ] && [ -r "$_HOOK_DIR/handoff-briefing.sh" ]; then
231
+ BRIEFING_SCRIPT="$_HOOK_DIR/handoff-briefing.sh"
232
+ elif command -v handoff-briefing.sh >/dev/null 2>&1; then
233
+ BRIEFING_SCRIPT="handoff-briefing.sh"
234
+ fi
235
+
236
+ if [ -n "$BRIEFING_SCRIPT" ]; then
237
+ # Inner timeout, SHORTER than the 8s Claude Code hook budget, so a slow
238
+ # assembler degrades to "recovery block only" instead of losing everything.
239
+ # Without it, if the assembler runs long (e.g. an operator raises
240
+ # HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT past the hook budget) Claude Code kills
241
+ # the WHOLE hook at 8s and discards ALL stdout — including the near-unkillable
242
+ # <compact-recovery> orientation block already printed above. Capping the
243
+ # assembler at 5s keeps the #4390 recovery floor intact. `timeout` is
244
+ # coreutils (present in the agent image); fall back to an un-timed call if it
245
+ # is somehow unavailable, so the lean briefing still works.
246
+ if command -v timeout >/dev/null 2>&1; then
247
+ LEAN_BRIEFING=$(timeout 5 bash "$BRIEFING_SCRIPT" --lean 2>/dev/null || true)
248
+ else
249
+ LEAN_BRIEFING=$(bash "$BRIEFING_SCRIPT" --lean 2>/dev/null || true)
250
+ fi
251
+ if [ -n "$LEAN_BRIEFING" ]; then
252
+ printf '%s\n' '<compact-briefing source="switchroom working-state-reload hook">'
253
+ printf '%s\n' 'The recent conversation and recalled memory below are re-seated so this'
254
+ printf '%s\n' 'compacted session has the same footing as a fresh boot. Use them to pick up'
255
+ printf '%s\n' 'the thread; reconcile against the native summary above before trusting either.'
256
+ printf '%s\n' '---'
257
+ printf '%s\n' "$LEAN_BRIEFING"
258
+ printf '%s\n' '</compact-briefing>'
259
+ fi
260
+ fi
261
+
262
+ exit 0
@@ -11666,8 +11666,10 @@ var AgentSchema = exports_external.object({
11666
11666
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
11667
11667
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("Per-agent IANA timezone override. Wins over any profile/defaults " + "value and over the top-level switchroom.timezone global. Controls " + "the UserPromptSubmit timezone hook's emitted local time and the " + "agent container's `environment.TZ` in compose."),
11668
11668
  auth: exports_external.object({
11669
- override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5.")
11670
- }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
11669
+ override: exports_external.string().min(1).optional().describe("Per-agent override of the fleet-wide `auth.active`. Edge-case use only — " + "this agent talks to the named account regardless of fleet active. See RFC H §4.5."),
11670
+ strict: exports_external.boolean().optional().describe("Requires `override`. When true, the pin is a hard binding, not a " + "routing preference: the broker NEVER serves this agent from any " + "other account — no failover to `auth.fallback_order` while the " + "pinned account is quota-walled or exhausted. The agent rides out " + "the wall on its own account (surfacing the normal 429/quota " + "cards) instead of silently borrowing fleet quota. Use for " + "accounts that must never cross a billing/compliance boundary " + "(e.g. an employer-provided subscription)."),
11671
+ exclusive: exports_external.boolean().optional().describe("Requires `override`. When true, the pinned account belongs to " + "THIS agent alone: the broker refuses to serve it to any other " + "agent or consumer, refuses `auth use <label>` (set-active) onto " + "it, and refuses pinning another agent to it. Config that routes " + "others to the account (`auth.active`, `auth.fallback_order`, " + "another agent's `override`, a consumer pin) is rejected at load. " + "Usually paired with `strict: true` for full two-way isolation.")
11672
+ }).optional().describe("Account routing for switchroom-auth-broker. RFC H schema uses " + "fleet-wide `auth.active` plus per-agent `override:` for edge cases, " + "with optional `strict:` (never borrow another account) and " + "`exclusive:` (no one else may use the pinned account) hardening. " + "Pre-RFC-H `auth.accounts: [..]` and `auth_label:` are migrated in-place " + "on first apply (see src/auth/migrate-schema.ts)."),
11671
11673
  dm_only: exports_external.boolean().optional().describe("Mark this agent as a DM-only bot — has its own bot_token and lives " + "exclusively in a private chat with the operator. Suppresses " + "scaffolding's default behavior of inheriting the global " + "telegram.forum_chat_id into the agent's access.json `groups` entry " + "(the forum chat the bot isn't a member of, which would otherwise " + "trigger a 'boot-probe-failed: 400 chat not found' warning every " + "restart). topic_name is still schema-required but unused — set it " + "to a display label like 'DM' for /switchroom status output."),
11672
11674
  topic_name: exports_external.string().describe("Telegram forum topic display name"),
11673
11675
  topic_emoji: exports_external.string().optional().describe("Emoji for the topic (e.g., '\uD83C\uDFCB️')"),
@@ -11742,6 +11744,17 @@ var AgentSchema = exports_external.object({
11742
11744
  tmp_size: exports_external.string().regex(/^\d+(\.\d+)?[kmgKMG]?$/, "tmp_size must be a Docker size string like '1g', '4g', '512m'").refine((v) => parseFloat(v) > 0, "tmp_size must be greater than zero").optional()
11743
11745
  }).optional()
11744
11746
  }).superRefine((agent, ctx) => {
11747
+ if (agent.auth && !agent.auth.override) {
11748
+ for (const flag of ["strict", "exclusive"]) {
11749
+ if (agent.auth[flag]) {
11750
+ ctx.addIssue({
11751
+ code: exports_external.ZodIssueCode.custom,
11752
+ message: `\`auth.${flag}: true\` requires \`auth.override: <account>\` — there is no pinned account for it to apply to.`,
11753
+ path: ["auth", flag]
11754
+ });
11755
+ }
11756
+ }
11757
+ }
11745
11758
  if (agent.dm_only !== true)
11746
11759
  return;
11747
11760
  const tg = agent.channels?.telegram;
@@ -11854,6 +11867,13 @@ var UserSchema = exports_external.object({
11854
11867
  profile_bank: exports_external.string().describe("Hindsight bank holding this user's memory profile (author via " + "`switchroom memory profile add <bank> ...`)."),
11855
11868
  person_id: exports_external.string().optional().describe("Free-text display name projected into the inbound `<channel>` " + 'tag\'s `user` attribute (e.g. "Lisa") so an agent can greet by ' + "name instead of only seeing the raw Telegram id/username. NOT a " + "stable identity system — just a label. Resolution is boot-time-only " + "(no hot-reload; a config change needs an agent restart) and " + "chat-scoped (a resolved name is only shown in a chat/group the " + "person is actually a member of, per that chat's access.json " + "membership — never broadcast into every chat the agent operates " + "in). Keep this broadly safe to show, the same discipline as " + "picking a `profile_bank` name: there is no automated enforcement " + "that a `person_id` stays safe if a group's membership changes " + "later — see docs/configuration.md.")
11856
11869
  });
11870
+ var ConfigRepoConfigSchema = exports_external.object({
11871
+ enabled: exports_external.boolean().default(false).describe("Master switch for config-repo change control. Default false (opt-in). " + "When false the `config-repo sync` verb still runs on demand but the " + "doctor check and (later) the scheduled tick treat the feature as off."),
11872
+ path: exports_external.string().regex(/^[a-zA-Z0-9~._\-/]+$/, "config_repo.path must not contain shell-special characters ($, `, \", ', \\, etc.)").default("~/.switchroom-config").describe("Filesystem path to the operator's private config repo. Must be a git " + "repo (else `switchroom doctor` FAILs). Tilde-expanded at read time."),
11873
+ push: exports_external.boolean().default(true).describe("When true, `config-repo sync` pushes after committing (subject to the " + "`require_private` gate). When false, it commits locally only — useful " + "for offline hosts or a review-before-push workflow."),
11874
+ remote: exports_external.string().min(1).default("origin").describe("Git remote name to push to. Default `origin`."),
11875
+ require_private: exports_external.boolean().default(true).describe("Refuse to PUSH unless the GitHub API confirms the remote repo is " + "private. On a public remote — or when the API is unreachable — the " + "push is skipped (commits still land locally) and a WARN is emitted. " + "Fail-safe against exfiltrating memory files / workspace state to a " + "repo that has been flipped public. Default true.")
11876
+ });
11857
11877
  var SwitchroomConfigSchema = exports_external.object({
11858
11878
  switchroom: exports_external.object({
11859
11879
  version: exports_external.literal(1).describe("Config schema version"),
@@ -11866,6 +11886,7 @@ var SwitchroomConfigSchema = exports_external.object({
11866
11886
  memory: MemoryBackendConfigSchema.optional(),
11867
11887
  hindsight: HindsightConfigSchema.optional().describe("Fleet-singleton Hindsight (memory backend) configuration. Currently " + "just the LLM knob (provider + model) used for retain/reflect/" + "consolidation. Both fields optional; when unset the container falls " + "back to provider=claude-code + the hard-coded HINDSIGHT_DEFAULT_MODEL " + "so nothing changes for operators who don't set it. Read at container " + "launch by startHindsight() (src/setup/hindsight.ts) — takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
11868
11888
  vault: VaultConfigSchema.optional(),
11889
+ config_repo: ConfigRepoConfigSchema.optional().describe("Change control for the operator's private ~/.switchroom-config git repo " + "(backup + audit trail of live host config, agent workspace state, and " + "mirrored personal skills). Consumed by `switchroom config-repo sync` and " + "the `config repo` doctor check. Optional and OFF by default."),
11869
11890
  auth: exports_external.object({
11870
11891
  active: exports_external.string().min(1).optional().describe("Fleet-wide active Anthropic account label. Every agent without " + "an explicit `agent.auth.override` uses this account. See " + "docs/auth.md for the full model. Set by `switchroom auth use <label>`."),
11871
11892
  fallback_order: exports_external.array(exports_external.string().min(1)).optional().describe("Ordered list of account labels for `switchroom auth rotate` to cycle " + "through when the active account hits a quota event. First entry is " + "normally the same as `auth.active`. When unset, `rotate` is a no-op."),
@@ -11933,6 +11954,48 @@ var SwitchroomConfigSchema = exports_external.object({
11933
11954
  for (const [name, a] of Object.entries(cfg.agents ?? {})) {
11934
11955
  checkServes(a.serves, ["agents", name, "serves"]);
11935
11956
  }
11957
+ for (const [name, a] of Object.entries(cfg.agents ?? {})) {
11958
+ const agentAuth = a.auth;
11959
+ if (!agentAuth?.exclusive || !agentAuth.override)
11960
+ continue;
11961
+ const acct = agentAuth.override;
11962
+ if (cfg.auth?.active === acct) {
11963
+ ctx.addIssue({
11964
+ code: exports_external.ZodIssueCode.custom,
11965
+ message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — it cannot be the fleet \`auth.active\``,
11966
+ path: ["auth", "active"]
11967
+ });
11968
+ }
11969
+ const fbIdx = (cfg.auth?.fallback_order ?? []).indexOf(acct);
11970
+ if (fbIdx !== -1) {
11971
+ ctx.addIssue({
11972
+ code: exports_external.ZodIssueCode.custom,
11973
+ message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — it cannot appear in \`auth.fallback_order\``,
11974
+ path: ["auth", "fallback_order", fbIdx]
11975
+ });
11976
+ }
11977
+ for (const [other, oa] of Object.entries(cfg.agents ?? {})) {
11978
+ if (other === name)
11979
+ continue;
11980
+ const otherOverride = oa.auth?.override;
11981
+ if (otherOverride === acct) {
11982
+ ctx.addIssue({
11983
+ code: exports_external.ZodIssueCode.custom,
11984
+ message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — agent '${other}' cannot pin it`,
11985
+ path: ["agents", other, "auth", "override"]
11986
+ });
11987
+ }
11988
+ }
11989
+ (cfg.auth?.consumers ?? []).forEach((c, i) => {
11990
+ if (c.account === acct) {
11991
+ ctx.addIssue({
11992
+ code: exports_external.ZodIssueCode.custom,
11993
+ message: `account '${acct}' is exclusive to agent '${name}' ` + `(agents.${name}.auth.exclusive) — consumer '${c.name}' cannot pin it`,
11994
+ path: ["auth", "consumers", i, "account"]
11995
+ });
11996
+ }
11997
+ });
11998
+ }
11936
11999
  const microsoftAccounts = cfg.microsoft_accounts;
11937
12000
  for (const [name, a] of Object.entries(cfg.agents ?? {})) {
11938
12001
  const mw = a.microsoft_workspace;