switchroom 0.20.11 → 0.20.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 (50) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +32 -25
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/self-improve-stop.mjs +13 -1
  5. package/dist/cli/switchroom.js +3069 -1146
  6. package/dist/host-control/main.js +33 -26
  7. package/dist/vault/approvals/kernel-server.js +32 -25
  8. package/dist/vault/broker/server.js +32 -25
  9. package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
  10. package/package.json +7 -4
  11. package/skills/switchroom-architecture/telegram.md +0 -1
  12. package/skills/switchroom-cli/SKILL.md +0 -1
  13. package/skills/switchroom-release/SKILL.md +3 -2
  14. package/telegram-plugin/README.md +2 -11
  15. package/telegram-plugin/bridge/bridge.ts +0 -12
  16. package/telegram-plugin/bunfig.toml +9 -5
  17. package/telegram-plugin/chat-lock.ts +1 -1
  18. package/telegram-plugin/dist/bridge/bridge.js +0 -12
  19. package/telegram-plugin/dist/gateway/gateway.js +219 -127
  20. package/telegram-plugin/dist/server.js +0 -12
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +23 -1
  22. package/telegram-plugin/gateway/gateway.ts +25 -59
  23. package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
  24. package/telegram-plugin/gateway/outbound-send-path.ts +111 -6
  25. package/telegram-plugin/gateway/outbox-sweep.ts +69 -0
  26. package/telegram-plugin/gateway/stale-pin-sweep.ts +4 -3
  27. package/telegram-plugin/gateway/status-pin-store.ts +10 -9
  28. package/telegram-plugin/gateway/stream-render.ts +8 -4
  29. package/telegram-plugin/gateway/turn-record-status.ts +32 -1
  30. package/telegram-plugin/hooks/audience-classify.d.mts +26 -0
  31. package/telegram-plugin/hooks/audience-classify.mjs +193 -0
  32. package/telegram-plugin/hooks/hooks.json +13 -12
  33. package/telegram-plugin/hooks/narration-classify.mjs +1 -2
  34. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +31 -1
  35. package/telegram-plugin/hooks/silent-end-scan.mjs +9 -2
  36. package/telegram-plugin/outbox.ts +69 -3
  37. package/telegram-plugin/silent-end.ts +48 -5
  38. package/telegram-plugin/status-pin.ts +2 -5
  39. package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
  40. package/telegram-plugin/tests/captured-answer-resume.test.ts +26 -11
  41. package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
  42. package/telegram-plugin/tests/hindsight-bank-preload.test.ts +50 -0
  43. package/telegram-plugin/tests/outbox-live-path-review-4490.test.ts +613 -0
  44. package/telegram-plugin/tests/outbox-self-improve-review.test.ts +401 -0
  45. package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
  46. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
  47. package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
  48. package/telegram-plugin/worker-activity-feed.ts +1 -1
  49. package/vendor/hindsight-memory/scripts/recall.py +140 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
@@ -24,6 +24,37 @@
24
24
  */
25
25
  export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
26
26
 
