switchroom 0.20.10 → 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 (52) hide show
  1. package/dist/agent-scheduler/index.js +65 -2
  2. package/dist/auth-broker/index.js +185 -23
  3. package/dist/cli/notion-write-pretool.mjs +65 -2
  4. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  5. package/dist/cli/self-improve-stop.mjs +889 -7
  6. package/dist/cli/skill-validate-pretool.mjs +82 -3
  7. package/dist/cli/switchroom.js +3669 -2107
  8. package/dist/host-control/main.js +67 -4
  9. package/dist/vault/approvals/kernel-server.js +66 -3
  10. package/dist/vault/broker/server.js +66 -3
  11. package/package.json +1 -1
  12. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  13. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  14. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  15. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  16. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  17. package/profiles/default/CLAUDE.md.hbs +3 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  19. package/telegram-plugin/dist/gateway/gateway.js +1405 -918
  20. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  21. package/telegram-plugin/gateway/auth-command.ts +11 -1
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  23. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  24. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  25. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  26. package/telegram-plugin/gateway/gateway.ts +85 -90
  27. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  28. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  29. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  30. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  31. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  32. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  33. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  34. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  35. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  36. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  37. package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
  38. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  39. package/telegram-plugin/slot-banner-driver.ts +42 -5
  40. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  41. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  42. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  43. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  44. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  46. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  47. package/telegram-plugin/throttle-tier.ts +12 -3
  48. package/telegram-plugin/turn-flush-safety.ts +97 -0
  49. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  50. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  51. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
@@ -22,9 +22,21 @@ import json
22
22
  import os
23
23
  import sys
24
24
  import time
25
+ from datetime import datetime, timezone
25
26
 
26
27
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
28
 
29
+ # Switchroom self-improve PR4 (slice 4a) — per-turn correction tagging.
30
+ # The self-improve Stop hook (a SEPARATE process — src/cli/self-improve-stop.ts)
31
+ # drops SELF_IMPROVE_CORRECTION_PENDING_FILE into the shared agent state dir when
32
+ # the deterministic gate fires on an operator-correction. This hook reads-and-
33
+ # clears it and stamps SELF_IMPROVE_CORRECTION_TAG on that turn's retain so PR5's
34
+ # failure-synthesis cron can recall correction turns cheaply. Both constants are
35
+ # pinned to their TS source (src/memory/hindsight-retain-provenance.ts) by
36
+ # tests/scaffold.retain-provenance.test.ts — they must not drift.
37
+ SELF_IMPROVE_CORRECTION_PENDING_FILE = "self-improve-correction-pending"
38
+ SELF_IMPROVE_CORRECTION_TAG = "self-improve:correction"
39
+
28
40
  from lib import watermark
29
41
  from lib.bank import derive_bank_id, ensure_bank_mission
30
42
  from lib.client import HindsightClient
@@ -38,6 +50,242 @@ from lib.pacing import inflight_lock
38
50
  from lib.state import increment_turn_count, track_retention
39
51
 
40
52
 
