switchroom 0.19.17 → 0.19.19

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 (91) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +13 -4
  4. package/dist/auth-broker/index.js +32 -5
  5. package/dist/cli/drive-write-pretool.mjs +48 -5
  6. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  7. package/dist/cli/notion-write-pretool.mjs +13 -4
  8. package/dist/cli/switchroom.js +10614 -8104
  9. package/dist/host-control/main.js +12849 -11446
  10. package/dist/vault/approvals/kernel-server.js +90 -12
  11. package/dist/vault/broker/server.js +277 -94
  12. package/package.json +5 -3
  13. package/profiles/_base/start.sh.hbs +69 -5
  14. package/profiles/coding/CLAUDE.md.hbs +1 -1
  15. package/profiles/default/CLAUDE.md.hbs +3 -3
  16. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  17. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  18. package/skills/mental-model-curator/SKILL.md +8 -6
  19. package/telegram-plugin/bridge/bridge.ts +25 -19
  20. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +28 -20
  22. package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
  23. package/telegram-plugin/dist/server.js +32 -20
  24. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  25. package/telegram-plugin/gateway/boot-card.ts +5 -1
  26. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  27. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  28. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  30. package/telegram-plugin/gateway/gateway.ts +43 -1
  31. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  32. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  33. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  34. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  35. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  36. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  37. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  38. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  39. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  40. package/telegram-plugin/gateway/store-file.ts +244 -0
  41. package/telegram-plugin/gateway/stream-render.ts +24 -5
  42. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  43. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  44. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  45. package/telegram-plugin/registry/turns-schema.ts +40 -12
  46. package/telegram-plugin/runtime-metrics.ts +14 -0
  47. package/telegram-plugin/silence-poke.ts +138 -0
  48. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  49. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  51. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  52. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  53. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  54. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  56. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  57. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  58. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  59. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  60. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  61. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  62. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  63. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  64. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  66. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  67. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  68. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  69. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  70. package/telegram-plugin/tts-normalize.ts +6 -4
  71. package/telegram-plugin/voice-normalize-text.ts +168 -11
  72. package/telegram-plugin/worker-activity-feed.ts +51 -1
  73. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  74. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  75. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  76. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  77. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  78. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  79. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  81. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  82. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  84. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  85. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  87. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  89. package/vendor/hindsight-memory/settings.json +1 -1
  90. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  91. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -306,3 +306,46 @@ describe('normalizeForTts — review findings (fixpoint decode, metachar, nits)'
306
306
  expect(normalizeForTts('X\Y')).not.toContain('\\')
307
307
  })
308
308
  })
309
+
310
+ describe('voice-out pipeline: normalizeForSpeech → normalizeForTts', () => {
311
+ const speak = (s: string) => normalizeForTts(normalizeForSpeech(s))
312
+
313
+ test('a mixed markdown list survives both passes as paced sentences', () => {
314
+ expect(
315
+ speak(
316
+ '- Check /var/log/syslog\n' +
317
+ '- Cost was $1,000\n' +
318
+ '- Ran at 14:30:46\n' +
319
+ '- Use HTTPS and/or SSH',
320
+ ),
321
+ ).toBe(
322
+ 'Check syslog. Cost was one thousand dollars. Ran at 14:30:46. ' +
323
+ 'Use H T T P S and slash or S S H.',
324
+ )
325
+ })
326
+
327
+ test('the HH:MM:SS guard holds in both normalizers', () => {
328
+ expect(normalizeForTts('Done at 14:30:46 today')).toBe('Done at 14:30:46 today')
329
+ expect(normalizeForSpeech('Done at 14:30:46 today')).toBe(
330
+ 'Done at 14:30:46 today',
331
+ )
332
+ })
333
+
334
+ // `14:30:46` survives even an unguarded scan because `30` is not a legal
335
+ // HH — so on its own it certifies nothing. These are the timestamps whose
336
+ // TAIL is itself a legal HH:MM, i.e. the ones a missing lookbehind
337
+ // actually half-reads ("09:zero o'clock").
338
+ test('an on-the-hour timestamp is not half-read (lookbehind guard)', () => {
339
+ for (const t of ['09:00:00', '12:00:15', '01:02:03']) {
340
+ expect(normalizeForTts(`ran at ${t} ok`)).toBe(`ran at ${t} ok`)
341
+ expect(normalizeForSpeech(`ran at ${t} ok`)).toBe(`ran at ${t} ok`)
342
+ }
343
+ })
344
+
345
+ test('thousands separators are spoken identically by both normalizers', () => {
346
+ expect(normalizeForTts('It costs $1,000')).toBe('It costs one thousand dollars')
347
+ expect(normalizeForSpeech('It costs $1,000')).toBe(
348
+ 'It costs one thousand dollars',
349
+ )
350
+ })
351
+ })
@@ -29,9 +29,11 @@ describe('normalizeForSpeech — reported markdown/symbol cases', () => {
29
29
  expect(normalizeForSpeech('run `npm test` now')).toBe('run npm test now')
30
30
  })