27
+ /**
28
+ * The ONE way a turn's `duration_ms` is computed, shared by every emitter
29
+ * (`buildTurnRecord` → turns.jsonl, and all three `turn_ended` runtime-metric
30
+ * sites: the two stream-render turn-end paths + the liveness framework_fallback).
31
+ *
32
+ * Why a single helper: the three `turn_ended` emitters each hand-rolled the
33
+ * subtraction, and one drifted. `stream-render.ts` guarded with
34
+ * `startedAt > 0 ? now - startedAt : 0`; the framework_fallback path in
35
+ * `liveness-wiring.ts` did a bare `Date.now() - turnStartedAt` with only a
36
+ * `!= null` presence check. When `activeTurnStartedAt` held `0` (or any
37
+ * non-positive / non-finite value), that path emitted `duration_ms = now - 0`,
38
+ * i.e. the absolute Unix-epoch-ms — a ~56,000-year "duration". This poisoned the
39
+ * analysed turn_ended dataset (observed: 110 rows where `duration_ms === ts`),
40
+ * making every latency aggregate unusable.
41
+ *
42
+ * The invariant this helper enforces: a `duration_ms` is ALWAYS a non-negative
43
+ * elapsed-milliseconds value derived from a positive, finite start stamp — never
44
+ * an absolute epoch value, never negative (clock step back), never NaN. A start
45
+ * that is missing / zero / bogus yields `0` (a bounded, filterable sentinel)
46
+ * instead of a value that destroys aggregates.
47
+ */
48
+ export function computeTurnDurationMs(
49
+ startedAt: number | null | undefined,
50
+ now: number,
51
+ ): number {
52
+ if (typeof startedAt !== 'number' || !Number.isFinite(startedAt) || startedAt <= 0) {
53
+ return 0
54
+ }
55
+ return Math.max(0, now - startedAt)
56
+ }
57
+
27
58
  /** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
28
59
  export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
29
60
 
@@ -261,7 +292,7 @@ export function buildTurnRecord(
261
292
  return {
262
293
  ts: Math.floor(endedAt / 1000),
263
294
  agent: turn.agent,
264
- duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
295
+ duration_ms: computeTurnDurationMs(turn.startedAt, endedAt),
265
296
  tools: turn.toolCallCount ?? 0,
266
297
  status: computeTurnStatus(turn),
267
298
  turn_id: turn.turnId,
@@ -3,9 +3,16 @@ export type Audience = 'user' | 'internal'
3
3
  export const AUDIENCE_USER: 'user'
4
4
  export const AUDIENCE_INTERNAL: 'internal'
5
5
 
6
+ /** Mirror of `REVIEW_SOURCE` in `src/self-improve/review-prompt.ts`. */
7
+ export const REVIEW_SOURCE: 'self_improve_review'
8
+
9
+ export function isReviewOriginatedSource(source: string | null | undefined): boolean
10
+
6
11
  export function decideCaptureAudience(signals: {
7
12
  replyToolThrewThisTurn?: boolean
8
13
  openInboundObligation?: true | false | 'unknown'
14
+ reviewOriginated?: boolean
15
+ reviewTextIsCard?: boolean
9
16
  }): Audience
10
17
 
