switchroom 0.16.46 → 0.17.0

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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -0,0 +1,113 @@
1
+ /**
2
+ * #2787 Mechanism B — the `claudeBusyKeys` orphan-reaper lifecycle, extracted
3
+ * so the gateway glue that mutates the busy-key set stays in lockstep with its
4
+ * shadow insertion-timestamp map and is unit-testable WITHOUT importing
5
+ * `gateway.ts` (which boots the bot + IPC listener on module load and so cannot
6
+ * run inside a unit test). The gateway keeps ownership of the two containers
7
+ * (`claudeBusyKeys: Set`, `claudeBusyKeySince: Map`) and calls these helpers at
8
+ * every mark / reap site; `disconnect-flush.ts` clears them at bridge death.
9
+ * The test harness (`tests/busy-key-reaper.test.ts`) wires the SAME containers,
10
+ * the SAME delivery queue, and the real `flushOnAgentDisconnect` to reproduce
11
+ * the disconnect → reconnect → re-mark and slow-vs-orphan delivery sequences.
12
+ *
13
+ * Two invariants this module enforces:
14
+ *
15
+ * 1. LOCKSTEP. The timestamp map shadows actual SET MEMBERSHIP, not its own
16
+ * prior presence. `markBusyKeyLockstep` stamps a fresh timestamp whenever a
17
+ * key transitions idle→busy (`claudeBusyKeys.has(key) === false`). Gating on
18
+ * the set — not on `since.has` — is load-bearing: a disconnect flush clears
19
+ * `claudeBusyKeys` directly, so if the shadow map ever lagged, a `!since.has`
20
+ * guard would decline to re-stamp on the reconnect→re-mark and leave a
21
+ * stale, >TTL-old timestamp on a freshly-marked key — which the reaper would
22
+ * then reap out from under a live delivery.
23
+ *
24
+ * 2. SLOW-DELIVERY SAFETY (the #1922 hazard). `busy` is marked EAGERLY at
25
+ * delivery, and the gateway→claude enqueue-ack lag can be up to ~5 MINUTES
26
+ * under load (#1922). During that eager-mark→enqueue window the gateway's
27
+ * `currentTurn` is null yet the turn is real-and-merely-slow. A bare
28
+ * time-grace reaper cannot tell "slow" from "orphaned" and would reap a
29
+ * genuine delivery, re-opening the idle-drain gate while claude is about to
30
+ * process the very inbound whose key it just reaped (duplicate / concurrent
31
+ * delivery on the CORE inbound path). So the reap is PROOF-GATED: a key is
32
+ * reaped ONLY when it has NO entry in the delivery-confirm queue. A
33
+ * slow-but-real inbound is tracked there from delivery until claude's
34
+ * `enqueue` ack (via the never-drop re-deliver loop), so any key still
35
+ * awaiting its turn is present and skipped — no matter how slow. The time
36
+ * grace is retained as defense-in-depth, defaulting well above the observed
37
+ * ack-lag tail. Only a busy-marked key with NO pending delivery AND age past
38
+ * the grace is a true orphan (its turn acked and should have cleared busy at
39
+ * turn_end, or it was a steer/interrupt inbound excluded from tracking that
40
+ * amends a now-absent turn) — reaping those can never clobber a slow one.
41
+ */
42
+
43
+ /** Lockstep mark: add to the busy set and, on the idle→busy transition only,
44
+ * stamp the insertion time. Keyed on set membership so a re-mark after a
45
+ * disconnect flush (which cleared the set directly) always re-stamps fresh. */
46
+ export function markBusyKeyLockstep(
47
+ keys: Set<string>,
48
+ since: Map<string, number>,
49
+ key: string,
50
+ now: number,
51
+ ): void {
52
+ const wasBusy = keys.has(key)
53
+ keys.add(key)
54
+ if (!wasBusy) since.set(key, now)
55
+ }
56
+
57
+ /** Lockstep clear: delete from both containers. */
58
+ export function clearBusyKeyLockstep(
59
+ keys: Set<string>,
60
+ since: Map<string, number>,
61
+ key: string,
62
+ ): void {
63
+ keys.delete(key)
64
+ since.delete(key)
65
+ }
66
+
67
+ export interface ReapOptions {
68
+ /** Grace before a busy-marked key with no pending delivery is a true orphan. */
69
+ ttlMs: number
70
+ /** True iff a delivery-confirm entry is still tracked for this key — i.e. a
71
+ * slow-but-real inbound awaiting its enqueue ack. Such keys are NEVER reaped,
72
+ * regardless of age (the #1922 slow-delivery guarantee). */
73
+ hasPendingDelivery: (key: string) => boolean
74
+ /** Optional line logger for each reaped key (observability). */
75
+ log?: (msg: string) => void
76
+ }
77
+
78
+ /**
79
+ * Reap orphaned busy markers. Call ONLY once the gateway has asserted no turn is
80
+ * in flight (`currentTurn == null`). Returns the keys reaped (for the caller's
81
+ * metrics / assertions). Also prunes shadow-map entries whose key already left
82
+ * the set, so the map can't grow unbounded.
83
+ */
84
+ export function reapOrphanBusyKeys(
85
+ keys: Set<string>,
86
+ since: Map<string, number>,
87
+ now: number,
88
+ opts: ReapOptions,
89
+ ): string[] {
90
+ const reaped: string[] = []
91
+ for (const key of [...since.keys()]) {
92
+ if (!keys.has(key)) {
93
+ since.delete(key)
94
+ continue
95
+ }
96
+ // Proof gate: a key with a pending delivery is a slow-but-real inbound still
97
+ // awaiting its enqueue ack (the never-drop re-deliver loop owns it) — NEVER
98
+ // an orphan, regardless of age. Skip so a multi-minute (#1922) delivery
99
+ // can't be reaped out from under claude.
100
+ if (opts.hasPendingDelivery(key)) continue
101
+ const stampedAt = since.get(key) ?? now
102
+ if (now - stampedAt >= opts.ttlMs) {
103
+ keys.delete(key)
104
+ since.delete(key)
105
+ reaped.push(key)
106
+ opts.log?.(
107
+ `telegram gateway: reaped orphan busy key=${key} (no turn in flight, ` +
108
+ `no pending delivery, >${opts.ttlMs}ms) — unwedging idle-drain`,
109
+ )
110
+ }
111
+ }
112
+ return reaped
113
+ }
@@ -42,6 +42,12 @@ export interface DisconnectFlushDeps<Ctrl extends { finalize: (reason?: 'done' |
42
42
  * activeTurnStartedAt — the bridge just died, every turn it
43
43
  * was handed is dead by definition. */
44
44
  claudeBusyKeys: Set<string>
45
+ /** #2787: insertion-timestamp map for `claudeBusyKeys`, backing the orphan
46
+ * reaper. MUST stay in lockstep with `claudeBusyKeys` at every clear site —
47
+ * a stale timestamp surviving a clear makes a later re-marked key look
48
+ * >TTL-old and get reaped against a live delivery. Deleted alongside every
49
+ * `claudeBusyKeys.delete`/`.clear` below. */
50
+ claudeBusyKeySince: Map<string, number>
45
51
 
46
52
  /** Open draft-stream handles keyed by chat:thread:replyId. */
47
53
  activeDraftStreams: Map<string, Stream>
@@ -82,6 +88,7 @@ export function flushOnAgentDisconnect<
82
88
  activeReactionMsgIds,
83
89
  activeTurnStartedAt,
84
90
  claudeBusyKeys,
91
+ claudeBusyKeySince,
85
92
  activeDraftStreams,
86
93
  clearActiveReactions,
87
94
  disposeProgressDriver,
@@ -109,6 +116,7 @@ export function flushOnAgentDisconnect<
109
116
  activeReactionMsgIds.delete(key)
110
117
  activeTurnStartedAt.delete(key)
111
118
  claudeBusyKeys.delete(key)
119
+ claudeBusyKeySince.delete(key) // #2787: keep orphan-TTL map in lockstep
112
120
  }
113
121
  clearActiveReactions()
114
122
 
@@ -129,6 +137,7 @@ export function flushOnAgentDisconnect<
129
137
  activeTurnStartedAt.delete(k)
130
138
  activeReactionMsgIds.delete(k)
131
139
  claudeBusyKeys.delete(k)
140
+ claudeBusyKeySince.delete(k) // #2787: keep orphan-TTL map in lockstep
132
141
  }
