switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -49,9 +49,11 @@ function setup(): Capture {
49
49
  }
50
50
 
51
51
  async function flush(): Promise<void> {
52
- // Allow the fire-and-forget promise chain in tick() to settle.
53
- await Promise.resolve()
54
- await Promise.resolve()
52
+ // Allow the fire-and-forget promise chain in tick() to settle. The chain
53
+ // is `.then(editMessage).then(emitMetric).catch(...)` with an async
54
+ // editMessage, so it spans several microtask hops — a single macrotask
55
+ // boundary (setTimeout 0) drains them all deterministically.
56
+ await new Promise((r) => setTimeout(r, 0))
55
57
  }
56
58
 
57
59
  describe('pending-work-progress', () => {
@@ -558,4 +560,115 @@ describe('pending-work-progress', () => {
558
560
  await flush()
559
561
  expect(cap.edits).toHaveLength(1)
560
562
  })
563
+
564
+ // ─── review finding #2: transport-hiccup classification ────────────────────
565
+ // A best-effort liveness surface must not log a scary "edit failed" warning
566
+ // for recoverable/permanent transport outcomes, and must stop retrying a
567
+ // dead anchor. "not modified" counts as success; "gone" clears state; 429 /
568
+ // transient leave state for the next tick — all silently.
569
+ it('#2: "message is not modified" counts as a successful edit (no warning, metric emitted)', async () => {
570
+ const cap = setup()
571
+ const errs: string[] = []
572
+ const origStderr = process.stderr.write.bind(process.stderr)
573
+ process.stderr.write = ((s: string) => { errs.push(s); return true }) as typeof process.stderr.write
574
+ try {
575
+ let calls = 0
576
+ __setDepsForTests({
577
+ editMessage: async () => {
578
+ calls++
579
+ if (calls === 1) throw new Error('Bad Request: message is not modified')
580
+ cap.edits.push({} as PendingProgressEditCtx)
581
+ },
582
+ emitMetric: (e) => cap.metrics.push(e),
583
+ nowMs: () => cap.now,
584
+ })
585
+ startTurn(KEY)
586
+ noteAsyncDispatch(KEY)
587
+ noteOutbound(KEY, { messageId: 100, text: 'working' })
588
+ noteTurnEnd(KEY)
589
+
590
+ cap.now = EDIT_INTERVAL_MS
591
+ __tickForTests(cap.now)
592
+ // The .catch hop needs an extra microtask drain beyond the 2-await flush.
593
+ await flush()
594
+ await Promise.resolve()
595
+ // The not-modified outcome is success — an `edited` metric was emitted.
596
+ expect(cap.metrics.some((m) => m.kind === 'pending_progress_edited')).toBe(true)
597
+ // No scary "edit failed" stderr for a transport-class outcome.
598
+ expect(errs.some((e) => e.includes('edit failed'))).toBe(false)
599
+ } finally {
600
+ process.stderr.write = origStderr
601
+ }
602
+ })
603
+
604
+ it('#2: a gone anchor (message not found) clears state so the tick stops retrying it, silently', async () => {
605
+ const cap = setup()
606
+ const errs: string[] = []
607
+ const origStderr = process.stderr.write.bind(process.stderr)
608
+ process.stderr.write = ((s: string) => { errs.push(s); return true }) as typeof process.stderr.write
609
+ try {
610
+ let calls = 0
611
+ __setDepsForTests({
612
+ editMessage: async () => {
613
+ calls++
614
+ throw new Error('Bad Request: message to edit not found')
615
+ },
616
+ emitMetric: (e) => cap.metrics.push(e),
617
+ nowMs: () => cap.now,
618
+ })
619
+ startTurn(KEY)
620
+ noteAsyncDispatch(KEY)
621
+ noteOutbound(KEY, { messageId: 100, text: 'working' })
622
+ noteTurnEnd(KEY)
623
+
624
+ cap.now = EDIT_INTERVAL_MS
625
+ __tickForTests(cap.now)
626
+ await flush()
627
+ await Promise.resolve()
628
+ expect(calls).toBe(1)
629
+ // No warning for a transport-gone outcome.
630
+ expect(errs.some((e) => e.includes('edit failed'))).toBe(false)
631
+
632
+ // Next tick — the anchor was cleared, so no retry against the dead id.
633
+ cap.now = EDIT_INTERVAL_MS * 3
634
+ __tickForTests(cap.now)
635
+ await flush()
636
+ await Promise.resolve()
637
+ expect(calls).toBe(1) // no second attempt
638
+ } finally {
639
+ process.stderr.write = origStderr
640
+ }
641
+ })
642
+
643
+ it('#2: a 429 / transient error leaves state intact for the next tick, silently', async () => {
644
+ const cap = setup()
645
+ const errs: string[] = []
646
+ const origStderr = process.stderr.write.bind(process.stderr)
647
+ process.stderr.write = ((s: string) => { errs.push(s); return true }) as typeof process.stderr.write
648
+ try {
649
+ let calls = 0
650
+ __setDepsForTests({
651
+ editMessage: async () => {
652
+ calls++
653
+ throw { error_code: 429, parameters: { retry_after: 1 }, message: 'Too Many Requests' }
654
+ },
655
+ emitMetric: (e) => cap.metrics.push(e),
656
+ nowMs: () => cap.now,
657
+ })
658
+ startTurn(KEY)
659
+ noteAsyncDispatch(KEY)
660
+ noteOutbound(KEY, { messageId: 100, text: 'working' })
661
+ noteTurnEnd(KEY)
662
+
663
+ cap.now = EDIT_INTERVAL_MS
664
+ __tickForTests(cap.now)
665
+ await flush()
666
+ await Promise.resolve()
667
+ // State survives — the anchor is still there to retry next tick.
668
+ expect(__getStateForTests(KEY)?.anchorMessageId).toBe(100)
669
+ expect(errs.some((e) => e.includes('edit failed'))).toBe(false)
670
+ } finally {
671
+ process.stderr.write = origStderr
672
+ }
673
+ })
561
674
  })