11
18
  export function resolveOpenObligation(args: {
@@ -39,6 +46,25 @@ export function formatReplyThrowFraming(s: {
39
46
  source?: string
40
47
  }): string
41
48
 
49
+ export const SELF_IMPROVEMENT_TITLE: string
50
+
51
+ export function isSelfImprovementCard(text: string | null | undefined): boolean
52
+
53
+ export function shouldFrameSelfImprovement(
54
+ record: { reviewOriginated?: unknown },
55
+ opts?: { frameEnabled?: boolean },
56
+ ): boolean
57
+
58
+ export function applySelfImprovementFraming(text: string): string
59
+
60
+ export function formatSelfImprovementFraming(s: {
61
+ turnNonce: string
62
+ turnId?: string | null
63
+ chatId?: string | null
64
+ textSha256?: string
65
+ source?: string
66
+ }): string
67
+
42
68
  export function formatInternalSuppression(s: {
43
69
  turnNonce: string
44
70
  turnId?: string | null
@@ -35,6 +35,39 @@ export const AUDIENCE_USER = 'user'
35
35
  */
36
36
  export const AUDIENCE_INTERNAL = 'internal'
37
37
 
38
+ /**
39
+ * The `meta.source` a self-improvement review turn's synthesized inbound
40
+ * carries. MUST stay byte-identical to `REVIEW_SOURCE` in
41
+ * `src/self-improve/review-prompt.ts` — this unbundled `.mjs` cannot import the
42
+ * TS module, so the constant is mirrored here (same discipline as `MAX_RETRIES`
43
+ * / `isTurnFlushSafetyEnabledEnv`). `tests/self-improve-review-audience.test.ts`
44
+ * pins the equality so a rename on either side reds CI.
45
+ *
46
+ * WHY IT LIVES IN THE AUDIENCE MODULE. A self-improvement review turn is
47
+ * injected with an explicit contract (`buildReviewPrompt`): "act SILENTLY, do
48
+ * NOT reply to the operator, end the turn when done." Its trailing transcript
49
+ * prose is therefore the agent reasoning to ITSELF — an `internal` audience by
50
+ * construction, exactly the vocabulary this module owns. Ken hit the leak this
51
+ * closes: a silent review turn's mid-turn reasoning ("I own personal-garmin,
52
+ * but the script I hand-rolled…") was captured by the outbox backstop and
53
+ * delivered into his DM as a raw, unlabelled message.
54
+ */
55
+ export const REVIEW_SOURCE = 'self_improve_review'
56
+
57
+ /**
58
+ * Did the turn that produced this capture originate from a self-improvement
59
+ * review inbound? Deterministic: it keys ONLY on the enqueue envelope's
60
+ * `source="…"` attribute (parsed upstream by `parseChannelEnvelope`), never on
61
+ * prose shape or a wording heuristic — the same source tag the rest of the
62
+ * self-improve machinery already routes on.
63
+ *
64
+ * @param {string | null | undefined} source The capture/record `source` field.
65
+ * @returns {boolean}
66
+ */
67
+ export function isReviewOriginatedSource(source) {
68
+ return source === REVIEW_SOURCE
69
+ }
70
+
38
71
  /**
39
72
  * Obligation state for the resolved chat, as seen at capture time.
40
73
  *
@@ -72,13 +105,46 @@ export const AUDIENCE_INTERNAL = 'internal'
72
105
  * would let a heuristic prose-shape match silently swallow a real answer —
73
106
  * exactly the severity-3 error this function is built to avoid.
74
107
  *
108
+ * `reviewOriginated` is the ONE other positive signal, and it is deterministic
109
+ * rather than heuristic. A self-improvement review turn is injected with a
110
+ * SYNTHESIZED inbound (`source="self_improve_review"`); no operator is waiting on
111
+ * an answer to it. Its contract (`buildReviewPrompt`) gives it exactly ONE
112
+ * operator-facing output: a well-formed self-improvement CARD (leading
113
+ * `SELF_IMPROVEMENT_TITLE` line) when — and only when — it surfaces a real
114
+ * outcome; otherwise it stays silent. So the review branch is checked FIRST and
115
+ * routes on card-shape:
116
+ *
117
+ * - review + text IS a card (`reviewTextIsCard === true`) ⇒ `user`. The card
118
+ * is the sanctioned surfacing message; deliver it. It is self-labelled by
119
+ * construction (it opens with the title), so it can never appear as raw,
120
+ * unlabelled reasoning.
121
+ * - review + text is NOT a card ⇒ `internal`. This is the leak Ken hit — a
122
+ * review turn's mid-turn reasoning captured by the backstop — and it is
123
+ * SUPPRESSED. Deterministic: the gate is an EXACT title-line prefix, not a
124
+ * fuzzy prose-shape guess, and the failure direction is SAFE (a mis-typed
125
+ * card is withheld, never a real answer swallowed — a review inbound has no
126
+ * waiting question to swallow, so this never manufactures the severity-3
127
+ * silent no-op the asymmetry above guards against).
128
+ *
129
+ * `reviewTextIsCard` is scoped to review turns ONLY; it can never affect a
130
+ * normal user turn's classification, so the "no prose-shape heuristic on user
131
+ * text" rule above is intact.
132
+ *
75
133
  * @param {{
76
134
  * replyToolThrewThisTurn?: boolean,
77
135
  * openInboundObligation?: true | false | 'unknown',
136
+ * reviewOriginated?: boolean,
137
+ * reviewTextIsCard?: boolean,
78
138
  * }} signals
79
139
  * @returns {'user' | 'internal'}
80
140
  */
81
141
  export function decideCaptureAudience(signals) {
142
+ // Self-improvement review turns (see the header note): the sanctioned card
143
+ // delivers (`user`); any other trailing prose is the leak and is suppressed
144
+ // (`internal`). Deterministic, independent of the reply-throw path below.
145
+ if (signals?.reviewOriginated === true) {
146
+ return signals?.reviewTextIsCard === true ? AUDIENCE_USER : AUDIENCE_INTERNAL
147
+ }
82
148
  const threw = signals?.replyToolThrewThisTurn === true
83
149
  if (!threw) return AUDIENCE_USER
84
150
  // Only a POSITIVE, known-empty obligation state clears the second gate.
@@ -330,6 +396,133 @@ export function formatReplyThrowFraming(s) {
330
396
  )
331
397
  }
332
398
 
399
+ /**
400
+ * ── Self-improvement review labelling (Ken, 2026-08-07) ──────────────────────
401
+ *
402
+ * TWO layers, both in this one shared module so the hook (which classifies +
403
+ * stamps) and the sweep (which delivers) run identical rules:
404
+ *
405
+ * 1. CARD GATE (primary, `decideCaptureAudience` above). A review turn's
406
+ * trailing backstop text is delivered to the operator ONLY IF it is a
407
+ * well-formed self-improvement card — `isSelfImprovementCard`, an EXACT
408
+ * leading-`SELF_IMPROVEMENT_TITLE` prefix. Non-card review prose (the raw
409
+ * reasoning Ken saw leak) classifies `internal` and is suppressed. This is
410
+ * the deterministic "never leak raw reasoning; the card is the only
411
+ * operator-facing output" guarantee.
412
+ *
413
+ * 2. TITLE FRAMING (residual). The card gate delivers only text that is
414
+ * ALREADY self-titled, so in the default config the delivered body needs no
415
+ * relabelling. This block is the belt-and-braces for the degraded config:
416
+ * if the audience gate is turned OFF (`SWITCHROOM_TG_OUTBOX_AUDIENCE_GATE=0`)
417
+ * so a NON-card review record reaches delivery, the title is prepended so it
418
+ * can still never appear as raw, unlabelled reasoning. Idempotent: text that
419
+ * already opens with the title is left untouched (no double title on a real
420
+ * card). Mirrors the reply-throw framing above — pure predicate + pure body
421
+ * transform + telemetry — and is additive only, so it can never manufacture
422
+ * silence.
423
+ */
424
+
425
+ /**
426
+ * The title line every self-improvement card opens with, and the label the
427
+ * residual framing prepends. `🔧 **Self-improvement**` — a leading glyph + bold
428
+ * so the operator sees at a glance this is a review note, not a normal reply.
429
+ * The card contract (`buildReviewPrompt`) continues the same line with
430
+ * ` — <one-line outcome>`, so this is a PREFIX of a real card, which is exactly
431
+ * what `isSelfImprovementCard` keys on.
432
+ */
433
+ export const SELF_IMPROVEMENT_TITLE = '🔧 **Self-improvement**'
434
+
435
+ /**
436
+ * Is `text` a well-formed self-improvement card — i.e. does it open with the
437
+ * `SELF_IMPROVEMENT_TITLE` line? This is the deterministic gate that separates
438
+ * the sanctioned surfacing card (deliver) from raw review reasoning (suppress).
439
+ *
440
+ * EXACT structural prefix, not a fuzzy prose-shape match: the model is
441
+ * instructed to emit the title verbatim as the first line of its one surfacing
442
+ * message, and the failure direction is SAFE — a mis-formatted card is withheld
443
+ * (silence), never a real answer delivered. Leading whitespace is tolerated so a
444
+ * stray newline before the title does not defeat the gate.
445
+ *
446
+ * @param {string | null | undefined} text
447
+ * @returns {boolean}
448
+ */
449
+ export function isSelfImprovementCard(text) {
450
+ if (typeof text !== 'string') return false
451
+ return text.trimStart().startsWith(SELF_IMPROVEMENT_TITLE)
452
+ }
453
+
454
+ /**
455
+ * Should this record's delivered text carry the residual title header?
456
+ *
457
+ * POSITIVE evidence only: an exact `true` on the record's persisted
458
+ * `reviewOriginated`. Missing / `undefined` / `'true'` / `1` — anything that is
459
+ * not the boolean — changes nothing, so a non-review record (every normal turn)
460
+ * delivers byte-for-byte as it does today. Text that is already a card is a
461
+ * no-op at `applySelfImprovementFraming` (idempotent), so this predicate stays
462
+ * simple: "is this a review record".
463
+ *
464
+ * `frameEnabled === false` is the kill switch, and it is the seam a revert-check
465
+ * flips.
466
+ *
467
+ * @param {{ reviewOriginated?: unknown }} record
468
+ * @param {{ frameEnabled?: boolean }} [opts]
469
+ * @returns {boolean}
470
+ */
471
+ export function shouldFrameSelfImprovement(record, opts) {
472
+ if (opts?.frameEnabled === false) return false
473
+ return record?.reviewOriginated === true
474
+ }
475
+
476
+ /**
477
+ * Compose the title header onto the delivered body. Pure; the caller owns the
478
+ * `(delayed) ` / `(from background task) ` delivery prefixes, which stay OUTSIDE
479
+ * (they describe the delivery, this describes the text). The title is the
480
+ * OUTERMOST content line so it always reads first, even when the reply-throw
481
+ * banner is also present.
482
+ *
483
+ * IDEMPOTENT: text that already opens with the title (a real card) is returned
484
+ * unchanged, so a delivered card never carries a duplicated title.
485
+ *
486
+ * @param {string} text
487
+ * @returns {string}
488
+ */
489
+ export function applySelfImprovementFraming(text) {
490
+ const body = typeof text === 'string' ? text : ''
491
+ // Empty body: the sweep's send adapter early-returns on empty text, and a
492
+ // title over nothing would be a message about nothing. Leave it.
493
+ if (body.trim().length === 0) return body
494
+ // Already a card — do not prepend a second title.
495
+ if (isSelfImprovementCard(body)) return body
496
+ return `${SELF_IMPROVEMENT_TITLE}\n\n${body}`
497
+ }
498
+
499
+ /**
500
+ * Structured telemetry for a self-improvement-framed delivery — the
501
+ * observability half of the rule, mirroring {@link formatReplyThrowFraming}.
502
+ * Framing changes what a human sees, so it must never be inferable only from the
503
+ * absence of a log line. Worded to match NONE of `GATEWAY_SIGNATURES` in
504
+ * `src/fleet-health/detect.ts`: a framed delivery IS a delivery, and must not
505
+ * page anyone.
506
+ *
507
+ * @param {{
508
+ * turnNonce: string,
509
+ * turnId?: string | null,
510
+ * chatId?: string | null,
511
+ * textSha256?: string,
512
+ * source?: string,
513
+ * }} s
514
+ * @returns {string}
515
+ */
516
+ export function formatSelfImprovementFraming(s) {
517
+ return (
518
+ `telegram gateway: outbox self-improvement framing nonce=${s.turnNonce} ` +
519
+ `turnId=${s.turnId ?? 'unknown'} chatId=${s.chatId ?? 'unresolved'} ` +
520
+ `sha=${(s.textSha256 ?? '').slice(0, 12)} source=${s.source ?? 'unknown'} ` +
521
+ `reviewOriginated=true — review-turn prose delivered with its self-improvement ` +
522
+ `title, never as raw unlabelled reasoning\n`
523
+ )
524
+ }
525
+
333
526
  /**
334
527
  * The structured telemetry line emitted when the sweep suppresses an
335
528
  * `internal` record. Mirrors `formatOrphanEscalation` (#4104): an exported pure
@@ -5,16 +5,17 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
8
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard-pretool.mjs\"",
9
9
  "timeout": 10
10
10
  }
11
11
  ]
12
12
  },
13
13
  {
14
+ "matcher": "^mcp__switchroom-telegram__(reply|stream_reply)$",
14
15
  "hooks": [
15
16
  {
16
17
  "type": "command",
17
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
18
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sentinel-reply-guard-pretool.mjs\"",
18
19
  "timeout": 5
19
20
  }
20
21
  ]
@@ -24,7 +25,7 @@
24
25
  "hooks": [
25
26
  {
26
27
  "type": "command",
27
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
28
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-pretool.mjs\"",
28
29
  "timeout": 10
29
30
  }
30
31
  ]
@@ -33,7 +34,7 @@
33
34
  "hooks": [
34
35
  {
35
36
  "type": "command",
36
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
37
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-pretool.mjs\"",
37
38
  "timeout": 5
38
39
  }
39
40
  ]
@@ -43,7 +44,7 @@
43
44
  "hooks": [
44
45
  {
45
46
  "type": "command",
46
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
47
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/repo-context-pretool.mjs\"",
47
48
  "timeout": 5
48
49
  }
49
50
  ]
@@ -55,7 +56,7 @@
55
56
  "hooks": [
56
57
  {
57
58
  "type": "command",
58
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
59
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/subagent-tracker-posttool.mjs\"",
59
60
  "timeout": 10
60
61
  }
61
62
  ]
@@ -65,7 +66,7 @@
65
66
  "hooks": [
66
67
  {
67
68
  "type": "command",
68
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
69
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/sandbox-hint-posttool.mjs\"",
69
70
  "timeout": 3
70
71
  }
71
72
  ]
@@ -76,7 +77,7 @@
76
77
  "hooks": [
77
78
  {
78
79
  "type": "command",
79
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
80
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/compaction-marker-precompact.mjs\"",
80
81
  "timeout": 5
81
82
  }
82
83
  ]
@@ -87,7 +88,7 @@
87
88
  "hooks": [
88
89
  {
89
90
  "type": "command",
90
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
91
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-scrub-stop.mjs\"",
91
92
  "timeout": 15,
92
93
  "async": true
93
94
  }
@@ -97,7 +98,7 @@
97
98
  "hooks": [
98
99
  {
99
100
  "type": "command",
100
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
101
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-end-interrupt-stop.mjs\"",
101
102
  "timeout": 5
102
103
  }
103
104
  ]
@@ -106,7 +107,7 @@
106
107
  "hooks": [
107
108
  {
108
109
  "type": "command",
109
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
110
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
110
111
  "timeout": 5
111
112
  }
112
113
  ]
@@ -115,7 +116,7 @@
115
116
  "hooks": [
116
117
  {
117
118
  "type": "command",
118
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
119
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" bun \"${CLAUDE_PLUGIN_ROOT}/hooks/tool-label-stop.mjs\"",
119
120
  "timeout": 5,
120
121
  "async": true
121
122
  }
@@ -108,7 +108,7 @@ export function isStructuralNarration(text, followedByToolUse) {
108
108
  * follow-up, MF4). A text block followed ONLY by tools in this set is still the
109
109
  * TERMINAL answer for backstop-coalescing purposes — these tools carry no
110
110
  * model-authored answer text and are the only ones plausibly fired AFTER a
111
- * terminal answer (answer, then react / pin / typing / edit). Everything NOT in
111
+ * terminal answer (answer, then react / typing / edit). Everything NOT in
112
112
  * this set — work / deliverable tools (download_attachment, get_recent_messages,
113
113
  * send_checklist, ask_user, …) and every non-telegram tool (retain / read /
114
114
  * bash / web / …) — is turn-CONTINUING: a text block a real tool followed is
@@ -123,7 +123,6 @@ export function isStructuralNarration(text, followedByToolUse) {
123
123
  export const EPHEMERAL_TOOLS = new Set([
124
124
  'react',
125
125
  'send_typing',
126
- 'pin_message',
127
126
  'delete_message',
128
127
  'edit_message',
129
128
  ])
@@ -72,6 +72,8 @@ import {
72
72
  import {
73
73
  decideCaptureAudience,
74
74
  resolveOpenObligation,
75
+ isReviewOriginatedSource,
76
+ isSelfImprovementCard,
75
77
  AUDIENCE_INTERNAL,
76
78
  } from './audience-classify.mjs'
77
79
 
@@ -129,6 +131,19 @@ function buildNextState(base, decision, retryCount) {
129
131
  } else {
130
132
  delete next.replyToolThrewThisTurn
131
133
  }
134
+ // #4490: same carry-through for review-turn provenance. The outbox capture
135
+ // path (`writeOutboxRecord` above) stamps `reviewOriginated` from the SAME
136
+ // envelope `source` tag; the ELECTED path ('trailing-text-after-reply') never
137
+ // writes an outbox record, so the captured-prose bridge (the delivering
138
+ // machine on this path) has no other way to learn it. Explicitly deleted
139
+ // when this turn's scan saw no review-source envelope, so a spread of a
140
+ // prior turn's file can never mislabel a normal turn's prose as a review
141
+ // turn's (or vice versa).
142
+ if (isReviewOriginatedSource(decision.source)) {
143
+ next.reviewOriginated = true
144
+ } else {
145
+ delete next.reviewOriginated
146
+ }
132
147
  return next
133
148
  }
134
149
 
@@ -181,7 +196,7 @@ function outboxAlreadyDelivered(outboxDir, nonce) {
181
196
  * `'unknown'` → the record delivers exactly as it does today.
182
197
  *
183
198
  * @param {string} stateDir
184
- * @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean }} capture
199
+ * @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean, source?: string|null }} capture
185
200
  * @returns {'user' | 'internal'}
186
201
  */
187
202
  function classifyCaptureAudience(stateDir, capture) {
@@ -212,6 +227,14 @@ function classifyCaptureAudience(stateDir, capture) {
212
227
  process.env.TELEGRAM_ACCESS_MODE !== 'static'
213
228
  return decideCaptureAudience({
214
229
  replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
230
+ // A self-improvement review turn has exactly ONE sanctioned operator-facing
231
+ // output: a well-formed self-improvement CARD. Deterministic on the enqueue
232
+ // envelope's `source` tag (the same signal the rest of the self-improve
233
+ // machinery routes on) plus the EXACT card-shape of the captured text. A
234
+ // card delivers; any other trailing prose (the raw-reasoning leak) is
235
+ // suppressed. Independent of the reply-throw / obligation state below.
236
+ reviewOriginated: isReviewOriginatedSource(capture.source),
237
+ reviewTextIsCard: isSelfImprovementCard(capture.text),
215
238
  openInboundObligation: resolveOpenObligation({
216
239
  snapshotRaw,
217
240
  snapshotTrusted,
@@ -265,6 +288,13 @@ function writeOutboxRecord(stateDir, capture, audience) {
265
288
  // not catch (the foreground case). Stamped here because this is the last
266
289
  // point in the pipeline that can still see the transcript.
267
290
  replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
291
+ // Ken 2026-08-07: did this turn originate from a self-improvement review
292
+ // inbound? `audience` already consumed it (with the card-shape signal) to
293
+ // deliver a well-formed card and suppress raw reasoning by default; the
294
+ // sweep consumes this raw flag again, on its own, to prepend the
295
+ // self-improvement TITLE to any review record delivered with the audience
296
+ // gate OFF, so review reasoning can never appear as raw, unlabelled prose.
297
+ reviewOriginated: isReviewOriginatedSource(capture.source),
268
298
  }
269
299
  const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
270
300
  writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
@@ -41,7 +41,7 @@
41
41
  // Cross-checked the full tool surface in `telegram-plugin/bridge/bridge.ts`
42
42
  // (`TOOL_SCHEMAS`, kept in sync with `gateway/gateway.ts`): `edit_message`
43
43
  // explicitly does NOT ping/deliver a fresh answer (its own description says
44
- // "send a new reply when a long task completes"); `react`, `pin_message`,
44
+ // "send a new reply when a long task completes"); `react`,
45
45
  // `delete_message`, `forward_message`, `send_typing`, `download_attachment`,
46
46
  // `get_recent_messages` carry no model-authored answer text at all;
47
47
  // `send_checklist` / `send_sticker` / `send_gif` / `ask_user` /
@@ -259,6 +259,13 @@ function buildTurnId(chatId, threadId, messageId) {
259
259
  */
260
260
  function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, replyToolThrewThisTurn) {
261
261
  const block = { decided: 'block', reason }
262
+ // #4490: the enqueue envelope's RAW `source` tag, carried through exactly
263
+ // like `replyToolThrewThisTurn` above — the single-writer election needs it
264
+ // to derive `reviewOriginated` for the captured-prose bridge (the ELECTED
265
+ // path's deliverer, which never writes an outbox record and so has no other
266
+ // way to learn a review turn's provenance). Omitted when the envelope carried
267
+ // none, so a spread of a prior state file is never read as positive evidence.
268
+ if (envelope.source != null) block.source = envelope.source
262
269
  if (replyToolThrewThisTurn === true) block.replyToolThrewThisTurn = true
263
270
  // Single-writer election input (#duplicate-message fix): does ANY
264
271
  // non-empty, non-silent trailing text block exist after the last
@@ -360,7 +367,7 @@ function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, reply
360
367
  * false-positive that burned retry budget on healthy turns.
361
368
  *
362
369
  * @param {string} jsonl
363
- * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string }}
370
+ * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string, source?: string, replyToolThrewThisTurn?: boolean }}
364
371
  */
365
372
  export function scanTurnForFinalReply(jsonl) {
366
373
  const lines = jsonl.split('\n')
@@ -75,6 +75,9 @@ import type { Audience } from './hooks/audience-classify.mjs'
75
75
  import {
76
76
  applyReplyThrowFraming,
77
77
  shouldFrameReplyThrow,
78
+ applySelfImprovementFraming,
79
+ shouldFrameSelfImprovement,
80
+ isSelfImprovementCard,
78
81
  } from './hooks/audience-classify.mjs'
79
82
 
80
83
  export type { Audience }
@@ -162,6 +165,25 @@ export interface OutboxRecord {
162
165
  * exact boolean `true` changes anything — see `shouldFrameReplyThrow`.
163
166
  */
164
167
  replyToolThrewThisTurn?: boolean
168
+ /**
169
+ * Ken 2026-08-07: did the turn that produced this record originate from a
170
+ * self-improvement review inbound (`source="self_improve_review"`)? Stamped at
171
+ * capture by `hooks/silent-end-interrupt-stop.mjs` from the enqueue envelope's
172
+ * source tag.
173
+ *
174
+ * `audience` already consumed it once — a review turn classifies `'internal'`
175
+ * (`decideCaptureAudience`), so by default it is SUPPRESSED at the sweep's
176
+ * entry gate and never delivered. The sweep consumes this raw flag again, on
177
+ * its own, only for the residual: a review record that IS delivered (the
178
+ * audience gate turned off, or a legacy/`user` route) must carry the
179
+ * self-improvement TITLE so it can never appear as raw, unlabelled agent
180
+ * reasoning — the leak this closes.
181
+ *
182
+ * OPTIONAL: absent on every pre-change record and on non-review turns, which
183
+ * deliver unframed. Only an exact boolean `true` changes anything — see
184
+ * `shouldFrameSelfImprovement`.
185
+ */
186
+ reviewOriginated?: boolean
165
187
  }
166
188
 
167
189
  /** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
@@ -204,6 +226,16 @@ export interface DeliveredEntry {
204
226
  * framed delivery is a delivery.
205
227
  */
206
228
  framedProvenance?: 'reply-throw'
229
+ /**
230
+ * Ken 2026-08-07: this delivery carried the self-improvement TITLE header
231
+ * (`SELF_IMPROVEMENT_TITLE`) in front of a review-originated record's prose.
232
+ * Durable and terminal, for the same reason `framedProvenance` is: framing
233
+ * changes what a human sees, so the outcome is recorded as an explicit named
234
+ * state on the journal line rather than being inferable only from a log line
235
+ * that may have rotated away. `tgMessageId` IS present on these lines — a
236
+ * framed delivery is a delivery.
237
+ */
238
+ framedSelfImprovement?: 'self-improve'
207
239
  }
208
240
 
209
241
  export function sha256Hex(s: string): string {
@@ -519,6 +551,13 @@ export interface OutboxSweepDecision {
519
551
  * the rule.
520
552
  */
521
553
  framedProvenance?: 'reply-throw'
554
+ /**
555
+ * Ken 2026-08-07: `text` carries the self-improvement title header. Surfaced
556
+ * on the decision (rather than left implicit in a string compare) so the
557
+ * caller can journal the outcome and emit telemetry without re-deriving the
558
+ * rule — same shape as `framedProvenance`.
559
+ */
560
+ framedSelfImprovement?: 'self-improve'
522
561
  }
523
562
 
524
563
  /**
@@ -538,7 +577,7 @@ export interface OutboxSweepDecision {
538
577
  export function decideOutboxSweep(input: {
539
578
  record: Pick<
540
579
  OutboxRecord,
541
- 'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn'
580
+ 'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn' | 'reviewOriginated'
542
581
  >
543
582
  now: number
544
583
  deliveredNonces: Set<string>
@@ -566,6 +605,13 @@ export function decideOutboxSweep(input: {
566
605
  * revert-check can flip it in-process.
567
606
  */
568
607
  provenanceFraming?: boolean
608
+ /**
609
+ * Ken 2026-08-07 kill switch for the self-improvement TITLE header. `false`
610
+ * restores the pre-change body byte-for-byte; the default is ON. Passed in
611
+ * rather than read from env here so this core stays pure and a revert-check
612
+ * can flip it in-process.
613
+ */
614
+ selfImprovementFraming?: boolean
569
615
  }): OutboxSweepDecision {
570
616
  const {
571
617
  record,
@@ -578,6 +624,7 @@ export function decideOutboxSweep(input: {
578
624
  maxAgeMs = OUTBOX_MAX_AGE_MS,
579
625
  shownLedgerHit = false,
580
626
  provenanceFraming = true,
627
+ selfImprovementFraming = true,
581
628
  } = input
582
629
  if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
583
630
  if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
@@ -592,11 +639,30 @@ export function decideOutboxSweep(input: {
592
639
  // the banner describes the TEXT. It is additive only: it can never turn a
593
640
  // send into a skip, so this branch cannot manufacture silence.
594
641
  const framed = shouldFrameReplyThrow(record, { frameEnabled: provenanceFraming })
595
- const body = framed ? applyReplyThrowFraming(record.text) : record.text
642
+ const provenanceBody = framed ? applyReplyThrowFraming(record.text) : record.text
643
+ // Ken 2026-08-07: the self-improvement title is the OUTERMOST content line, so
644
+ // it is applied AFTER the reply-throw banner — a review record that somehow
645
+ // also threw reads "🔧 Self-improvement" first, then the provenance note, then
646
+ // the prose. Additive only: it can never turn a send into a skip.
647
+ //
648
+ // #4489: idempotency is gated on the RAW `record.text`, NOT on `provenanceBody`.
649
+ // A real card's `record.text` opens with the title; the reply-throw banner
650
+ // above is PREPENDED in front of it, so `provenanceBody` no longer opens with
651
+ // the title even though the underlying card does. `applySelfImprovementFraming`
652
+ // alone can't see that — its idempotency check only looks at what it was
653
+ // handed — so gating the whole decision on the raw text is what keeps a real
654
+ // card from acquiring a second, duplicated title when both framings compose.
655
+ const selfImproveFramed =
656
+ shouldFrameSelfImprovement(record, { frameEnabled: selfImprovementFraming }) &&
657
+ !isSelfImprovementCard(record.text)
658
+ const body = selfImproveFramed ? applySelfImprovementFraming(provenanceBody) : provenanceBody
596
659
  return {
597
660
  action: delayed ? 'send-delayed' : 'send',
598
661
  text: prefix + body,
599
- ...(framed && body !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
662
+ ...(framed && provenanceBody !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
663
+ ...(selfImproveFramed && body !== provenanceBody
664
+ ? { framedSelfImprovement: 'self-improve' as const }
665
+ : {}),
600
666
  }
601
667
  }
602
668