31
31
 
32
- it('strips headings markers', () => {
33
- expect(normalizeForSpeech('# Summary\nAll good')).toBe('Summary All good')
34
- expect(normalizeForSpeech('### Deep heading')).toBe('Deep heading')
32
+ it('strips headings markers and gives the heading a spoken full stop', () => {
33
+ // The heading is its own spoken sentence — without the terminator the
34
+ // newline-collapse ran the heading straight into the body text.
35
+ expect(normalizeForSpeech('# Summary\nAll good')).toBe('Summary. All good')
36
+ expect(normalizeForSpeech('### Deep heading')).toBe('Deep heading.')
35
37
  })
36
38
 
37
39
  it('speaks link text and drops the URL', () => {
@@ -118,6 +120,18 @@ describe('normalizeForSpeech — emoji & pictographs', () => {
118
120
  expect(normalizeForSpeech('nice :rocket: work')).toBe('nice work')
119
121
  })
120
122
 
123
+ // Digit-bodied shortcodes are real. A letter-only body left them in the
124
+ // text AND glued them to the previous word ("Nice:100: work").
125
+ it('drops a DIGIT-bodied :shortcode: without eating the space', () => {
126
+ expect(normalizeForSpeech('Nice :100: work')).toBe('Nice work')
127
+ expect(normalizeForSpeech('a :8ball: b')).toBe('a b')
128
+ expect(normalizeForSpeech('a :1st_place_medal: b')).toBe('a b')
129
+ })
130
+
131
+ it('the digit-bodied form still cannot eat a timestamp colon', () => {
132
+ expect(normalizeForSpeech('Ran at 14:30:46 ok')).toBe('Ran at 14:30:46 ok')
133
+ })
134
+
121
135
  it('leaves ordinary colon usage alone', () => {
122
136
  expect(normalizeForSpeech('note: this is fine')).toBe('note: this is fine')
123
137
  })
@@ -335,3 +349,198 @@ describe('normalizeForSpeech — review findings (fixpoint decode, metachar, nit
335
349
  expect(decodeHtmlEntities('Q&A test')).toBe('Q&A test')
336
350
  })
337
351
  })
352
+
353
+ describe('normalizeForSpeech — list & heading pacing', () => {
354
+ it('speaks a 4-bullet list as 4 separate sentences', () => {
355
+ const out = normalizeForSpeech(
356
+ 'Here are the steps:\n' +
357
+ '- Check the logs\n' +
358
+ '- Restart the container\n' +
359
+ '- Verify health\n' +
360
+ '- Report back',
361
+ )
362
+ expect(out).toBe(
363
+ 'Here are the steps: Check the logs. Restart the container. ' +
364
+ 'Verify health. Report back.',
365
+ )
366
+ // Four sentence-terminated spoken units after the lead-in.
367
+ const units = out
368
+ .split(/(?<=[.:])\s+/)
369
+ .filter((u) => u.trim().length > 0)
370
+ expect(units).toEqual([
371
+ 'Here are the steps:',
372
+ 'Check the logs.',
373
+ 'Restart the container.',
374
+ 'Verify health.',
375
+ 'Report back.',
376
+ ])
377
+ })
378
+
379
+ it('separates ordered-list items into sentences', () => {
380
+ expect(normalizeForSpeech('1. first\n2. second\n3. third')).toBe(
381
+ 'first. second. third.',
382
+ )
383
+ })
384
+
385
+ it('does not double punctuation when the item already ends in . ! or ?', () => {
386
+ const out = normalizeForSpeech('- Done.\n- Really?\n- Ship it!')
387
+ expect(out).toBe('Done. Really? Ship it!')
388
+ expect(out).not.toContain('..')
389
+ expect(out).not.toContain('. .')
390
+ expect(out).not.toContain('?.')
391
+ expect(out).not.toContain('!.')
392
+ })
393
+
394
+ it('terminates the line that introduces a list when it lacks punctuation', () => {
395
+ expect(normalizeForSpeech('Steps\n- one\n- two')).toBe('Steps. one. two.')
396
+ })
397
+
398
+ it('does not chop a wrapped list item mid-clause', () => {
399
+ expect(normalizeForSpeech('- a long item that\ncontinues here\n- second')).toBe(
400
+ 'a long item that continues here. second.',
401
+ )
402
+ })
403
+
404
+ it('separates heading sections into sentences', () => {
405
+ expect(normalizeForSpeech('# One\nbody one\n\n# Two\nbody two')).toBe(
406
+ 'One. body one. Two. body two',
407
+ )
408
+ })
409
+ })
410
+
411
+ describe('normalizeForSpeech — time guard (HH:MM:SS)', () => {
412
+ it('leaves a full HH:MM:SS timestamp as digits rather than half-reading it', () => {
413
+ expect(normalizeForSpeech('Done at 14:30:46 today')).toBe(
414
+ 'Done at 14:30:46 today',
415
+ )
416
+ })
417
+
418
+ // `14:30:46` survives even an unguarded scan because `30` is not a legal
419
+ // HH. These are the timestamps whose TAIL is itself a legal HH:MM — the
420
+ // ones a missing lookbehind half-reads ("09:zero o'clock").
421
+ it('leaves an on-the-hour timestamp alone (lookbehind guard)', () => {
422
+ expect(normalizeForSpeech('t 09:00:00 z')).toBe('t 09:00:00 z')
423
+ expect(normalizeForSpeech('t 12:00:15 z')).toBe('t 12:00:15 z')
424
+ expect(normalizeForSpeech('t 01:02:03 y')).toBe('t 01:02:03 y')
425
+ })
426
+
427
+ it('still speaks a plain HH:MM clock time', () => {
428
+ expect(normalizeForSpeech('Meeting at 14:30 today')).toBe(
429
+ 'Meeting at fourteen thirty today',
430
+ )
431
+ expect(normalizeForSpeech('at 9:05')).toBe("at nine oh five")
432
+ })
433
+ })
434
+
435
+ describe('normalizeForSpeech — thousands separators', () => {
436
+ it('speaks $1,000 as one thousand dollars (not "one dollar,000")', () => {
437
+ expect(normalizeForSpeech('It costs $1,000 up front')).toBe(
438
+ 'It costs one thousand dollars up front',
439
+ )
440
+ })
441
+
442
+ it('speaks a millions-scale amount', () => {
443
+ expect(normalizeForSpeech('Budget $1,234,567 total')).toBe(
444
+ 'Budget one million two hundred thirty-four thousand five hundred ' +
445
+ 'sixty-seven dollars total',
446
+ )
447
+ })
448
+
449
+ it('does not regress plain currency or ordinary comma prose', () => {
450
+ expect(normalizeForSpeech('It costs $500')).toBe('It costs five hundred dollars')
451
+ expect(normalizeForSpeech('$5.50 each')).toBe('five dollars fifty each')
452
+ expect(normalizeForSpeech('a, b, 3')).toBe('a, b, 3')
453
+ expect(normalizeForSpeech('We saw 12,500 requests')).toBe(
454
+ 'We saw 12,500 requests',
455
+ )
456
+ })
457
+
458
+ // The thousands guard must not mistake an ordinary SENTENCE comma for a
459
+ // partial digit group — that left a raw "$" unspoken.
460
+ it('still speaks currency followed by a sentence comma', () => {
461
+ expect(normalizeForSpeech('It costs $500, plus tax')).toBe(
462
+ 'It costs five hundred dollars, plus tax',
463
+ )
464
+ expect(normalizeForSpeech('We paid $1,000, then left')).toBe(
465
+ 'We paid one thousand dollars, then left',
466
+ )
467
+ })
468
+
469
+ it('still bails on a genuinely malformed amount', () => {
470
+ expect(normalizeForSpeech('$1,00 partial')).toBe('$1,00 partial')
471
+ expect(normalizeForSpeech('$5.203 odd')).toBe('$5.203 odd')
472
+ })
473
+ })
474
+
475
+ describe('normalizeForSpeech — file paths', () => {
476
+ it('speaks an absolute path as its last segment', () => {
477
+ expect(normalizeForSpeech('Check /var/log/syslog now')).toBe(
478
+ 'Check syslog now',
479
+ )
480
+ })
481
+
482
+ it('handles ~/ and relative multi-segment paths', () => {
483
+ expect(normalizeForSpeech('Edit ~/.config/nvim/init.lua please')).toBe(
484
+ 'Edit init.lua please',
485
+ )
486
+ expect(normalizeForSpeech('See src/telegram-plugin/gateway.ts')).toBe(
487
+ 'See gateway.ts',
488
+ )
489
+ })
490
+
491
+ it('says "a path" when the final segment is unspeakable noise', () => {
492
+ expect(normalizeForSpeech('Path /tmp/a1b2c3d4e5f6a7b8 there')).toBe(
493
+ 'Path a path there',
494
+ )
495
+ })
496
+
497
+ it('leaves a single-slash word/word pair for the downstream slash pass', () => {
498
+ expect(normalizeForSpeech('and/or maybe')).toBe('and/or maybe')
499
+ })
500
+
501
+ it('does not treat an all-numeric date as a path', () => {
502
+ expect(normalizeForSpeech('the date 12/25/2026 works')).toBe(
503
+ 'the date 12/25/2026 works',
504
+ )
505
+ })
506
+
507
+ // "Two or more slashes ⇒ path" is false in English. Without a path-ish
508
+ // anchor the pass DELETES words from ordinary prose ("yes/no/maybe" →
509
+ // "maybe"). Each of these must survive verbatim for the downstream
510
+ // "word slash word" pass to speak.
511
+ it('never swallows a multi-slash PROSE run (no path anchor)', () => {
512
+ expect(normalizeForSpeech('yes/no/maybe')).toBe('yes/no/maybe')
513
+ expect(normalizeForSpeech('read/write/exec perms')).toBe(
514
+ 'read/write/exec perms',
515
+ )
516
+ expect(normalizeForSpeech('he/she/they pronouns')).toBe(
517
+ 'he/she/they pronouns',
518
+ )
519
+ expect(normalizeForSpeech('a client/server/proxy split')).toBe(
520
+ 'a client/server/proxy split',
521
+ )
522
+ expect(normalizeForSpeech('reading input/output/error')).toBe(
523
+ 'reading input/output/error',
524
+ )
525
+ expect(normalizeForSpeech('tests in unit/integration/e2e are green')).toBe(
526
+ 'tests in unit/integration/e2e are green',
527
+ )
528
+ })
529
+ })
530
+
531
+ describe('normalizeForSpeech — extended acronym set', () => {
532
+ it('spells the newly-added initialisms letter-by-letter', () => {
533
+ expect(normalizeForSpeech('use HTTPS not HTTP')).toBe('use H T T P S not H T T P')
534
+ expect(normalizeForSpeech('over SSH via DNS')).toBe('over S S H via D N S')
535
+ expect(normalizeForSpeech('the CLI on AWS at UTC')).toBe(
536
+ 'the C L I on A W S at U T C',
537
+ )
538
+ expect(normalizeForSpeech('MCP PDF VM LLM YAML RAM USB ID OK')).toBe(
539
+ 'M C P P D F V M L L M Y A M L R A M U S B I D O K',
540
+ )
541
+ })
542
+
543
+ it('still leaves word-style all-caps tokens alone', () => {
544
+ expect(normalizeForSpeech('NASA ALWAYS SHOUTING')).toBe('NASA ALWAYS SHOUTING')
545
+ })
546
+ })
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createWorkerActivityFeed,
4
+ stripRepeatSuffix,
5
+ repeatCountOf,
6
+ type WorkerActivityView,
7
+ type BotApiForWorkerFeed,
8
+ } from '../worker-activity-feed.js'
9
+
10
+ /**
11
+ * A worker whose steps all carry the SAME label must still visibly advance.
12
+ *
13
+ * The bug (observed live 2026-07-25, klanker): `description` is optional on
14
+ * Bash, so a sub-agent that never wrote one produced the constant label
15
+ * "Running a command" for every call. `accumulateNarrative` dropped every
16
+ * repeat as a duplicate, and the card held ONE line at a constant byte length
17
+ * for the whole job — editMessageText kept succeeding, so the surface looked
18
+ * healthy while conveying nothing. A wedged worker and a busy worker rendered
19
+ * identically.
20
+ *
21
+ * The fix counts repeats (`·×N`) instead of discarding them, gated on
22
+ * `view.toolCount` so that a re-emitted UNCHANGED view (the watcher does this
23
+ * every tick) never inflates the count.
24
+ */
25
+
26
+ function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
27
+ return {
28
+ description: 'fix the thing',
29
+ lastTool: { name: 'Bash', sanitisedArg: 'x' },
30
+ toolCount: 1,
31
+ latestSummary: 'Running a command',
32
+ elapsedMs: 10_000,
33
+ state: 'running',
34
+ ...partial,
35
+ }
36
+ }
37
+
38
+ interface FakeBot extends BotApiForWorkerFeed {
39
+ sent: Array<{ text: string }>
40
+ edits: Array<{ text: string }>
41
+ }
42
+
43
+ function makeFakeBot(): FakeBot {
44
+ let nextId = 1000
45
+ const fb: FakeBot = {
46
+ sent: [],
47
+ edits: [],
48
+ sendMessage: async (_chatId, text) => {
49
+ fb.sent.push({ text })
50
+ return { message_id: nextId++ }
51
+ },
52
+ editMessageText: async (_chatId, _messageId, text) => {
53
+ fb.edits.push({ text })
54
+ return {}
55
+ },
56
+ }
57
+ return fb
58
+ }
59
+
60
+ /** Latest rendered body for the group (last edit, else the first paint). */
61
+ function latestBody(bot: FakeBot): string {
62
+ return bot.edits.length > 0 ? bot.edits[bot.edits.length - 1].text : bot.sent[0].text
63
+ }
64
+
65
+ describe('worker feed — repeated step labels', () => {
66
+ it('renders a climbing ·×N counter instead of freezing on one line', async () => {
67
+ const bot = makeFakeBot()
68
+ let clock = 0
69
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
70
+
71
+ for (let i = 1; i <= 5; i++) {
72
+ clock += 10_000
73
+ await feed.update('w1', 'chat', view({ toolCount: i, elapsedMs: clock }))
74
+ }
75
+
76
+ const body = latestBody(bot)
77
+ expect(body).toContain('Running a command ·×5')
78
+ // …and the frozen single-count line is gone from the newest render.
79
+ expect(body).not.toMatch(/Running a command\*{0,2}\s*$/m)
80
+ })
81
+
82
+ it('does NOT inflate the count when the watcher re-emits an unchanged view', async () => {
83
+ const bot = makeFakeBot()
84
+ let clock = 0
85
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
86
+
87
+ // Same toolCount across many ticks = the SAME tool call, re-observed.
88
+ for (let i = 0; i < 6; i++) {
89
+ clock += 5_000
90
+ await feed.update('w1', 'chat', view({ toolCount: 2, elapsedMs: clock }))
91
+ }
92
+
93
+ const body = latestBody(bot)
94
+ expect(body).toContain('Running a command')
95
+ expect(body).not.toContain('·×')
96
+ })
97
+
98
+ it('starts a NEW step (no counter) when the label actually changes', async () => {
99
+ const bot = makeFakeBot()
100
+ let clock = 0
101
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
102
+
103
+ clock += 10_000
104
+ await feed.update('w1', 'chat', view({ toolCount: 1, elapsedMs: clock }))
105
+ clock += 10_000
106
+ await feed.update('w1', 'chat', view({ toolCount: 2, elapsedMs: clock }))
107
+ clock += 10_000
108
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'Running git status', elapsedMs: clock }))
109
+
110
+ const body = latestBody(bot)
111
+ expect(body).toContain('Running a command ·×2')
112
+ expect(body).toContain('Running git status')
113
+ expect(body).not.toContain('Running git status ·×')
114
+ })
115
+
116
+ it('keeps the non-adjacent A,B,A dedup (no duplicate step lines)', async () => {
117
+ const bot = makeFakeBot()
118
+ let clock = 0
119
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
120
+
121
+ const steps = ['Reading alpha.ts', 'Running grep', 'Reading alpha.ts']
122
+ for (const [i, summary] of steps.entries()) {
123
+ clock += 10_000
124
+ await feed.update('w1', 'chat', view({ toolCount: i + 1, latestSummary: summary, elapsedMs: clock }))
125
+ }
126
+
127
+ const body = latestBody(bot)
128
+ expect((body.match(/Reading alpha\.ts/g) ?? []).length).toBe(1)
129
+ // Non-adjacent repeats are dropped, not counted — the A,B,A duplication
130
+ // guard predates this change and stays intact.
131
+ expect(body).not.toContain('Reading alpha.ts ·×')
132
+ })
133
+ })
134
+
135
+ describe('repeat-suffix helpers', () => {
136
+ it('round-trips the marker', () => {
137
+ expect(stripRepeatSuffix('Running a command ·×12')).toBe('Running a command')
138
+ expect(stripRepeatSuffix('Running a command')).toBe('Running a command')
139
+ expect(repeatCountOf('Running a command')).toBe(1)
140
+ expect(repeatCountOf('Running a command ·×12')).toBe(12)
141
+ })
142
+
143
+ it('does not mistake ordinary text for a marker', () => {
144
+ expect(stripRepeatSuffix('Comparing 3 ×2 grids')).toBe('Comparing 3 ×2 grids')
145
+ expect(repeatCountOf('Comparing 3 ×2 grids')).toBe(1)
146
+ })
147
+ })
@@ -292,10 +292,12 @@ export function normalizeForTts(text: string): string {
292
292
  return words.join(' ')
293
293
  })
294
294
 
295
- // -- Times HH:MM (24h) → spoken. The (?!:?\d) guard skips HH:MM:SS
296
- // entirely — a half-spoken time with a dangling ":46" reads worse
297
- // than leaving the digits as-is.
298
- s = s.replace(/\b([01]?\d|2[0-3]):([0-5]\d)(?!:?\d)/g, (_m, hh: string, mm: string) => {
295
+ // -- Times HH:MM (24h) → spoken. The (?<![\d:]) / (?!:?\d) guards skip
296
+ // HH:MM:SS entirely — a half-spoken time with a dangling ":46" reads
297
+ // worse than leaving the digits as-is. The LOOKBEHIND is load-bearing:
298
+ // without it the scan re-anchors INSIDE the timestamp (`09:00:00` →
299
+ // "09:zero o'clock") because the trailing `00` is itself a legal HH:MM.
300
+ s = s.replace(/(?<![\d:])\b([01]?\d|2[0-3]):([0-5]\d)(?!:?\d)/g, (_m, hh: string, mm: string) => {
299
301
  const h = Number(hh)
300
302
  const min = Number(mm)
301
303
  const hw = belowThousand(h)