@@ -178,6 +178,89 @@ describe('synthesizeAllowRuleDiff — structural cases', () => {
178
178
  }
179
179
  })
180
180
 
181
+ it('(a2) multi-line flow list (clerk\'s exact shape, #2973): appends before the closing ]', () => {
182
+ // Mirrors switchroom.yaml:507-514 — `allow:` has an empty inline
183
+ // remainder, `[` opens on the NEXT line, one entry per line, `]`
184
+ // closes on its own line with no trailing comma on the last entry.
185
+ const cfg = cfgWith(
186
+ [
187
+ ' clerk:',
188
+ ' topic_name: clerk',
189
+ ' purpose: clerk',
190
+ ' tools:',
191
+ ' allow:',
192
+ ' [',
193
+ ' all,',
194
+ ' mcp__ms-365__*,',
195
+ ' mcp__ms-365__verify-login,',
196
+ ' mcp__ms-365__list-mail-messages',
197
+ ' ]',
198
+ ' model: opus',
199
+ ].join('\n'),
200
+ )
201
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Skill(calendar)', configText: cfg })
202
+ expect(diff).not.toBeNull()
203
+ expect(diff).toContain('--- a/switchroom.yaml')
204
+ expect(diff).toContain('+++ b/switchroom.yaml')
205
+ const res = applyViaValidator(cfg, diff!)
206
+ expect(res).toMatchObject({ ok: true })
207
+ if (res.ok) {
208
+ expect(allowListFor(res.postApplyContent, 'clerk')).toEqual([
209
+ 'all',
210
+ 'mcp__ms-365__*',
211
+ 'mcp__ms-365__verify-login',
212
+ 'mcp__ms-365__list-mail-messages',
213
+ 'Skill(calendar)',
214
+ ])
215
+ }
216
+ })
217
+
218
+ it('(a2) multi-line flow list: last entry already comma-terminated', () => {
219
+ const cfg = cfgWith(
220
+ [
221
+ ' clerk:',
222
+ ' topic_name: clerk',
223
+ ' purpose: clerk',
224
+ ' tools:',
225
+ ' allow:',
226
+ ' [',
227
+ ' Read,',
228
+ ' Grep,',
229
+ ' ]',
230
+ ' model: opus',
231
+ ].join('\n'),
232
+ )
233
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Bash', configText: cfg })
234
+ expect(diff).not.toBeNull()
235
+ const res = applyViaValidator(cfg, diff!)
236
+ expect(res).toMatchObject({ ok: true })
237
+ if (res.ok) {
238
+ expect(allowListFor(res.postApplyContent, 'clerk')).toEqual(['Read', 'Grep', 'Bash'])
239
+ }
240
+ })
241
+
242
+ it('(a2) multi-line flow list: empty list across lines gets first entry', () => {
243
+ const cfg = cfgWith(
244
+ [
245
+ ' clerk:',
246
+ ' topic_name: clerk',
247
+ ' purpose: clerk',
248
+ ' tools:',
249
+ ' allow:',
250
+ ' [',
251
+ ' ]',
252
+ ' model: opus',
253
+ ].join('\n'),
254
+ )
255
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Bash', configText: cfg })
256
+ expect(diff).not.toBeNull()
257
+ const res = applyViaValidator(cfg, diff!)
258
+ expect(res).toMatchObject({ ok: true })
259
+ if (res.ok) {
260
+ expect(allowListFor(res.postApplyContent, 'clerk')).toEqual(['Bash'])
261
+ }
262
+ })
263
+
181
264
  it('multi-agent: only the target agent is touched', () => {
182
265
  const cfg = cfgWith(
183
266
  [
@@ -250,6 +333,34 @@ describe('extractAddedAllowRule — round-trips the synthesized diff', () => {
250
333
  expect(extractAddedAllowRule(diff)).toBe('Skill(mail)')
251
334
  })
252
335
 
336
+ it('multi-line flow-list append (clerk\'s exact shape, #2973)', () => {
337
+ const cfg = cfgWith(
338
+ [
339
+ ' clerk:',
340
+ ' topic_name: clerk',
341
+ ' purpose: clerk',
342
+ ' tools:',
343
+ ' allow:',
344
+ ' [',
345
+ ' all,',
346
+ ' mcp__ms-365__verify-login',
347
+ ' ]',
348
+ ' model: opus',
349
+ ].join('\n'),
350
+ )
351
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Skill(calendar)', configText: cfg })!
352
+ expect(extractAddedAllowRule(diff)).toBe('Skill(calendar)')
353
+ })
354
+
355
+ it('multi-line flow-list first-entry insert (empty list across lines)', () => {
356
+ const cfg = cfgWith(
357
+ [' clerk:', ' topic_name: clerk', ' purpose: clerk',
358
+ ' tools:', ' allow:', ' [', ' ]', ' model: opus'].join('\n'),
359
+ )
360
+ const diff = synthesizeAllowRuleDiff({ agentName: 'clerk', rule: 'Bash', configText: cfg })!
361
+ expect(extractAddedAllowRule(diff)).toBe('Bash')
362
+ })
363
+
253
364
  it('absent tools.allow (case c) — extracts the single added rule', () => {
254
365
  const cfg = cfgWith([' carrie:', ' topic_name: carrie',
255
366
  ' purpose: carrie', ' model: sonnet', ' role: assistant'].join('\n'))