switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -178,6 +178,57 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
178
178
  }
179
179
  }
180
180
 
181
+ // Migration (review finding H5): make INSERT OR REPLACE idempotent for thread-less rows.
182
+ //
183
+ // The table's PRIMARY KEY is (chat_id, thread_id, message_id), but thread_id
184
+ // is nullable and SQLite treats NULL as DISTINCT from NULL in a PK/UNIQUE
185
+ // index. Every DM and every non-topic group message has thread_id = NULL, so
186
+ // two inserts of the same (chat_id, message_id) with NULL thread do NOT
187
+ // conflict — `INSERT OR REPLACE` APPENDS a duplicate row instead of replacing
188
+ // it. On the documented at-least-once boot replay / synthesized-resume
189
+ // re-record, an already-stored message is duplicated, inflating
190
+ // get_recent_messages and the getRecentOutboundCount / hasOutboundDeliveredSince
191
+ // counters that feed the silence / over-ping detectors.
192
+ //
193
+ // Fix: a UNIQUE index over COALESCE(thread_id, '') gives every logical
194
+ // (chat, thread-or-general, message_id) key a NON-null uniqueness value, so
195
+ // REPLACE conflict-resolves and dedupes thread-less rows too. INSERT OR
196
+ // REPLACE resolves against ANY unique index, so no writer change is needed.
197
+ // A forum-topic row (non-null thread) and a general row (NULL thread) that
198
+ // share a message_id keep DISTINCT keys (the topic id vs ''), so they stay
199
+ // separate. Stored thread_id values remain real NULLs, so every read path
200
+ // (`thread_id IS NULL` / `thread_id = ?`) is unchanged.
201
+ const LOGICAL_KEY_INDEX = 'idx_messages_logical_key'
202
+ const logicalKeyIndexExists =
203
+ db
204
+ .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`)
205
+ .get(LOGICAL_KEY_INDEX) != null
206
+ if (!logicalKeyIndexExists) {
207
+ // De-dupe rows an earlier (pre-fix) build already appended, keeping the
208
+ // NEWEST row per logical key (highest ts, then highest rowid = the last
209
+ // write, which is what INSERT OR REPLACE would have left). This MUST run
210
+ // before the UNIQUE index is created, or CREATE UNIQUE INDEX would fail on
211
+ // the existing duplicates. On a fresh/empty DB it is a harmless no-op.
212
+ db.exec(`
213
+ DELETE FROM messages
214
+ WHERE rowid NOT IN (
215
+ SELECT keep_rowid FROM (
216
+ SELECT rowid AS keep_rowid,
217
+ ROW_NUMBER() OVER (
218
+ PARTITION BY chat_id, COALESCE(thread_id, ''), message_id
219
+ ORDER BY ts DESC, rowid DESC
220
+ ) AS rn
221
+ FROM messages
222
+ )
223
+ WHERE rn = 1
224
+ )
225
+ `)
226
+ db.exec(
227
+ `CREATE UNIQUE INDEX IF NOT EXISTS ${LOGICAL_KEY_INDEX} ` +
228
+ `ON messages (chat_id, COALESCE(thread_id, ''), message_id)`,
229
+ )
230
+ }
231
+
181
232
  // Readable by owner and others so the web dashboard (different uid than the
182
233
  // agent) can stream replies back to Hermes Desktop. The WAL sidecar files
183
234
  // (-shm/-wal) are also chmod'd so SQLite readonly opens succeed for uid=1000.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Pure helpers for the dispatch-claim Stop hook (#396) — extracted so
3
+ * unit tests can exercise the scan logic without spawning the .mjs
4
+ * subprocess (mirrors the `silent-end-scan.mjs` extraction pattern).
5
+ *
6
+ * The defect (#396): the model sends a reply that TELLS the user it has
7
+ * dispatched work ("Dispatching a worker now to fix this") but the turn
8
+ * contains no `Agent`/`Task` tool call and no backgrounded `Bash` — the
9
+ * narration was never backed by an actual dispatch. The silent-end Stop
10
+ * gate (#1664) does not catch this: that turn HAS a qualifying final
11
+ * reply, so it passes untouched.
12
+ *
13
+ * Mechanism (Stop-hook only — the design comment on #396 drops the
14
+ * 2026-04 PreToolUse deny; a pre-send deny would fire before the Agent
15
+ * call exists and block legitimate reply-then-dispatch flows):
16
+ * 1. Walk the current turn's transcript slice (same turn-anchor walk as
17
+ * `silent-end-scan.mjs:scanTurnForFinalReply`), skipping
18
+ * `isSidechain:true` lines. A sub-agent's own reply/dispatch lines
19
+ * leak into the parent transcript with that marker; they are neither
20
+ * the parent's claim NOR the parent's dispatch, so they are skipped
21
+ * for BOTH detections.
22
+ * 2. Collect the text of qualifying `reply`/`stream_reply` tool_use
23
+ * calls (reuse `REPLY_TOOLS`).
24
+ * 3. Detect whether the turn actually dispatched: any non-sidechain
25
+ * `Agent`/`Task` tool_use, OR a `Bash` tool_use with
26
+ * `run_in_background: true`.
27
+ * 4. Block only when a reply text makes a first-person dispatch
28
+ * commitment AND the turn dispatched nothing.
29
+ *
30
+ * Registry-check decision (design comment "belt-and-braces" step 4):
31
+ * The design offered two options — (a) query the subagent-tracker
32
+ * `registry.db` for a dispatch row in this turn window, or (b) skip the
33
+ * registry and rely on transcript-only detection, in which case
34
+ * "past-tense phrasing must be excluded by the regex". We take (b). The
35
+ * transcript already carries the ground truth for THIS turn's dispatches
36
+ * (the `Agent`/`Task` tool_use blocks are in the same JSONL slice we
37
+ * walk), so a second sqlite round-trip from a dependency-free .mjs hook
38
+ * would be redundant and awkward. The registry's real value in the design
39
+ * was clearing PAST-tense references to earlier-turn dispatches; we
40
+ * achieve the same by EXCLUDING pure past-tense verb forms from the
41
+ * commitment regex (see DISPATCH_COMMIT_RE below) so "the worker I
42
+ * dispatched earlier" never trips the gate in the first place. This keeps
43
+ * the hook self-contained and deterministic (no cross-process DB read on
44
+ * the reply path).
45
+ */
46
+
47
+ // Same two MCP tools whose payload is the model's free-text answer text
48
+ // reaching the user (verified complete in silent-end-scan.mjs's header).
49
+ const REPLY_TOOLS = new Set([
50
+ 'mcp__switchroom-telegram__reply',
51
+ 'mcp__switchroom-telegram__stream_reply',
52
+ ])
53
+
54
+ // Dispatch tool names — Claude Code emits the sub-agent dispatch tool under
55
+ // either the legacy `Agent` or the newer `Task` name depending on version
56
+ // (same dual-name recognition subagent-tracker-pretool.mjs uses).
57
+ const DISPATCH_TOOLS = new Set(['Agent', 'Task'])
58
+
59
+ // Silent marker (NO_REPLY / HEARTBEAT_OK + optional trailing punct) — a turn
60
+ // whose only reply is a bare silent marker made no claim about anything.
61
+ // Matches silent-end-scan.mjs SILENT_MARKER_RE.
62
+ const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
63
+
64
+ // First-person dispatch-commitment regex. Seed list from the #396 design
65
+ // comment:
66
+ // \b(dispatch(ing|ed)?|launch(ing|ed)?|spinning up|kick(ing|ed) off|
67
+ // delegat(ing|ed)|spawn(ing|ed)?)\b[^.?!]{0,60}\b(worker|researcher|
68
+ // reviewer|sub-?agent|agent|task)\b
69
+ //
70
+ // DEVIATION (documented above): because we skip the registry belt-and-
71
+ // braces check, we drop the PURE PAST-TENSE alternatives (`dispatched`,
72
+ // `launched`, `kicked off`, `delegated`, `spawned`) from the verb group.
73
+ // Present/progressive/bare-infinitive forms ("dispatching a worker",
74
+ // "I'll dispatch a worker", "spinning up a researcher") are commitments
75
+ // about THIS turn; a bare past-tense reference ("the worker I dispatched
76
+ // earlier") is about a prior turn and must not trip the gate. Keeping only
77
+ // the non-past forms is the transcript-only substitute for the registry's
78
+ // past-tense clearing.
79
+ const DISPATCH_COMMIT_RE =
80
+ /\b(?:dispatch(?:ing)?|launch(?:ing)?|spinning up|kick(?:ing)? off|delegat(?:e|ing)|spawn(?:ing)?)\b[^.?!]{0,60}?\b(?:worker|researcher|reviewer|sub-?agent|agent|task)\b/i
81
+
82
+ // Interrogative / conditional exclusion. A sentence offering to dispatch
83
+ // ("want me to dispatch a worker?", "I could spin up a reviewer if you
84
+ // like") is not a commitment that a dispatch HAPPENED — it is a question
85
+ // or a conditional, so it must not block. Seed list from the design
86
+ // comment: '?', could|should|would|want me to|shall I|if you.
87
+ const CONDITIONAL_RE = /\?|\b(?:could|should|would|want me to|shall i|if you)\b/i
88
+
89
+ /**
90
+ * True when `text` (one reply's payload) contains at least one SENTENCE
91
+ * that makes a first-person dispatch commitment and is not interrogative
92
+ * or conditional.
93
+ *
94
+ * Split into sentences first (retaining the trailing `.?!` terminator) so
95
+ * the conditional exclusion is scoped to the sentence carrying the verb —
96
+ * a later unrelated sentence with a '?' must not amnesty a real earlier
97
+ * commitment, and vice versa.
98
+ *
99
+ * @param {string} text
100
+ * @returns {boolean}
101
+ */
102
+ export function replyClaimsDispatch(text) {
103
+ if (typeof text !== 'string' || text.length === 0) return false
104
+ // Bare silent markers carry no claim.
105
+ if (SILENT_MARKER_RE.test(text.trim())) return false
106
+ const sentences = text.match(/[^.?!]+[.?!]*/g) || [text]
107
+ for (const sentence of sentences) {
108
+ if (!DISPATCH_COMMIT_RE.test(sentence)) continue
109
+ if (CONDITIONAL_RE.test(sentence)) continue
110
+ return true
111
+ }
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * Compute a stable per-turn signature from the enqueue anchor line's raw
117
+ * content string (the inbound message envelope). Used by the hook's
118
+ * 1-retry budget so the budget resets on a genuinely new turn (a new
119
+ * enqueue line with a new message_id) but is preserved across the
120
+ * re-prompt of the SAME turn (same enqueue anchor — no new inbound).
121
+ *
122
+ * A tiny djb2 hash keeps the state file small and dependency-free.
123
+ *
124
+ * @param {string} content
125
+ * @returns {string}
126
+ */
127
+ export function turnSignature(content) {
128
+ const s = typeof content === 'string' ? content : ''
129
+ let h = 5381
130
+ for (let i = 0; i < s.length; i++) {
131
+ h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
132
+ }
133
+ return `t${h.toString(36)}`
134
+ }
135
+
136
+ /**
137
+ * Scan a JSONL transcript and decide whether the current turn told the
138
+ * user it dispatched work without actually dispatching anything.
139
+ *
140
+ * Returns:
141
+ * { decided: 'allow', reason } — no unbacked dispatch claim
142
+ * { decided: 'block', reason, turnSig } — a reply claimed a dispatch
143
+ * but the turn dispatched
144
+ * nothing; `turnSig` anchors
145
+ * the hook's retry budget
146
+ * { decided: 'unknown', reason } — no turn-start anchor found;
147
+ * caller fails open
148
+ *
149
+ * Turn-start anchor: the most recent `queue-operation`/`enqueue` line
150
+ * (identical to `scanTurnForFinalReply`). Fail-open on any structural
151
+ * miss — a mis-firing gate must never wedge the reply path.
152
+ *
153
+ * @param {string} jsonl
154
+ * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnSig?: string }}
155
+ */
156
+ export function scanTurnForDispatchClaim(jsonl) {
157
+ if (typeof jsonl !== 'string' || jsonl.length === 0) {
158
+ return { decided: 'unknown', reason: 'empty-transcript' }
159
+ }
160
+ const lines = jsonl.split('\n')
161
+
162
+ // 1. Walk backward to the most-recent enqueue (the turn-start anchor).
163
+ let startIdx = -1
164
+ let enqueueContent = ''
165
+ for (let i = lines.length - 1; i >= 0; i--) {
166
+ const line = lines[i]
167
+ if (!line || line[0] !== '{') continue
168
+ let obj
169
+ try { obj = JSON.parse(line) } catch { continue }
170
+ if (obj?.type === 'queue-operation' && obj.operation === 'enqueue') {
171
+ startIdx = i
172
+ enqueueContent = typeof obj.content === 'string' ? obj.content : ''
173
+ break
174
+ }
175
+ }
176
+ if (startIdx < 0) {
177
+ return { decided: 'unknown', reason: 'no-turn-start' }
178
+ }
179
+
180
+ // 2. Walk the turn forward, skipping sub-agent (isSidechain) lines for
181
+ // BOTH claim detection and dispatch detection. Collect reply texts;
182
+ // note whether the turn dispatched anything real.
183
+ let dispatched = false
184
+ const replyTexts = []
185
+ for (let i = startIdx + 1; i < lines.length; i++) {
186
+ const line = lines[i]
187
+ if (!line || line[0] !== '{') continue
188
+ let obj
189
+ try { obj = JSON.parse(line) } catch { continue }
190
+ // Sidechain (sub-agent) lines are not the parent's claim NOR the
191
+ // parent's dispatch — skip entirely.
192
+ if (obj?.isSidechain === true) continue
193
+ if (obj?.type !== 'assistant') continue
194
+ const content = obj?.message?.content
195
+ if (!Array.isArray(content)) continue
196
+ for (const c of content) {
197
+ if (c?.type !== 'tool_use') continue
198
+ const name = c.name
199
+ if (DISPATCH_TOOLS.has(name)) {
200
+ dispatched = true
201
+ continue
202
+ }
203
+ if (name === 'Bash') {
204
+ const input = c.input ?? {}
205
+ if (input.run_in_background === true) dispatched = true
206
+ continue
207
+ }
208
+ if (REPLY_TOOLS.has(name)) {
209
+ const input = c.input ?? {}
210
+ replyTexts.push(String(input.text ?? ''))
211
+ continue
212
+ }
213
+ }
214
+ }
215
+
216
+ // 3. If the turn actually dispatched work, the claim (if any) is backed
217
+ // — allow, regardless of reply phrasing.
218
+ if (dispatched) {
219
+ return { decided: 'allow', reason: 'dispatched' }
220
+ }
221
+
222
+ // 4. No dispatch happened. Block iff a reply made a first-person
223
+ // dispatch commitment that was neither interrogative nor conditional.
224
+ const claimed = replyTexts.some((t) => replyClaimsDispatch(t))
225
+ if (claimed) {
226
+ return { decided: 'block', reason: 'claim-without-dispatch', turnSig: turnSignature(enqueueContent) }
227
+ }
228
+ return { decided: 'allow', reason: 'no-claim' }
229
+ }
230
+
231
+ /**
232
+ * Pure 1-retry-budget helper for the Stop hook. Mirrors the
233
+ * `silent-end-interrupt-stop.mjs` MAX_RETRIES pattern but with its own
234
+ * state shape, keyed on the per-turn signature so the budget resets on a
235
+ * new turn and is honoured across the re-prompt of the same turn.
236
+ *
237
+ * Returns:
238
+ * { block: true, nextState } — budget available; write nextState and block
239
+ * { block: false } — budget exhausted for this turn; fail open
240
+ *
241
+ * @param {{ turnSig?: string, retryCount?: number } | null | undefined} existingState
242
+ * @param {string} turnSig
243
+ * @param {number} maxRetries
244
+ * @returns {{ block: boolean, nextState?: { turnSig: string, retryCount: number, timestamp: number } }}
245
+ */
246
+ export function applyRetryBudget(existingState, turnSig, maxRetries) {
247
+ const prev = existingState && typeof existingState === 'object' ? existingState : {}
248
+ // Only carry the counter forward when it belongs to the SAME turn;
249
+ // otherwise this is a fresh turn and the budget starts at 0.
250
+ const prevCount =
251
+ prev.turnSig === turnSig && typeof prev.retryCount === 'number' ? prev.retryCount : 0
252
+ if (prevCount >= maxRetries) {
253
+ return { block: false }
254
+ }
255
+ return {
256
+ block: true,
257
+ nextState: { turnSig, retryCount: prevCount + 1, timestamp: Date.now() },
258
+ }
259
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop hook (#396) — deterministic guardrail against "narrate a dispatch
4
+ * without executing it".
5
+ *
6
+ * The defect: the model replies "Dispatching a worker now to fix this",
7
+ * the user sees a confident hand-off, but the turn contains no `Agent` /
8
+ * `Task` tool call and no backgrounded `Bash` — nothing was actually
9
+ * dispatched. The #1664 silent-end Stop gate does NOT catch this: that
10
+ * turn HAS a qualifying final reply, so it passes untouched.
11
+ *
12
+ * This hook, at Stop, scans the just-finished turn's transcript slice for
13
+ * a reply that makes a first-person dispatch commitment while the turn
14
+ * dispatched nothing, and re-prompts the model exactly once to actually
15
+ * dispatch (or send a correction reply). See `dispatch-claim-scan.mjs`
16
+ * for the pure decision logic and the registry-check rationale.
17
+ *
18
+ * Protocol (Claude Code Stop hook v1):
19
+ * Input: JSON on stdin — { session_id, transcript_path, ... }
20
+ * Output: exit 0 + empty stdout → allow stop.
21
+ * exit 0 + JSON stdout { decision: "block", reason } → re-prompt.
22
+ *
23
+ * Fail-open on EVERY error path (no transcript / unreadable / no
24
+ * turn-start anchor / state-file failure) — consistent with every sibling
25
+ * reply-path hook. A mis-firing gate must never loop a session; the
26
+ * 1-retry budget bounds the block direction on top of that.
27
+ */
28
+
29
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { homedir } from 'node:os'
32
+
33
+ import { scanTurnForDispatchClaim, applyRetryBudget } from './dispatch-claim-scan.mjs'
34
+
35
+ // Exactly one re-prompt per turn (#396 design). A second Stop fire on the
36
+ // same turn (same turnSig) exhausts the budget and fails open.
37
+ const MAX_RETRIES = 1
38
+
39
+ function readStdin() {
40
+ try {
41
+ return readFileSync(0, 'utf8')
42
+ } catch {
43
+ return ''
44
+ }
45
+ }
46
+
47
+ function getStateDir() {
48
+ return process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
49
+ }
50
+
51
+ function main() {
52
+ const raw = readStdin().trim()
53
+ if (!raw) process.exit(0)
54
+
55
+ let event
56
+ try {
57
+ event = JSON.parse(raw)
58
+ } catch {
59
+ process.exit(0)
60
+ }
61
+
62
+ const transcriptPath = event?.transcript_path
63
+ if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
64
+ process.exit(0)
65
+ }
66
+
67
+ let jsonl
68
+ try {
69
+ jsonl = readFileSync(transcriptPath, 'utf8')
70
+ } catch (err) {
71
+ process.stderr.write(
72
+ `[dispatch-claim] failed to read transcript ${transcriptPath}: ${err.message}\n`,
73
+ )
74
+ process.exit(0)
75
+ }
76
+
77
+ const decision = scanTurnForDispatchClaim(jsonl)
78
+
79
+ // 'allow' (no unbacked claim) and 'unknown' (no turn-start anchor) both
80
+ // allow the stop.
81
+ if (decision.decided !== 'block') {
82
+ process.exit(0)
83
+ }
84
+
85
+ // 1-retry budget, keyed on the per-turn signature so a second Stop fire
86
+ // on the SAME turn fails open while a genuinely new turn starts fresh.
87
+ const statePath = join(getStateDir(), 'dispatch-claim-pending.json')
88
+
89
+ let state = {}
90
+ if (existsSync(statePath)) {
91
+ try {
92
+ state = JSON.parse(readFileSync(statePath, 'utf8'))
93
+ } catch {
94
+ state = {}
95
+ }
96
+ }
97
+
98
+ const budget = applyRetryBudget(state, decision.turnSig, MAX_RETRIES)
99
+ if (!budget.block) {
100
+ process.stderr.write(
101
+ `[dispatch-claim] retry budget exhausted for turn ${decision.turnSig} — allowing stop\n`,
102
+ )
103
+ process.exit(0)
104
+ }
105
+
106
+ try {
107
+ writeFileSync(statePath, JSON.stringify(budget.nextState), 'utf8')
108
+ } catch (err) {
109
+ // Fail-open: a retry-count write failure must not loop the session.
110
+ process.stderr.write(`[dispatch-claim] failed to update state file: ${err.message}\n`)
111
+ process.exit(0)
112
+ }
113
+
114
+ process.stderr.write(
115
+ `[dispatch-claim] blocking stop to re-prompt agent (reason=${decision.reason} turn=${decision.turnSig})\n`,
116
+ )
117
+
118
+ process.stdout.write(
119
+ JSON.stringify({
120
+ decision: 'block',
121
+ reason:
122
+ 'Your reply told the user you dispatched work, but no Agent/Task call ' +
123
+ 'ran this turn. Actually dispatch it now, or send a correction reply.',
124
+ }),
125
+ )
126
+ process.exit(0)
127
+ }
128
+
129
+ main()
@@ -91,6 +91,15 @@
91
91
  }
92
92
  ]
93
93
  },
94
+ {
95
+ "hooks": [
96
+ {
97
+ "type": "command",
98
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
99
+ "timeout": 5
100
+ }
101
+ ]
102
+ },
94
103
  {
95
104
  "hooks": [
96
105
  {