133
142
  log(
134
143
  `telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
@@ -156,6 +165,8 @@ export function flushOnAgentDisconnect<
156
165
  const orphanCount = claudeBusyKeys.size
157
166
  const orphanKeys = [...claudeBusyKeys]
158
167
  claudeBusyKeys.clear()
168
+ // #2787: keep the orphan-TTL map in lockstep with the set it shadows.
169
+ for (const k of orphanKeys) claudeBusyKeySince.delete(k)
159
170
  log(
160
171
  `telegram gateway: disconnect-flush cleared ${orphanCount} orphan claudeBusyKeys ` +
161
172
  `entr${orphanCount === 1 ? 'y' : 'ies'} (synthetic-inbound deliveries that never turn_ended)` +
@@ -0,0 +1,46 @@
1
+ /**
2
+ * escalation-bridge-gate.ts — the bridge-alive gate for the obligation sweep's
3
+ * ESCALATE branch, extracted from obligationSweep so the "do NOT direct-send the
4
+ * 'I may have missed this' nudge while the Telegram bridge is down" decision
5
+ * (#2788 Gap A) is EXECUTABLE in a pure unit test.
6
+ *
7
+ * The bug (#2788 Gap A): the obligation sweep keeps running while the Telegram
8
+ * bridge is disconnected. Unlike the represent branch (which goes through the
9
+ * bridge → buffer and is naturally stranded behind a dead bridge), the escalate
10
+ * branch DIRECT-SENDS via `bot.api.sendMessage`, bypassing the bridge. So during
11
+ * a transient bridge flap the user could receive a false "I may have missed this"
12
+ * even though the real reply is simply queued behind the outage and will land the
13
+ * moment the bridge recovers.
14
+ *
15
+ * The fix is a deferral, not a drop: obligations already survive bridge death
16
+ * (they live in the durable ledger and are re-evaluated every sweep), so gating
17
+ * the escalate SEND on bridge-alive and returning without closing leaves the
18
+ * obligation OPEN. The next sweep after the bridge reconnects re-drives it — the
19
+ * nudge is deferred until it can reflect a real, post-recovery miss. This adds NO
20
+ * unbounded liveness dependency: the entire gateway's inbound delivery already
21
+ * rests on the bridge eventually reconnecting / the process restarting.
22
+ *
23
+ * PURE — no Telegram, no IPC; the gateway injects `bridgeAlive` (read from
24
+ * `ipcServer.getClient(agent)?.isAlive()`).
25
+ */
26
+
27
+ export interface EscalationBridgeGateDeps {
28
+ /**
29
+ * True when this agent's bridge sidecar is currently registered and alive on
30
+ * the IPC server — i.e. a direct escalate send can actually reflect delivery
31
+ * reality rather than racing a queued-behind-a-dead-bridge reply.
32
+ */
33
+ bridgeAlive: boolean
34
+ }
35
+
36
+ /**
37
+ * Decide whether the escalate branch should DEFER (skip this sweep, leave the
38
+ * obligation OPEN) because the bridge is down.
39
+ *
40
+ * Returns true ⇒ defer: do not send the nudge, do not close — the durable
41
+ * obligation is re-driven on a later sweep once the bridge recovers.
42
+ * Returns false ⇒ the bridge is alive; proceed with escalation.
43
+ */
44
+ export function shouldDeferEscalationForBridge(deps: EscalationBridgeGateDeps): boolean {
45
+ return !deps.bridgeAlive
46
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Gate-parity drift probe — scaffolding for the inbound-delivery
3
+ * state-machine cutover (#2794, RFC PR3b→PR4).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The inbound-delivery pipeline is mid-migration and TRIPLE-maintained:
8
+ *
9
+ * 1. the pure state machine (`inbound-delivery-machine.ts`) — the model,
10
+ * 2. the shadow (`inbound-delivery-machine-shadow.ts`) — module-scope
11
+ * machine state advanced by `shadowEmit`, now AUTHORITATIVE for the
12
+ * turn-in-flight gate via `isMachineInTurn()`,
13
+ * 3. the legacy imperative `claudeBusyKeys` set — still fully maintained
14
+ * in parallel and read as the kill-switch fallback.
15
+ *
16
+ * Per #2794 the standing risk is DRIFT: because the same turn-lifecycle
17
+ * fact ("is a turn in flight?") is tracked in two live places, a fix in
18
+ * one can silently diverge from the other. The machine is authoritative
19
+ * today, but `claudeBusyKeys` is still wired and still read on the
20
+ * kill-switch path — so the two MUST NOT drift in the dangerous direction.
21
+ *
22
+ * ## What "agree" means (and the ONE intended divergence)
23
+ *
24
+ * The machine and `claudeBusyKeys` are designed to agree on every
25
+ * WELL-FORMED schedule (every turnStart has a matching turnEnd). They are
26
+ * deliberately allowed to diverge in exactly ONE direction on a MALFORMED
27
+ * schedule — an orphaned turnStart (turn B opens before turn A's turnEnd
28
+ * ever lands, the gymbro/clerk 5-min dangle of 2026-05-28):
29
+ *
30
+ * - `busykeys_dangle` — machine reads idle (self-healed via TTL tick or
31
+ * single-activeTurn reopen) while `claudeBusyKeys`
32
+ * still holds an orphan key. This is EXPECTED and
33
+ * GOOD: it is precisely the wedge the machine was
34
+ * made authoritative to kill. Not a drift alarm.
35
+ *
36
+ * - `machine_over_holds` — machine reads in-flight while `claudeBusyKeys`
37
+ * is empty. This is the DANGEROUS direction: the
38
+ * now-authoritative gate would hold closed while
39
+ * the imperative view says idle — a NEW wedge
40
+ * class the cutover must never introduce. This
41
+ * is the drift #2794 warns about; surface it.
42
+ *
43
+ * This module is a pure classifier plus a log-only runtime probe. It
44
+ * performs NO I/O of its own beyond an optional injected log sink and
45
+ * changes NO delivery behaviour — it only observes. Deleting it once the
46
+ * `claudeBusyKeys` shadow is removed in PR4 is a no-op for behaviour.
47
+ */
48
+
49
+ export type GateParityDivergence = 'none' | 'busykeys_dangle' | 'machine_over_holds'
50
+
51
+ /**
52
+ * Classify the relationship between the machine's authoritative
53
+ * turn-in-flight read and the legacy imperative `claudeBusyKeys` size.
54
+ * Pure — no side effects.
55
+ */
56
+ export function gateParityDivergence(
57
+ machineInTurn: boolean,
58
+ busyKeysSize: number,
59
+ ): GateParityDivergence {
60
+ const busy = busyKeysSize > 0
61
+ if (machineInTurn === busy) return 'none'
62
+ // machine idle, busyKeys non-empty → the orphan dangle the machine heals.
63
+ if (!machineInTurn && busy) return 'busykeys_dangle'
64
+ // machine in-flight, busyKeys empty → the dangerous over-hold.
65
+ return 'machine_over_holds'
66
+ }
67
+
68
+ /**
69
+ * True only for divergences that indicate a real cutover regression
70
+ * (the machine holding a gate the imperative shadow believes is open).
71
+ * The benign `busykeys_dangle` — the very wedge the machine fixes — is
72
+ * NOT flagged, so this probe has zero false positives on the known-good
73
+ * self-heal path.
74
+ */
75
+ export function isDangerousGateDivergence(d: GateParityDivergence): boolean {
76
+ return d === 'machine_over_holds'
77
+ }
78
+
79
+ /**
80
+ * Log-only runtime drift canary. Call at the authoritative gate read.
81
+ * Emits a single grep-friendly `gw-trace gate-drift` line ONLY on the
82
+ * dangerous over-hold direction. Returns the machine value UNCHANGED so
83
+ * it can wrap the gate read without altering behaviour:
84
+ *
85
+ * return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
86
+ *
87
+ * @param log optional sink (default stderr) — test hook.
88
+ */
89
+ export function probeGateParity(
90
+ machineInTurn: boolean,
91
+ busyKeysSize: number,
92
+ log: (line: string) => void = (line) => process.stderr.write(line),
93
+ ): boolean {
94
+ const d = gateParityDivergence(machineInTurn, busyKeysSize)
95
+ if (isDangerousGateDivergence(d)) {
96
+ log(
97
+ `gw-trace gate-drift kind=${d} machineInTurn=${machineInTurn} ` +
98
+ `busyKeys=${busyKeysSize} note=machine-authoritative-gate-holds-while-imperative-idle\n`,
99
+ )
100
+ }
101
+ return machineInTurn
102
+ }