53
+ def _self_improve_state_dir() -> str:
54
+ """State dir the self-improve Stop hook drops its correction sentinel into.
55
+
56
+ Mirrors ``resolveStateDir()`` in ``src/cli/self-improve-stop.ts``:
57
+ ``TELEGRAM_STATE_DIR`` when set, else ``~/.claude/channels/telegram``. Both
58
+ hooks run in the same agent process env, so they agree on this path.
59
+ """
60
+ env = os.environ.get("TELEGRAM_STATE_DIR")
61
+ if env:
62
+ return env
63
+ return os.path.join(os.path.expanduser("~"), ".claude", "channels", "telegram")
64
+
65
+
66
+ def read_and_clear_correction_pending(state_dir: str | None = None) -> list:
67
+ """Read-and-clear the per-turn operator-correction sentinel (PR4 slice 4a).
68
+
69
+ Returns ``[SELF_IMPROVE_CORRECTION_TAG]`` when the sentinel is present (and
70
+ REMOVES it, so exactly one retain carries the tag — read-once), else ``[]``.
71
+ Called from ``run_retain`` only (the live Stop-hook entry), NOT from the
72
+ network-free ``build_retain_payload`` seam, so backfill / reconcile /
73
+ subagent retains never consume a live turn's marker. Best-effort and NEVER
74
+ raises — a triage marker must not be able to fail a retain.
75
+ """
76
+ try:
77
+ d = state_dir if state_dir is not None else _self_improve_state_dir()
78
+ path = os.path.join(d, SELF_IMPROVE_CORRECTION_PENDING_FILE)
79
+ if not os.path.exists(path):
80
+ return []
81
+ try:
82
+ os.remove(path)
83
+ except OSError:
84
+ pass
85
+ return [SELF_IMPROVE_CORRECTION_TAG]
86
+ except Exception:
87
+ return []
88
+
89
+
90
+ PRIVACY_STATE_FILE = "privacy-state.json"
91
+
92
+
93
+ def _parse_iso(value) -> "datetime | None":
94
+ """Best-effort parse of an ISO-8601 timestamp to an aware ``datetime``.
95
+
96
+ Accepts the ``...Z`` (UTC) suffix the gateway writes and the offset forms
97
+ ``datetime.fromisoformat`` understands. Returns ``None`` on anything
98
+ unparseable — never raises. A naive result (no tz) is coerced to UTC so all
99
+ comparisons in ``exclude_private_ranges`` are between aware datetimes.
100
+ """
101
+ if not isinstance(value, str) or not value:
102
+ return None
103
+ try:
104
+ s = value.strip()
105
+ if s.endswith("Z"):
106
+ s = s[:-1] + "+00:00"
107
+ dt = datetime.fromisoformat(s)
108
+ if dt.tzinfo is None:
109
+ dt = dt.replace(tzinfo=timezone.utc)
110
+ return dt
111
+ except (ValueError, TypeError):
112
+ return None
113
+
114
+
115
+ PRIVACY_STATE_CORRUPT_KEY = "__corrupt__"
116
+
117
+
118
+ def read_privacy_state(state_dir: str | None = None) -> list:
119
+ """Read the shared /private-mode interval list (switchroom privacy PR1).
120
+
121
+ Contract with the Telegram gateway's ``/private`` / ``/public`` commands:
122
+ the gateway maintains ``${TELEGRAM_STATE_DIR}/privacy-state.json`` (same dir
123
+ fallback as ``_self_improve_state_dir``) shaped::
124
+
125
+ {"version": 1, "intervals": [
126
+ {"start": "<iso>", "end": "<iso>"}, # a closed private window
127
+ {"start": "<iso>", "end": null} # the OPEN window = private NOW
128
+ ]}
129
+
130
+ Returns the ``intervals`` list (list of ``{"start", "end"}`` dicts).
131
+
132
+ ABSENT vs CORRUPT are deliberately DISTINCT (review MAJOR 1 —
133
+ defense-in-depth, independent of the gateway's write atomicity):
134
+
135
+ - A **missing** file, or a valid JSON object with no ``intervals`` key,
136
+ yields ``[]`` — the default = fully public.
137
+ - A file that **exists but cannot be parsed** (truncated mid-rewrite,
138
+ not-a-JSON-object, ``intervals`` not a list) must NOT collapse to the same
139
+ "public" ``[]`` — that would leak the just-completed private turn if a Stop
140
+ hook fired during a non-atomic rewrite. It returns a single CORRUPT
141
+ sentinel interval and logs loudly; downstream this fails TOWARD privacy
142
+ (``_has_open_interval`` → True, ``exclude_private_ranges`` → drop all).
143
+
144
+ Best-effort — NEVER raises: a privacy-state read must not be able to fail a
145
+ retain.
146
+ """
147
+ try:
148
+ d = state_dir if state_dir is not None else _self_improve_state_dir()
149
+ path = os.path.join(d, PRIVACY_STATE_FILE)
150
+ if not os.path.isfile(path):
151
+ return [] # absent = public (the default)
152
+ except Exception:
153
+ # Could not even resolve/stat the path — treat as absent (public).
154
+ return []
155
+ # The file EXISTS. From here a parse/read failure fails TOWARD privacy.
156
+ try:
157
+ with open(path, encoding="utf-8") as f:
158
+ data = json.load(f)
159
+ if not isinstance(data, dict):
160
+ raise ValueError("privacy-state.json is not a JSON object")
161
+ intervals = data.get("intervals")
162
+ if intervals is None:
163
+ return [] # valid JSON, no intervals declared = public
164
+ if not isinstance(intervals, list):
165
+ raise ValueError("privacy-state.json .intervals is not a list")
166
+ return [iv for iv in intervals if isinstance(iv, dict)]
167
+ except Exception as e:
168
+ print(
169
+ f"[Hindsight] privacy-state.json present but unreadable ({e!r}); "
170
+ f"failing TOWARD privacy — treating this session as private-now",
171
+ file=sys.stderr,
172
+ )
173
+ return [{PRIVACY_STATE_CORRUPT_KEY: True}]
174
+
175
+
176
+ def _classify_privacy_intervals(intervals: list) -> tuple:
177
+ """Parse raw intervals into ``(parsed, fail_private)`` — the single source of
178
+ truth both ``_has_open_interval`` and ``exclude_private_ranges`` use so they
179
+ can NEVER disagree on what "open" means (review MINOR).
180
+
181
+ - ``parsed``: list of ``(start_dt, end_dt_or_None)`` aware-datetime tuples;
182
+ ``end_dt is None`` means an OPEN ``[start, ∞)`` window.
183
+ - ``fail_private``: True when the state must be treated as private-now,
184
+ unbounded — a CORRUPT sentinel, or an interval whose ``start`` is present
185
+ but unparseable (we cannot place the window, so we refuse to un-redact).
186
+
187
+ A malformed (present-but-unparseable) ``end`` degrades to OPEN ``[start, ∞)``
188
+ in BOTH consumers — and is LOGGED, not silently applied. Best-effort; never
189
+ raises.
190
+ """
191
+ parsed = []
192
+ fail_private = False
193
+ for iv in intervals:
194
+ if not isinstance(iv, dict):
195
+ continue
196
+ if iv.get(PRIVACY_STATE_CORRUPT_KEY):
197
+ fail_private = True
198
+ continue
199
+ start_raw = iv.get("start")
200
+ start_dt = _parse_iso(start_raw)
201
+ if start_dt is None:
202
+ if start_raw is not None:
203
+ # Present but unparseable start: cannot place the window — fail
204
+ # toward privacy rather than silently ignoring a private range.
205
+ print(
206
+ f"[Hindsight] privacy interval has unparseable start "
207
+ f"{start_raw!r}; failing TOWARD privacy (private-now)",
208
+ file=sys.stderr,
209
+ )
210
+ fail_private = True
211
+ continue # start missing entirely = an empty entry, covers nothing
212
+ end_raw = iv.get("end")
213
+ if end_raw is None:
214
+ parsed.append((start_dt, None))
215
+ continue
216
+ end_dt = _parse_iso(end_raw)
217
+ if end_dt is None:
218
+ # Present but unparseable end: degrade to OPEN [start, ∞) AND surface
219
+ # it, so the guard and the redactor agree (no silent unbounded wipe).
220
+ print(
221
+ f"[Hindsight] privacy interval has unparseable end {end_raw!r}; "
222
+ f"treating as OPEN [start, ∞) — check the gateway writer",
223
+ file=sys.stderr,
224
+ )
225
+ parsed.append((start_dt, None))
226
+ continue
227
+ parsed.append((start_dt, end_dt))
228
+ return parsed, fail_private
229
+
230
+
231
+ def _has_open_interval(intervals: list) -> bool:
232
+ """True when the state is private-right-now: some OPEN ``[start, ∞)`` window,
233
+ OR a corrupt/unplaceable state that fails toward privacy. Shares the
234
+ classifier with ``exclude_private_ranges`` so the two never diverge."""
235
+ parsed, fail_private = _classify_privacy_intervals(intervals)
236
+ if fail_private:
237
+ return True
238
+ return any(end is None for _, end in parsed)
239
+
240
+
241
+ def exclude_private_ranges(messages: list, intervals: list) -> list:
242
+ """Drop any message whose ``timestamp`` falls inside a private interval.
243
+
244
+ An interval ``{"start": s, "end": e}`` covers ``[s, e]`` (inclusive, toward
245
+ privacy); an OPEN interval (``end`` is null, or a malformed ``end``) covers
246
+ ``[s, ∞)``. A message whose ``timestamp`` is missing or unparseable is
247
+ dropped ONLY when some interval is open (conservative — we cannot place it,
248
+ and private-now is live), otherwise kept.
249
+
250
+ A CORRUPT state or an unplaceable ``start`` (``fail_private``) drops ALL
251
+ messages — failing toward privacy rather than leaking on a bad file.
252
+ Best-effort and total: the function never raises.
253
+ """
254
+ if not intervals:
255
+ return list(messages)
256
+
257
+ parsed, fail_private = _classify_privacy_intervals(intervals)
258
+ if fail_private:
259
+ # Corrupt / unplaceable state: refuse to retain anything this pass.
260
+ return []
261
+ if not parsed:
262
+ return list(messages)
263
+
264
+ any_open = any(end is None for _, end in parsed)
265
+
266
+ def _in_any_range(ts_dt) -> bool:
267
+ for start_dt, end_dt in parsed:
268
+ if ts_dt < start_dt:
269
+ continue
270
+ if end_dt is None or ts_dt <= end_dt:
271
+ return True
272
+ return False
273
+
274
+ kept = []
275
+ for m in messages:
276
+ ts_dt = _parse_iso(m.get("timestamp")) if isinstance(m, dict) else None
277
+ if ts_dt is None:
278
+ # No placeable timestamp: drop only if a private window is open now.
279
+ if any_open:
280
+ continue
281
+ kept.append(m)
282
+ continue
283
+ if _in_any_range(ts_dt):
284
+ continue
285
+ kept.append(m)
286
+ return kept
287
+
288
+
41
289
  def read_transcript(transcript_path: str, max_bytes: int | None = None) -> list:
42
290
  """Read a JSONL transcript file and return list of message dicts.
43
291
 
@@ -92,6 +340,15 @@ def read_transcript(transcript_path: str, max_bytes: int | None = None) -> list:
92
340
  if uid is not None and "uuid" not in msg:
93
341
  msg = dict(msg)
94
342
  msg["uuid"] = uid
343
+ # Surface the transcript-entry timestamp the same
344
+ # way (nested format carries it on the OUTER entry).
345
+ # The /private-mode redaction filters on it
346
+ # (exclude_private_ranges); without surfacing it here
347
+ # every message would look timestamp-less.
348
+ ts = entry.get("timestamp")
349
+ if ts is not None and "timestamp" not in msg:
350
+ msg = dict(msg)
351
+ msg["timestamp"] = ts
95
352
  messages.append(msg)
96
353
  # Flat format (testing / future compatibility)
97
354
  elif "role" in entry and "content" in entry:
@@ -254,6 +511,7 @@ def build_retain_payload(
254
511
  api_token,
255
512
  retain_full_window: bool = True,
256
513
  document_id=None,
514
+ extra_tags=None,
257
515
  ) -> dict | None:
258
516
  """Pure transcript → retain payload + deterministic id (switchroom #3244 §1.4).
259
517
 
@@ -323,6 +581,23 @@ def build_retain_payload(
323
581
  merged.append(lt)
324
582
  tags = merged
325
583
 
584
+ # Switchroom self-improve PR4 (slice 4a) — per-turn correction tag. The
585
+ # caller (``run_retain``) reads-and-clears the operator-correction sentinel
586
+ # and threads the resulting tag(s) in here; this seam stays network-free and
587
+ # state-free (no sentinel IO), so it composes with lesson tags and rides the
588
+ # SAME payload the pending-retains queue persists. ``self-improve:correction``
589
+ # is STABLE by contract (it must NOT match DEFAULT_VOLATILE_SCOPE_PATTERNS),
590
+ # so on a correction turn the retain lands in its own
591
+ # ``[["self-improve:correction"]]`` consolidation scope — exactly the
592
+ # partition PR5's failure-synthesis cron works over. Absent ⇒ tag absent ⇒
593
+ # scope stays byte-identical to a normal turn's ``"shared"``.
594
+ if extra_tags:
595
+ merged = list(tags) if tags else []
596
+ for et in extra_tags:
597
+ if isinstance(et, str) and et and et not in merged:
598
+ merged.append(et)
599
+ tags = merged or None
600
+
326
601
  metadata = {
327
602
  "retained_at": template_vars["timestamp"],
328
603
  "message_count": str(message_count),
@@ -438,6 +713,17 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
438
713
  ``lib/pending.py``). ``status="skipped"`` is the normal early-exit
439
714
  cases (auto-retain disabled, empty transcript, throttled chunk).
440
715
  """
716
+ # /private-mode enforcement (switchroom privacy PR1) — FIRST, before
717
+ # load_config(). An OPEN private interval means the operator is discussing
718
+ # confidential material right now, so a normal (non-forced) auto-retain must
719
+ # not fire at all. Placed above load_config() deliberately so a
720
+ # HINDSIGHT_AUTO_RETAIN=true env pin (the autoRetain gate below) cannot
721
+ # override the privacy guarantee. A FORCED SessionEnd sweep is EXEMPT here on
722
+ # purpose — it must still flush the PUBLIC portion of the session — and
723
+ # relies instead on exclude_private_ranges() dropping the private turns.
724
+ if not force and _has_open_interval(read_privacy_state()):
725
+ return {"status": "skipped", "reason": "private-mode"}
726
+
441
727
  config = load_config()
442
728
 
443
729
  if not config.get("autoRetain"):
@@ -472,6 +758,17 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
472
758
  debug_log(config, "No messages in transcript, skipping retain")
473
759
  return {"status": "skipped", "reason": "empty transcript"}
474
760
 
761
+ # /private-mode redaction (switchroom privacy PR1) — drop any message that
762
+ # falls inside a private interval BEFORE window selection. This single
763
+ # insertion covers BOTH the chunked sliding window AND the force/full-session
764
+ # slice (select_retain_window returns list(all_messages) for force=True), so
765
+ # a forced SessionEnd sweep flushes only the PUBLIC portion and an
766
+ # open-at-session-end private range is never force-swept into the bank.
767
+ all_messages = exclude_private_ranges(all_messages, read_privacy_state())
768
+ if not all_messages:
769
+ debug_log(config, "All messages fell inside private ranges, skipping retain")
770
+ return {"status": "skipped", "reason": "private-mode"}
771
+
475
772
  debug_log(config, f"Read {len(all_messages)} messages from transcript")
476
773
 
477
774
  # Retention mode: full session (vendor default) or chunked. Switchroom
@@ -564,6 +861,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
564
861
  # chunk 0 → plain session_id (backwards compatible with existing docs)
565
862
  document_id = session_id if chunk_index == 0 else f"{session_id}-c{chunk_index}"
566
863
 
864
+ # Switchroom self-improve PR4 (slice 4a) — read-and-clear the per-turn
865
+ # operator-correction sentinel here, in the live Stop path only, AFTER the
866
+ # throttle / re-fire / empty-transcript early-returns above so only a retain
867
+ # that actually FIRES consumes the marker (a throttled turn leaves it for the
868
+ # next firing retain, whose overlapping window still contains the correction).
869
+ # Read-once: the tag rides exactly one retain. Best-effort; never raises.
870
+ extra_tags = read_and_clear_correction_pending()
871
+
567
872
  # Build the payload via the shared, network-free seam (§1.4). This carries
568
873
  # the deterministic id, connection info, formatted transcript, tags and
569
874
  # metadata — sufficient to retry from a different process (drain_pending).
@@ -577,6 +882,7 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
577
882
  api_token=api_token,
578
883
  retain_full_window=retain_full_window,
579
884
  document_id=document_id,
885
+ extra_tags=extra_tags,
580
886
  )
581
887
  if built is None:
582
888
  debug_log(config, "Empty transcript after formatting, skipping retain")
@@ -73,7 +73,13 @@ from lib.pacing import inflight_lock
73
73
  # retain.py owns the transcript reader, the deterministic-id recipe and the
74
74
  # network-free payload builder; reuse them wholesale so the sidechain path
75
75
  # stays byte-identical to the main path where it matters (dedup ids, formatting).
76
- from retain import build_retain_payload, read_transcript
76
+ from retain import (
77
+ _has_open_interval,
78
+ build_retain_payload,
79
+ exclude_private_ranges,
80
+ read_privacy_state,
81
+ read_transcript,
82
+ )
77
83
 
78
84
  # Retain the last N human turns of the sidechain. The window is formatted on the
79
85
  # TEXT-ONLY path (``retainToolCalls`` is forced False for the sidechain — see
@@ -333,6 +339,15 @@ def run_subagent_retain(hook_input: dict) -> dict:
333
339
  "payload": {...}, # only when status == "failed" (for enqueue)
334
340
  "error": Exception} # only when status == "failed"
335
341
  """
342
+ # /private-mode enforcement (switchroom privacy PR1) — FIRST, before
343
+ # load_config(). Subagents have no toggle of their own; they honor the
344
+ # parent session's privacy state file (same env, same path). An OPEN private
345
+ # interval means confidential material is being discussed right now, so the
346
+ # sidechain retain must not fire — placed above load_config() so a
347
+ # HINDSIGHT_AUTO_RETAIN=true env pin cannot override the privacy guarantee.
348
+ if _has_open_interval(read_privacy_state()):
349
+ return {"status": "skipped", "reason": "private-mode"}
350
+
336
351
  config = load_config()
337
352
 
338
353
  if not config.get("autoRetain"):
@@ -379,6 +394,19 @@ def run_subagent_retain(hook_input: dict) -> dict:
379
394
  debug_log(config, f"SubagentStop: empty sidechain transcript {transcript_path}")
380
395
  return {"status": "skipped", "reason": "empty transcript"}
381
396
 
397
+ # /private-mode redaction (review MAJOR 2) — the open-interval early-skip
398
+ # above only catches a range that is STILL open at SubagentStop. But a
399
+ # backgrounded sub-agent can be dispatched under /private, carry that
400
+ # confidential context, and finish AFTER /public closed the interval — no
401
+ # interval open here, yet its window still holds private-window material.
402
+ # Apply the same range redaction the parent Stop path uses so a now-CLOSED
403
+ # private range is excluded from the sidechain retain too. Runs BEFORE the
404
+ # volume gate so the gate measures the redacted content.
405
+ all_messages = exclude_private_ranges(all_messages, read_privacy_state())
406
+ if not all_messages:
407
+ debug_log(config, "SubagentStop: all sidechain messages fell inside private ranges")
408
+ return {"status": "skipped", "reason": "private-mode"}
409
+
382
410
  # Volume gate — skip trivial forks, log the skip for coverage auditing.
383
411
  passed, turns, chars = passes_volume_gate(all_messages, config)
384
412
  if not passed: