vellum 0.2.12 → 0.2.14

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 (209) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +171 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +402 -5
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +271 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +28 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +96 -8
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +97 -0
  94. package/src/calls/elevenlabs-config.ts +31 -0
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +50 -6
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +114 -0
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +207 -19
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +26 -2
  116. package/src/config/schema.ts +178 -9
  117. package/src/config/types.ts +3 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/defaults.ts +11 -0
  160. package/src/permissions/prompter.ts +0 -4
  161. package/src/permissions/shell-identity.ts +227 -0
  162. package/src/permissions/trust-store.ts +76 -53
  163. package/src/permissions/types.ts +0 -19
  164. package/src/permissions/workspace-policy.ts +114 -0
  165. package/src/providers/retry.ts +12 -37
  166. package/src/runtime/assistant-event-hub.ts +41 -4
  167. package/src/runtime/channel-approval-parser.ts +60 -0
  168. package/src/runtime/channel-approval-types.ts +71 -0
  169. package/src/runtime/channel-approvals.ts +145 -0
  170. package/src/runtime/gateway-client.ts +16 -0
  171. package/src/runtime/http-server.ts +29 -9
  172. package/src/runtime/routes/call-routes.ts +52 -2
  173. package/src/runtime/routes/channel-routes.ts +296 -16
  174. package/src/runtime/routes/conversation-routes.ts +12 -5
  175. package/src/runtime/routes/events-routes.ts +97 -28
  176. package/src/runtime/routes/run-routes.ts +2 -7
  177. package/src/runtime/run-orchestrator.ts +0 -3
  178. package/src/schedule/recurrence-engine.ts +26 -2
  179. package/src/schedule/recurrence-types.ts +1 -1
  180. package/src/schedule/schedule-store.ts +12 -3
  181. package/src/security/secret-scanner.ts +7 -0
  182. package/src/tasks/ephemeral-permissions.ts +0 -2
  183. package/src/tasks/task-scheduler.ts +2 -1
  184. package/src/tools/calls/call-start.ts +8 -0
  185. package/src/tools/execution-target.ts +21 -0
  186. package/src/tools/execution-timeout.ts +49 -0
  187. package/src/tools/executor.ts +6 -135
  188. package/src/tools/network/web-search.ts +9 -32
  189. package/src/tools/policy-context.ts +29 -0
  190. package/src/tools/schedule/update.ts +8 -1
  191. package/src/tools/terminal/parser.ts +16 -18
  192. package/src/tools/types.ts +4 -11
  193. package/src/twitter/oauth-client.ts +102 -0
  194. package/src/twitter/router.ts +101 -0
  195. package/src/util/debounce.ts +88 -0
  196. package/src/util/network-info.ts +47 -0
  197. package/src/util/platform.ts +29 -4
  198. package/src/util/promise-guard.ts +37 -0
  199. package/src/util/retry.ts +98 -0
  200. package/src/util/truncate.ts +1 -1
  201. package/src/workspace/git-service.ts +129 -112
  202. package/src/tools/contacts/contact-merge.ts +0 -55
  203. package/src/tools/contacts/contact-search.ts +0 -58
  204. package/src/tools/contacts/contact-upsert.ts +0 -64
  205. package/src/tools/playbooks/index.ts +0 -4
  206. package/src/tools/playbooks/playbook-create.ts +0 -96
  207. package/src/tools/playbooks/playbook-delete.ts +0 -52
  208. package/src/tools/playbooks/playbook-list.ts +0 -74
  209. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -8,6 +8,8 @@ let resolverCallCount = 0;
8
8
  let markAskedCalls: string[] = [];
9
9
  let conflictScopeCalls: string[] = [];
10
10
  let memoryEnabled = true;
11
+ let askOnIrrelevantTurns = false;
12
+ let resolveConflictCalls: Array<{ id: string; input: { status: string; resolutionNote?: string | null } }> = [];
11
13
  let pendingConflicts: Array<{
12
14
  id: string;
13
15
  scopeId: string;
@@ -23,6 +25,8 @@ let pendingConflicts: Array<{
23
25
  updatedAt: number;
24
26
  existingStatement: string;
25
27
  candidateStatement: string;
28
+ existingKind: string;
29
+ candidateKind: string;
26
30
  }> = [];
27
31
 
28
32
  let resolverResult: {
@@ -96,6 +100,8 @@ mock.module('../config/loader.js', () => ({
96
100
  reaskCooldownTurns: 3,
97
101
  resolverLlmTimeoutMs: 250,
98
102
  relevanceThreshold: 0.2,
103
+ askOnIrrelevantTurns,
104
+ conflictableKinds: ['preference', 'profile', 'constraint', 'instruction', 'style'],
99
105
  },
100
106
  },
101
107
  }),
@@ -212,6 +218,16 @@ mock.module('../memory/conflict-store.js', () => ({
212
218
  return true;
213
219
  },
214
220
  applyConflictResolution: () => true,
221
+ resolveConflict: (id: string, input: { status: string; resolutionNote?: string | null }) => {
222
+ resolveConflictCalls.push({ id, input });
223
+ // Remove dismissed conflicts so the second listPendingConflictDetails call
224
+ // reflects the dismissal (mirrors real DB behavior).
225
+ if (input.status === 'dismissed') {
226
+ const idx = pendingConflicts.findIndex((c) => c.id === id);
227
+ if (idx !== -1) pendingConflicts.splice(idx, 1);
228
+ }
229
+ return null;
230
+ },
215
231
  }));
216
232
 
217
233
  mock.module('../memory/clarification-resolver.js', () => ({
@@ -249,7 +265,7 @@ mock.module('../agent/loop.js', () => ({
249
265
  }));
250
266
 
251
267
  import { Session, type SessionMemoryPolicy } from '../daemon/session.js';
252
- import { looksLikeClarificationReply } from '../daemon/session-conflict-gate.js';
268
+ import { ConflictGate, looksLikeClarificationReply } from '../daemon/session-conflict-gate.js';
253
269
 
254
270
  function makeSession(memoryPolicy?: SessionMemoryPolicy): Session {
255
271
  const provider = {
@@ -279,7 +295,9 @@ describe('Session conflict soft gate', () => {
279
295
  resolverCallCount = 0;
280
296
  markAskedCalls = [];
281
297
  conflictScopeCalls = [];
298
+ resolveConflictCalls = [];
282
299
  memoryEnabled = true;
300
+ askOnIrrelevantTurns = false;
283
301
  pendingConflicts = [];
284
302
  persistedMessages.length = 0;
285
303
  resolverResult = {
@@ -306,6 +324,8 @@ describe('Session conflict soft gate', () => {
306
324
  updatedAt: 1,
307
325
  existingStatement: 'Use React for frontend work.',
308
326
  candidateStatement: 'Use Vue for frontend work.',
327
+ existingKind: 'preference',
328
+ candidateKind: 'preference',
309
329
  }];
310
330
 
311
331
  const session = makeSession();
@@ -325,9 +345,9 @@ describe('Session conflict soft gate', () => {
325
345
  expect(events.some((event) => event.type === 'message_complete')).toBe(true);
326
346
  });
327
347
 
328
- test('irrelevant unresolved conflict does not inject side-question into normal answer flow', async () => {
348
+ test('irrelevant unresolved conflict does not inject side-question when askOnIrrelevantTurns is false (default)', async () => {
329
349
  pendingConflicts = [{
330
- id: 'conflict-irrelevant',
350
+ id: 'conflict-irrelevant-silent',
331
351
  scopeId: 'default',
332
352
  existingItemId: 'existing-b',
333
353
  candidateItemId: 'candidate-b',
@@ -341,6 +361,8 @@ describe('Session conflict soft gate', () => {
341
361
  updatedAt: 1,
342
362
  existingStatement: 'Use Postgres as the default database.',
343
363
  candidateStatement: 'Use MySQL as the default database.',
364
+ existingKind: 'preference',
365
+ candidateKind: 'preference',
344
366
  }];
345
367
  const session = makeSession();
346
368
  await session.loadFromDb();
@@ -348,17 +370,57 @@ describe('Session conflict soft gate', () => {
348
370
  const events: ServerMessage[] = [];
349
371
  await session.processMessage('How do I set up pre-commit hooks?', [], (event) => events.push(event));
350
372
 
373
+ // Agent loop runs without conflict side-question injection
351
374
  expect(runCalls).toHaveLength(1);
352
375
  const injectedUser = runCalls[0][runCalls[0].length - 1];
353
376
  expect(injectedUser.role).toBe('user');
354
377
  const injectedText = extractText(injectedUser);
355
378
  expect(injectedText).not.toContain('Memory clarification request');
356
- expect(injectedText).not.toContain('Should I assume Postgres or MySQL?');
357
379
  expect(resolverCallCount).toBe(0);
358
380
  expect(markAskedCalls).toEqual([]);
359
381
  expect(events.some((event) => event.type === 'message_complete')).toBe(true);
360
382
  });
361
383
 
384
+ test('irrelevant unresolved conflict injects soft clarification when askOnIrrelevantTurns is explicitly true', async () => {
385
+ askOnIrrelevantTurns = true;
386
+ pendingConflicts = [{
387
+ id: 'conflict-irrelevant',
388
+ scopeId: 'default',
389
+ existingItemId: 'existing-b',
390
+ candidateItemId: 'candidate-b',
391
+ relationship: 'ambiguous_contradiction',
392
+ status: 'pending_clarification',
393
+ clarificationQuestion: 'Should I assume Postgres or MySQL?',
394
+ resolutionNote: null,
395
+ lastAskedAt: null,
396
+ resolvedAt: null,
397
+ createdAt: 1,
398
+ updatedAt: 1,
399
+ existingStatement: 'Use Postgres as the default database.',
400
+ candidateStatement: 'Use MySQL as the default database.',
401
+ existingKind: 'preference',
402
+ candidateKind: 'preference',
403
+ }];
404
+ const session = makeSession();
405
+ await session.loadFromDb();
406
+
407
+ const events: ServerMessage[] = [];
408
+ await session.processMessage('How do I set up pre-commit hooks?', [], (event) => events.push(event));
409
+
410
+ // Agent loop still runs (soft ask, not a hard block)
411
+ expect(runCalls).toHaveLength(1);
412
+ const injectedUser = runCalls[0][runCalls[0].length - 1];
413
+ expect(injectedUser.role).toBe('user');
414
+ const injectedText = extractText(injectedUser);
415
+ // With askOnIrrelevantTurns=true, the irrelevant conflict is soft-injected
416
+ expect(injectedText).toContain('Memory clarification request');
417
+ expect(injectedText).toContain('Should I assume Postgres or MySQL?');
418
+ expect(resolverCallCount).toBe(0);
419
+ // Zero-relevance conflicts are surfaced but not tracked as asked
420
+ expect(markAskedCalls).toEqual([]);
421
+ expect(events.some((event) => event.type === 'message_complete')).toBe(true);
422
+ });
423
+
362
424
  test('recently asked conflicts still resolve directional clarification replies', async () => {
363
425
  pendingConflicts = [{
364
426
  id: 'conflict-followup',
@@ -375,6 +437,8 @@ describe('Session conflict soft gate', () => {
375
437
  updatedAt: 1,
376
438
  existingStatement: 'Use Postgres as the default database.',
377
439
  candidateStatement: 'Use MySQL as the default database.',
440
+ existingKind: 'preference',
441
+ candidateKind: 'preference',
378
442
  }];
379
443
 
380
444
  const session = makeSession();
@@ -416,6 +480,8 @@ describe('Session conflict soft gate', () => {
416
480
  updatedAt: 1,
417
481
  existingStatement: 'Use Postgres as the default database.',
418
482
  candidateStatement: 'Use MySQL as the default database.',
483
+ existingKind: 'preference',
484
+ candidateKind: 'preference',
419
485
  }];
420
486
 
421
487
  const session = makeSession();
@@ -456,6 +522,8 @@ describe('Session conflict soft gate', () => {
456
522
  updatedAt: 1,
457
523
  existingStatement: 'Use Postgres as the default database.',
458
524
  candidateStatement: 'Use MySQL as the default database.',
525
+ existingKind: 'preference',
526
+ candidateKind: 'preference',
459
527
  }];
460
528
 
461
529
  const session = makeSession();
@@ -498,6 +566,8 @@ describe('Session conflict soft gate', () => {
498
566
  updatedAt: 1,
499
567
  existingStatement: 'Use Postgres as the default database.',
500
568
  candidateStatement: 'Use MySQL as the default database.',
569
+ existingKind: 'preference',
570
+ candidateKind: 'preference',
501
571
  }];
502
572
 
503
573
  const session = makeSession();
@@ -523,9 +593,9 @@ describe('Session conflict soft gate', () => {
523
593
  expect(runCalls).toHaveLength(1);
524
594
  });
525
595
 
526
- test('irrelevant conflicts remain silent across subsequent turns', async () => {
596
+ test('irrelevant conflicts remain silent across subsequent turns when askOnIrrelevantTurns is false (default)', async () => {
527
597
  pendingConflicts = [{
528
- id: 'conflict-cooldown',
598
+ id: 'conflict-silent-multi',
529
599
  scopeId: 'default',
530
600
  existingItemId: 'existing-c',
531
601
  candidateItemId: 'candidate-c',
@@ -539,6 +609,8 @@ describe('Session conflict soft gate', () => {
539
609
  updatedAt: 1,
540
610
  existingStatement: 'Use pnpm for workspace installs.',
541
611
  candidateStatement: 'Use npm for workspace installs.',
612
+ existingKind: 'preference',
613
+ candidateKind: 'preference',
542
614
  }];
543
615
 
544
616
  const session = makeSession();
@@ -550,11 +622,52 @@ describe('Session conflict soft gate', () => {
550
622
  expect(runCalls).toHaveLength(2);
551
623
  const firstUserText = extractText(runCalls[0][runCalls[0].length - 1]);
552
624
  const secondUserText = extractText(runCalls[1][runCalls[1].length - 1]);
625
+ // Both turns: no soft injection because askOnIrrelevantTurns=false
553
626
  expect(firstUserText).not.toContain('Memory clarification request');
554
627
  expect(secondUserText).not.toContain('Memory clarification request');
555
628
  expect(markAskedCalls).toEqual([]);
556
629
  });
557
630
 
631
+ test('zero-relevance conflict is soft-asked on every turn (not tracked) when askOnIrrelevantTurns is explicitly true', async () => {
632
+ askOnIrrelevantTurns = true;
633
+ pendingConflicts = [{
634
+ id: 'conflict-cooldown',
635
+ scopeId: 'default',
636
+ existingItemId: 'existing-c',
637
+ candidateItemId: 'candidate-c',
638
+ relationship: 'ambiguous_contradiction',
639
+ status: 'pending_clarification',
640
+ clarificationQuestion: 'Should I use pnpm or npm?',
641
+ resolutionNote: null,
642
+ lastAskedAt: null,
643
+ resolvedAt: null,
644
+ createdAt: 1,
645
+ updatedAt: 1,
646
+ existingStatement: 'Use pnpm for workspace installs.',
647
+ candidateStatement: 'Use npm for workspace installs.',
648
+ existingKind: 'preference',
649
+ candidateKind: 'preference',
650
+ }];
651
+
652
+ const session = makeSession();
653
+ await session.loadFromDb();
654
+
655
+ await session.processMessage('How should I structure my repo?', [], () => {});
656
+ await session.processMessage('What branch naming should I use?', [], () => {});
657
+
658
+ expect(runCalls).toHaveLength(2);
659
+ const firstUserText = extractText(runCalls[0][runCalls[0].length - 1]);
660
+ const secondUserText = extractText(runCalls[1][runCalls[1].length - 1]);
661
+ // First turn: askOnIrrelevantTurns=true causes soft injection
662
+ expect(firstUserText).toContain('Memory clarification request');
663
+ // Second turn: cooldown prevents re-asking (but since relevance is 0,
664
+ // the first ask was not tracked, so cooldown doesn't apply — the conflict
665
+ // is surfaced again on the second turn too)
666
+ expect(secondUserText).toContain('Memory clarification request');
667
+ // Zero-relevance conflicts are never tracked as asked
668
+ expect(markAskedCalls).toEqual([]);
669
+ });
670
+
558
671
  test('passes session scopeId through to conflict store queries', async () => {
559
672
  pendingConflicts = [{
560
673
  id: 'conflict-scoped',
@@ -571,6 +684,8 @@ describe('Session conflict soft gate', () => {
571
684
  updatedAt: 1,
572
685
  existingStatement: 'Use tabs for indentation.',
573
686
  candidateStatement: 'Use spaces for indentation.',
687
+ existingKind: 'preference',
688
+ candidateKind: 'preference',
574
689
  }];
575
690
 
576
691
  const session = makeSession({
@@ -618,6 +733,8 @@ describe('Session conflict soft gate', () => {
618
733
  updatedAt: 1,
619
734
  existingStatement: 'Use React for frontend work.',
620
735
  candidateStatement: 'Use Vue for frontend work.',
736
+ existingKind: 'preference',
737
+ candidateKind: 'preference',
621
738
  }];
622
739
 
623
740
  const session = makeSession();
@@ -631,6 +748,117 @@ describe('Session conflict soft gate', () => {
631
748
  expect(resolverCallCount).toBe(0);
632
749
  expect(markAskedCalls).toEqual([]);
633
750
  });
751
+
752
+ test('pending transient conflict is dismissed and not asked', async () => {
753
+ pendingConflicts = [{
754
+ id: 'conflict-transient',
755
+ scopeId: 'default',
756
+ existingItemId: 'existing-transient',
757
+ candidateItemId: 'candidate-transient',
758
+ relationship: 'ambiguous_contradiction',
759
+ status: 'pending_clarification',
760
+ clarificationQuestion: 'Which PR should we track?',
761
+ resolutionNote: null,
762
+ lastAskedAt: null,
763
+ resolvedAt: null,
764
+ createdAt: 1,
765
+ updatedAt: 1,
766
+ existingStatement: 'Track PR #5526 for review.',
767
+ candidateStatement: 'Track PR #5525 for review.',
768
+ existingKind: 'instruction',
769
+ candidateKind: 'instruction',
770
+ }];
771
+
772
+ const session = makeSession();
773
+ await session.loadFromDb();
774
+
775
+ const events: ServerMessage[] = [];
776
+ await session.processMessage('Check latest PRs', [], (event) => events.push(event));
777
+
778
+ // Should run normal agent loop, no clarification asked
779
+ expect(runCalls).toHaveLength(1);
780
+ expect(markAskedCalls).toEqual([]);
781
+ // The conflict should have been dismissed
782
+ expect(resolveConflictCalls).toEqual([{
783
+ id: 'conflict-transient',
784
+ input: {
785
+ status: 'dismissed',
786
+ resolutionNote: 'Dismissed by conflict policy (transient/non-durable).',
787
+ },
788
+ }]);
789
+ });
790
+
791
+ test('incoherent conflict (zero statement overlap) is dismissed', async () => {
792
+ pendingConflicts = [{
793
+ id: 'conflict-incoherent',
794
+ scopeId: 'default',
795
+ existingItemId: 'existing-incoherent',
796
+ candidateItemId: 'candidate-incoherent',
797
+ relationship: 'ambiguous_contradiction',
798
+ status: 'pending_clarification',
799
+ clarificationQuestion: 'I have conflicting notes: "The default model for the summarize CLI is google/gemini-3-flash-preview" vs "User\'s favorite color is blue." Which one is correct?',
800
+ resolutionNote: null,
801
+ lastAskedAt: null,
802
+ resolvedAt: null,
803
+ createdAt: 1,
804
+ updatedAt: 1,
805
+ existingStatement: 'The default model for the summarize CLI is google/gemini-3-flash-preview.',
806
+ candidateStatement: "User's favorite color is blue.",
807
+ existingKind: 'preference',
808
+ candidateKind: 'preference',
809
+ }];
810
+
811
+ const session = makeSession();
812
+ await session.loadFromDb();
813
+
814
+ const events: ServerMessage[] = [];
815
+ await session.processMessage('my favorite color is white', [], (event) => events.push(event));
816
+
817
+ // Should run normal agent loop, no clarification asked
818
+ expect(runCalls).toHaveLength(1);
819
+ expect(markAskedCalls).toEqual([]);
820
+ // The conflict should have been dismissed as incoherent
821
+ expect(resolveConflictCalls).toEqual([{
822
+ id: 'conflict-incoherent',
823
+ input: {
824
+ status: 'dismissed',
825
+ resolutionNote: 'Dismissed by conflict policy (incoherent — zero statement overlap).',
826
+ },
827
+ }]);
828
+ });
829
+
830
+ test('pending durable preference conflict still follows normal flow', async () => {
831
+ pendingConflicts = [{
832
+ id: 'conflict-durable',
833
+ scopeId: 'default',
834
+ existingItemId: 'existing-durable',
835
+ candidateItemId: 'candidate-durable',
836
+ relationship: 'ambiguous_contradiction',
837
+ status: 'pending_clarification',
838
+ clarificationQuestion: 'Do you want React or Vue?',
839
+ resolutionNote: null,
840
+ lastAskedAt: null,
841
+ resolvedAt: null,
842
+ createdAt: 1,
843
+ updatedAt: 1,
844
+ existingStatement: 'Use React for frontend work.',
845
+ candidateStatement: 'Use Vue for frontend work.',
846
+ existingKind: 'preference',
847
+ candidateKind: 'preference',
848
+ }];
849
+
850
+ const session = makeSession();
851
+ await session.loadFromDb();
852
+
853
+ const events: ServerMessage[] = [];
854
+ await session.processMessage('Should I use React or Vue?', [], (event) => events.push(event));
855
+
856
+ // Should ask clarification for relevant durable conflict
857
+ expect(runCalls).toHaveLength(0);
858
+ expect(markAskedCalls).toEqual(['conflict-durable']);
859
+ // No dismissal should have happened
860
+ expect(resolveConflictCalls).toEqual([]);
861
+ });
634
862
  });
635
863
 
636
864
  describe('looksLikeClarificationReply', () => {
@@ -698,3 +926,211 @@ describe('looksLikeClarificationReply', () => {
698
926
  expect(looksLikeClarificationReply('sounds good')).toBe(false);
699
927
  });
700
928
  });
929
+
930
+ describe('ConflictGate askOnIrrelevantTurns knob', () => {
931
+
932
+ const baseConfig = {
933
+ enabled: true,
934
+ gateMode: 'soft' as const,
935
+ relevanceThreshold: 0.2,
936
+ reaskCooldownTurns: 3,
937
+ resolverLlmTimeoutMs: 250,
938
+ conflictableKinds: ['preference', 'profile', 'constraint', 'instruction', 'style'] as readonly string[],
939
+ };
940
+
941
+ beforeEach(() => {
942
+ markAskedCalls = [];
943
+ pendingConflicts = [];
944
+ resolveConflictCalls = [];
945
+ resolverCallCount = 0;
946
+ resolverResult = {
947
+ resolution: 'still_unclear',
948
+ strategy: 'heuristic',
949
+ resolvedStatement: null,
950
+ explanation: 'Need user clarification.',
951
+ };
952
+ });
953
+
954
+ test('with askOnIrrelevantTurns=false, irrelevant conflict is not asked', async () => {
955
+ pendingConflicts = [{
956
+ id: 'conflict-irrel-false',
957
+ scopeId: 'default',
958
+ existingItemId: 'existing-irrel',
959
+ candidateItemId: 'candidate-irrel',
960
+ relationship: 'ambiguous_contradiction',
961
+ status: 'pending_clarification',
962
+ clarificationQuestion: 'Should I assume Postgres or MySQL?',
963
+ resolutionNote: null,
964
+ lastAskedAt: null,
965
+ resolvedAt: null,
966
+ createdAt: 1,
967
+ updatedAt: 1,
968
+ existingStatement: 'Use Postgres as the default database.',
969
+ candidateStatement: 'Use MySQL as the default database.',
970
+ existingKind: 'preference',
971
+ candidateKind: 'preference',
972
+ }];
973
+
974
+ const gate = new ConflictGate();
975
+ const result = await gate.evaluate(
976
+ 'How do I set up pre-commit hooks?',
977
+ { ...baseConfig, askOnIrrelevantTurns: false },
978
+ );
979
+
980
+ expect(result).toBeNull();
981
+ expect(markAskedCalls).toEqual([]);
982
+ });
983
+
984
+ test('with askOnIrrelevantTurns=true, irrelevant conflict is asked as non-relevant', async () => {
985
+ pendingConflicts = [{
986
+ id: 'conflict-irrel-true',
987
+ scopeId: 'default',
988
+ existingItemId: 'existing-irrel2',
989
+ candidateItemId: 'candidate-irrel2',
990
+ relationship: 'ambiguous_contradiction',
991
+ status: 'pending_clarification',
992
+ clarificationQuestion: 'Should I assume Postgres or MySQL?',
993
+ resolutionNote: null,
994
+ lastAskedAt: null,
995
+ resolvedAt: null,
996
+ createdAt: 1,
997
+ updatedAt: 1,
998
+ existingStatement: 'Use Postgres as the default database.',
999
+ candidateStatement: 'Use MySQL as the default database.',
1000
+ existingKind: 'preference',
1001
+ candidateKind: 'preference',
1002
+ }];
1003
+
1004
+ const gate = new ConflictGate();
1005
+ const result = await gate.evaluate(
1006
+ 'How do I set up pre-commit hooks?',
1007
+ { ...baseConfig, askOnIrrelevantTurns: true },
1008
+ );
1009
+
1010
+ expect(result).not.toBeNull();
1011
+ expect(result!.relevant).toBe(false);
1012
+ expect(result!.question).toContain('Postgres or MySQL');
1013
+ // Zero-relevance conflicts are surfaced but not tracked as asked
1014
+ expect(markAskedCalls).toEqual([]);
1015
+ });
1016
+
1017
+ test('zero-relevance conflict asked via askOnIrrelevantTurns does not cause wasRecentlyAsked on next turn', async () => {
1018
+ pendingConflicts = [{
1019
+ id: 'conflict-zero-rel',
1020
+ scopeId: 'default',
1021
+ existingItemId: 'existing-zero',
1022
+ candidateItemId: 'candidate-zero',
1023
+ relationship: 'ambiguous_contradiction',
1024
+ status: 'pending_clarification',
1025
+ clarificationQuestion: 'Should I assume Postgres or MySQL?',
1026
+ resolutionNote: null,
1027
+ lastAskedAt: null,
1028
+ resolvedAt: null,
1029
+ createdAt: 1,
1030
+ updatedAt: 1,
1031
+ existingStatement: 'Use Postgres as the default database.',
1032
+ candidateStatement: 'Use MySQL as the default database.',
1033
+ existingKind: 'preference',
1034
+ candidateKind: 'preference',
1035
+ }];
1036
+
1037
+ const gate = new ConflictGate();
1038
+
1039
+ // First turn: zero-relevance conflict is surfaced via askOnIrrelevantTurns
1040
+ const result1 = await gate.evaluate(
1041
+ 'How do I set up pre-commit hooks?',
1042
+ { ...baseConfig, askOnIrrelevantTurns: true },
1043
+ );
1044
+ expect(result1).not.toBeNull();
1045
+ expect(result1!.relevant).toBe(false);
1046
+ // Not tracked as asked because relevance is 0
1047
+ expect(markAskedCalls).toEqual([]);
1048
+
1049
+ // Second turn: an unrelated short imperative that looks like a clarification reply.
1050
+ // If the zero-relevance conflict had been tracked, wasRecentlyAsked would return
1051
+ // true and shouldAttemptConflictResolution would try to resolve it — which is wrong.
1052
+ // Since we don't track zero-relevance asks, the resolver should NOT be called.
1053
+ const result2 = await gate.evaluate(
1054
+ 'keep it',
1055
+ { ...baseConfig, askOnIrrelevantTurns: false },
1056
+ );
1057
+
1058
+ // The conflict should not have been resolved by the resolver
1059
+ expect(resolverCallCount).toBe(0);
1060
+ // With askOnIrrelevantTurns=false and the conflict being irrelevant, result is null
1061
+ expect(result2).toBeNull();
1062
+ });
1063
+
1064
+ test('zero-relevance conflict on primary askable path (relevanceThreshold=0) is tracked as asked', async () => {
1065
+ pendingConflicts = [{
1066
+ id: 'conflict-zero-threshold',
1067
+ scopeId: 'default',
1068
+ existingItemId: 'existing-zt',
1069
+ candidateItemId: 'candidate-zt',
1070
+ relationship: 'ambiguous_contradiction',
1071
+ status: 'pending_clarification',
1072
+ clarificationQuestion: 'Should I assume Postgres or MySQL?',
1073
+ resolutionNote: null,
1074
+ lastAskedAt: null,
1075
+ resolvedAt: null,
1076
+ createdAt: 1,
1077
+ updatedAt: 1,
1078
+ existingStatement: 'Use Postgres as the default database.',
1079
+ candidateStatement: 'Use MySQL as the default database.',
1080
+ existingKind: 'preference',
1081
+ candidateKind: 'preference',
1082
+ }];
1083
+
1084
+ const gate = new ConflictGate();
1085
+ // relevanceThreshold=0 means zero-relevance conflicts pass the primary askable filter
1086
+ const result1 = await gate.evaluate(
1087
+ 'How do I set up pre-commit hooks?',
1088
+ { ...baseConfig, relevanceThreshold: 0, askOnIrrelevantTurns: false },
1089
+ );
1090
+
1091
+ expect(result1).not.toBeNull();
1092
+ expect(result1!.relevant).toBe(true);
1093
+ // Should be tracked as asked since it came through the primary askable path
1094
+ expect(markAskedCalls).toEqual(['conflict-zero-threshold']);
1095
+
1096
+ // Second turn within cooldown: the conflict should NOT be re-asked
1097
+ const result2 = await gate.evaluate(
1098
+ 'Another unrelated question',
1099
+ { ...baseConfig, relevanceThreshold: 0, askOnIrrelevantTurns: false },
1100
+ );
1101
+ expect(result2).toBeNull();
1102
+ });
1103
+
1104
+ test('relevant conflict is asked regardless of askOnIrrelevantTurns value', async () => {
1105
+ pendingConflicts = [{
1106
+ id: 'conflict-rel-knob',
1107
+ scopeId: 'default',
1108
+ existingItemId: 'existing-rel',
1109
+ candidateItemId: 'candidate-rel',
1110
+ relationship: 'ambiguous_contradiction',
1111
+ status: 'pending_clarification',
1112
+ clarificationQuestion: 'Do you want React or Vue for frontend work?',
1113
+ resolutionNote: null,
1114
+ lastAskedAt: null,
1115
+ resolvedAt: null,
1116
+ createdAt: 1,
1117
+ updatedAt: 1,
1118
+ existingStatement: 'Use React for frontend work.',
1119
+ candidateStatement: 'Use Vue for frontend work.',
1120
+ existingKind: 'preference',
1121
+ candidateKind: 'preference',
1122
+ }];
1123
+
1124
+ // Test with askOnIrrelevantTurns=false — relevant conflicts should still be asked
1125
+ const gate = new ConflictGate();
1126
+ const result = await gate.evaluate(
1127
+ 'Should I use React or Vue here?',
1128
+ { ...baseConfig, askOnIrrelevantTurns: false },
1129
+ );
1130
+
1131
+ expect(result).not.toBeNull();
1132
+ expect(result!.relevant).toBe(true);
1133
+ expect(result!.question).toContain('React or Vue');
1134
+ expect(markAskedCalls).toEqual(['conflict-rel-knob']);
1135
+ });
1136
+ });
@@ -188,6 +188,9 @@ mock.module('../calls/call-state.js', () => ({
188
188
  registerCallQuestionNotifier: () => {},
189
189
  unregisterCallQuestionNotifier: () => {},
190
190
  fireCallQuestionNotifier: () => {},
191
+ registerCallTranscriptNotifier: () => {},
192
+ unregisterCallTranscriptNotifier: () => {},
193
+ fireCallTranscriptNotifier: () => {},
191
194
  registerCallCompletionNotifier: () => {},
192
195
  unregisterCallCompletionNotifier: () => {},
193
196
  fireCallCompletionNotifier: () => {},