vellum 0.2.0 → 0.2.2

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 (361) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +5 -2
  3. package/package.json +4 -2
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +161 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/app-bundler.test.ts +12 -33
  11. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  12. package/src/__tests__/asset-search-tool.test.ts +23 -22
  13. package/src/__tests__/attachments-store.test.ts +56 -127
  14. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  15. package/src/__tests__/browser-skill-endstate.test.ts +5 -8
  16. package/src/__tests__/call-bridge.test.ts +385 -0
  17. package/src/__tests__/call-constants.test.ts +40 -0
  18. package/src/__tests__/call-orchestrator.test.ts +454 -0
  19. package/src/__tests__/call-recovery.test.ts +518 -0
  20. package/src/__tests__/call-routes-http.test.ts +459 -0
  21. package/src/__tests__/call-state-machine.test.ts +143 -0
  22. package/src/__tests__/call-state.test.ts +133 -0
  23. package/src/__tests__/call-store.test.ts +691 -0
  24. package/src/__tests__/cli-discover.test.ts +1 -1
  25. package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
  26. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  27. package/src/__tests__/computer-use-tools.test.ts +250 -0
  28. package/src/__tests__/config-schema.test.ts +348 -3
  29. package/src/__tests__/conflict-store.test.ts +2 -1
  30. package/src/__tests__/contacts-tools.test.ts +331 -0
  31. package/src/__tests__/conversation-store.test.ts +30 -32
  32. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  33. package/src/__tests__/date-context.test.ts +373 -0
  34. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  35. package/src/__tests__/doordash-session.test.ts +9 -0
  36. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  37. package/src/__tests__/followup-tools.test.ts +303 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  39. package/src/__tests__/intent-routing.test.ts +64 -57
  40. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  41. package/src/__tests__/ipc-snapshot.test.ts +96 -28
  42. package/src/__tests__/llm-usage-store.test.ts +3 -8
  43. package/src/__tests__/media-generate-image.test.ts +1 -1
  44. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  45. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  46. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  47. package/src/__tests__/playbook-tools.test.ts +342 -0
  48. package/src/__tests__/profile-compiler.test.ts +2 -1
  49. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  50. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  51. package/src/__tests__/recurrence-engine.test.ts +69 -0
  52. package/src/__tests__/recurrence-types.test.ts +71 -0
  53. package/src/__tests__/registry.test.ts +17 -10
  54. package/src/__tests__/relay-server.test.ts +633 -0
  55. package/src/__tests__/reminder-store.test.ts +6 -3
  56. package/src/__tests__/reminder.test.ts +43 -77
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +222 -0
  58. package/src/__tests__/run-orchestrator.test.ts +7 -7
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +19 -20
  60. package/src/__tests__/runtime-runs-http.test.ts +5 -23
  61. package/src/__tests__/runtime-runs.test.ts +11 -11
  62. package/src/__tests__/schedule-store.test.ts +482 -0
  63. package/src/__tests__/schedule-tools.test.ts +700 -0
  64. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  65. package/src/__tests__/server-history-render.test.ts +14 -13
  66. package/src/__tests__/session-error.test.ts +28 -0
  67. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  68. package/src/__tests__/session-queue.test.ts +89 -16
  69. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  70. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  71. package/src/__tests__/signup-e2e.test.ts +2 -1
  72. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  73. package/src/__tests__/skill-script-runner.test.ts +159 -0
  74. package/src/__tests__/speaker-identification.test.ts +52 -0
  75. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  76. package/src/__tests__/subagent-tools.test.ts +141 -41
  77. package/src/__tests__/task-compiler.test.ts +2 -1
  78. package/src/__tests__/task-runner.test.ts +2 -1
  79. package/src/__tests__/task-scheduler.test.ts +2 -1
  80. package/src/__tests__/task-tools.test.ts +49 -56
  81. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  82. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  84. package/src/__tests__/tool-executor.test.ts +13 -17
  85. package/src/__tests__/turn-commit.test.ts +273 -2
  86. package/src/__tests__/twilio-provider.test.ts +143 -0
  87. package/src/__tests__/twilio-routes.test.ts +789 -0
  88. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  89. package/src/__tests__/view-image-tool.test.ts +217 -0
  90. package/src/__tests__/workspace-git-service.test.ts +403 -0
  91. package/src/__tests__/workspace-heartbeat-service.test.ts +141 -2
  92. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  93. package/src/bundler/app-bundler.ts +35 -14
  94. package/src/calls/call-bridge.ts +95 -0
  95. package/src/calls/call-constants.ts +48 -0
  96. package/src/calls/call-domain.ts +276 -0
  97. package/src/calls/call-orchestrator.ts +390 -0
  98. package/src/calls/call-recovery.ts +207 -0
  99. package/src/calls/call-state-machine.ts +68 -0
  100. package/src/calls/call-state.ts +64 -0
  101. package/src/calls/call-store.ts +416 -0
  102. package/src/calls/relay-server.ts +335 -0
  103. package/src/calls/speaker-identification.ts +213 -0
  104. package/src/calls/twilio-config.ts +34 -0
  105. package/src/calls/twilio-provider.ts +173 -0
  106. package/src/calls/twilio-routes.ts +250 -0
  107. package/src/calls/types.ts +37 -0
  108. package/src/calls/voice-provider.ts +14 -0
  109. package/src/cli/config-commands.ts +334 -0
  110. package/src/cli/core-commands.ts +776 -0
  111. package/src/cli/doordash.ts +256 -25
  112. package/src/cli/ipc-client.ts +82 -0
  113. package/src/cli/map.ts +246 -0
  114. package/src/cli/twitter.ts +575 -0
  115. package/src/cli.ts +7 -5
  116. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  117. package/src/commands/cc-command-registry.ts +209 -0
  118. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  119. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  120. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  123. package/src/config/bundled-skills/document/SKILL.md +18 -0
  124. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  125. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  126. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  127. package/src/config/bundled-skills/doordash/SKILL.md +163 -0
  128. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  129. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  130. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  131. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  133. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -24
  135. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  136. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  137. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  138. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  139. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  140. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  141. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  142. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  143. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  144. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  145. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  146. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  149. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  150. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  151. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  152. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  153. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  154. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  155. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  156. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  157. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  158. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  159. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  160. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  161. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  162. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  163. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  164. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  165. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  166. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  167. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  168. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  169. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  170. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  171. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  172. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  173. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  174. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  175. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  176. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  177. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  178. package/src/config/defaults.ts +44 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +218 -1
  181. package/src/config/system-prompt.ts +100 -6
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +5 -0
  184. package/src/contacts/contact-store.ts +4 -4
  185. package/src/daemon/assistant-attachments.ts +10 -0
  186. package/src/daemon/classifier.ts +3 -1
  187. package/src/daemon/computer-use-session.ts +3 -1
  188. package/src/daemon/date-context.ts +136 -0
  189. package/src/daemon/handlers/apps.ts +16 -1
  190. package/src/daemon/handlers/browser.ts +54 -0
  191. package/src/daemon/handlers/computer-use.ts +7 -1
  192. package/src/daemon/handlers/config.ts +192 -4
  193. package/src/daemon/handlers/diagnostics.ts +5 -1
  194. package/src/daemon/handlers/documents.ts +18 -29
  195. package/src/daemon/handlers/home-base.ts +5 -1
  196. package/src/daemon/handlers/index.ts +40 -271
  197. package/src/daemon/handlers/misc.ts +9 -1
  198. package/src/daemon/handlers/publish.ts +6 -1
  199. package/src/daemon/handlers/sessions.ts +65 -12
  200. package/src/daemon/handlers/shared.ts +36 -1
  201. package/src/daemon/handlers/signing.ts +37 -0
  202. package/src/daemon/handlers/skills.ts +20 -6
  203. package/src/daemon/handlers/subagents.ts +8 -3
  204. package/src/daemon/handlers/twitter-auth.ts +169 -0
  205. package/src/daemon/handlers/work-items.ts +495 -39
  206. package/src/daemon/ipc-contract-inventory.json +40 -4
  207. package/src/daemon/ipc-contract.ts +185 -37
  208. package/src/daemon/ipc-protocol.ts +7 -2
  209. package/src/daemon/lifecycle.ts +48 -5
  210. package/src/daemon/main.ts +10 -4
  211. package/src/daemon/ride-shotgun-handler.ts +74 -10
  212. package/src/daemon/server.ts +144 -29
  213. package/src/daemon/session-agent-loop.ts +887 -0
  214. package/src/daemon/session-attachments.ts +28 -5
  215. package/src/daemon/session-error.ts +24 -3
  216. package/src/daemon/session-lifecycle.ts +147 -0
  217. package/src/daemon/session-media-retry.ts +147 -0
  218. package/src/daemon/session-messaging.ts +145 -0
  219. package/src/daemon/session-notifiers.ts +164 -0
  220. package/src/daemon/session-process.ts +2 -2
  221. package/src/daemon/session-queue-manager.ts +1 -0
  222. package/src/daemon/session-runtime-assembly.ts +52 -0
  223. package/src/daemon/session-skill-tools.ts +124 -5
  224. package/src/daemon/session-slash.ts +3 -0
  225. package/src/daemon/session-surfaces.ts +77 -2
  226. package/src/daemon/session-tool-setup.ts +222 -2
  227. package/src/daemon/session-usage.ts +0 -2
  228. package/src/daemon/session.ts +114 -1365
  229. package/src/daemon/video-thumbnail.ts +60 -0
  230. package/src/doordash/client.ts +121 -27
  231. package/src/doordash/queries.ts +1 -2
  232. package/src/export/formatter.ts +3 -1
  233. package/src/followups/followup-store.ts +4 -2
  234. package/src/followups/types.ts +6 -0
  235. package/src/hooks/templates.ts +1 -1
  236. package/src/index.ts +32 -1151
  237. package/src/media/gemini-image-service.ts +1 -1
  238. package/src/memory/attachments-store.ts +28 -83
  239. package/src/memory/channel-delivery-store.ts +7 -21
  240. package/src/memory/clarification-resolver.ts +6 -5
  241. package/src/memory/contradiction-checker.ts +3 -2
  242. package/src/memory/conversation-key-store.ts +10 -29
  243. package/src/memory/conversation-store.ts +2 -1
  244. package/src/memory/db.ts +362 -2
  245. package/src/memory/entity-extractor.ts +6 -3
  246. package/src/memory/items-extractor.ts +5 -4
  247. package/src/memory/jobs-store.ts +3 -2
  248. package/src/memory/llm-usage-store.ts +1 -2
  249. package/src/memory/runs-store.ts +1 -2
  250. package/src/memory/schema.ts +65 -2
  251. package/src/messaging/style-analyzer.ts +3 -2
  252. package/src/messaging/thread-summarizer.ts +8 -12
  253. package/src/messaging/triage-engine.ts +4 -2
  254. package/src/providers/openrouter/client.ts +20 -0
  255. package/src/providers/registry.ts +8 -0
  256. package/src/runtime/http-server.ts +277 -25
  257. package/src/runtime/http-types.ts +0 -2
  258. package/src/runtime/routes/attachment-routes.ts +5 -6
  259. package/src/runtime/routes/call-routes.ts +140 -0
  260. package/src/runtime/routes/channel-routes.ts +12 -19
  261. package/src/runtime/routes/conversation-routes.ts +5 -9
  262. package/src/runtime/routes/run-routes.ts +4 -8
  263. package/src/runtime/run-orchestrator.ts +39 -6
  264. package/src/schedule/recurrence-engine.ts +138 -0
  265. package/src/schedule/recurrence-types.ts +67 -0
  266. package/src/schedule/schedule-store.ts +102 -57
  267. package/src/schedule/scheduler.ts +9 -6
  268. package/src/security/oauth2.ts +29 -4
  269. package/src/security/secret-allowlist.ts +46 -0
  270. package/src/skills/clawhub.ts +1 -1
  271. package/src/subagent/manager.ts +40 -8
  272. package/src/swarm/backend-claude-code.ts +64 -9
  273. package/src/swarm/worker-prompts.ts +2 -1
  274. package/src/tasks/SPEC.md +34 -28
  275. package/src/tasks/ephemeral-permissions.ts +16 -7
  276. package/src/tasks/task-compiler.ts +5 -4
  277. package/src/tasks/task-runner.ts +10 -5
  278. package/src/tasks/task-scheduler.ts +1 -1
  279. package/src/tasks/tool-sanitizer.ts +36 -0
  280. package/src/tools/assets/search.ts +4 -4
  281. package/src/tools/browser/api-map.ts +220 -0
  282. package/src/tools/browser/auto-navigate.ts +270 -0
  283. package/src/tools/browser/browser-execution.ts +2 -1
  284. package/src/tools/browser/browser-manager.ts +2 -2
  285. package/src/tools/browser/network-recorder.ts +5 -4
  286. package/src/tools/browser/x-auto-navigate.ts +207 -0
  287. package/src/tools/calls/call-end.ts +67 -0
  288. package/src/tools/calls/call-start.ts +73 -0
  289. package/src/tools/calls/call-status.ts +81 -0
  290. package/src/tools/claude-code/claude-code.ts +77 -11
  291. package/src/tools/contacts/contact-merge.ts +46 -78
  292. package/src/tools/contacts/contact-search.ts +35 -79
  293. package/src/tools/contacts/contact-upsert.ts +35 -108
  294. package/src/tools/credentials/vault.ts +21 -5
  295. package/src/tools/document/document-tool.ts +71 -144
  296. package/src/tools/executor.ts +129 -10
  297. package/src/tools/followups/followup_create.ts +46 -88
  298. package/src/tools/followups/followup_list.ts +34 -74
  299. package/src/tools/followups/followup_resolve.ts +31 -66
  300. package/src/tools/host-terminal/cli-discover.ts +2 -1
  301. package/src/tools/host-terminal/host-shell.ts +10 -0
  302. package/src/tools/memory/handlers.ts +5 -4
  303. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  304. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  305. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  306. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  307. package/src/tools/network/web-fetch.ts +18 -6
  308. package/src/tools/playbooks/index.ts +4 -5
  309. package/src/tools/playbooks/playbook-create.ts +3 -47
  310. package/src/tools/playbooks/playbook-delete.ts +1 -25
  311. package/src/tools/playbooks/playbook-list.ts +1 -28
  312. package/src/tools/playbooks/playbook-update.ts +3 -51
  313. package/src/tools/registry.ts +2 -4
  314. package/src/tools/reminder/reminder.ts +5 -78
  315. package/src/tools/schedule/create.ts +69 -74
  316. package/src/tools/schedule/delete.ts +21 -47
  317. package/src/tools/schedule/list.ts +55 -74
  318. package/src/tools/schedule/update.ts +77 -84
  319. package/src/tools/subagent/abort.ts +29 -58
  320. package/src/tools/subagent/message.ts +30 -63
  321. package/src/tools/subagent/read.ts +53 -84
  322. package/src/tools/subagent/spawn.ts +43 -82
  323. package/src/tools/subagent/status.ts +42 -71
  324. package/src/tools/swarm/delegate.ts +2 -1
  325. package/src/tools/tasks/index.ts +8 -6
  326. package/src/tools/tasks/task-delete.ts +69 -56
  327. package/src/tools/tasks/task-list.ts +31 -52
  328. package/src/tools/tasks/task-run.ts +74 -102
  329. package/src/tools/tasks/task-save.ts +33 -65
  330. package/src/tools/tasks/work-item-enqueue.ts +192 -134
  331. package/src/tools/tasks/work-item-list.ts +33 -78
  332. package/src/tools/tasks/work-item-remove.ts +60 -0
  333. package/src/tools/tasks/work-item-update.ts +114 -0
  334. package/src/tools/terminal/backends/native.ts +3 -1
  335. package/src/tools/tool-manifest.ts +20 -74
  336. package/src/tools/types.ts +6 -0
  337. package/src/tools/ui-surface/definitions.ts +6 -1
  338. package/src/tools/watch/screen-watch.ts +3 -1
  339. package/src/tools/watcher/create.ts +52 -98
  340. package/src/tools/watcher/delete.ts +20 -46
  341. package/src/tools/watcher/digest.ts +36 -70
  342. package/src/tools/watcher/list.ts +49 -79
  343. package/src/tools/watcher/update.ts +45 -91
  344. package/src/twitter/client.ts +690 -0
  345. package/src/twitter/session.ts +91 -0
  346. package/src/usage/types.ts +0 -1
  347. package/src/util/truncate.ts +6 -0
  348. package/src/watcher/providers/slack.ts +2 -1
  349. package/src/watcher/watcher-store.ts +3 -2
  350. package/src/work-items/work-item-store.ts +236 -2
  351. package/src/workspace/commit-message-enrichment-service.ts +284 -0
  352. package/src/workspace/commit-message-provider.ts +95 -0
  353. package/src/workspace/git-service.ts +272 -52
  354. package/src/workspace/heartbeat-service.ts +70 -13
  355. package/src/workspace/provider-commit-message-generator.ts +242 -0
  356. package/src/workspace/turn-commit.ts +100 -51
  357. package/src/tools/contacts/index.ts +0 -4
  358. package/src/tools/document/index.ts +0 -5
  359. package/src/tools/followups/index.ts +0 -3
  360. package/src/tools/subagent/index.ts +0 -5
  361. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { getLogger } from '../util/logger.js';
6
+ import { getConfig } from '../config/loader.js';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
9
  const log = getLogger('workspace-git');
@@ -51,15 +52,6 @@ const WORKSPACE_GITIGNORE_RULES = [
51
52
  'http-token',
52
53
  ];
53
54
 
54
- /**
55
- * Rules that were used in older versions but have been superseded.
56
- * These are removed from existing .gitignore files during normalization
57
- * to prevent them from overriding the newer, more selective rules.
58
- */
59
- const DEPRECATED_GITIGNORE_RULES = [
60
- 'data/',
61
- ];
62
-
63
55
  /** Properties added by Node's child_process errors. */
64
56
  interface ExecError extends Error {
65
57
  killed?: boolean;
@@ -142,12 +134,88 @@ export class WorkspaceGitService {
142
134
  private readonly mutex: Mutex;
143
135
  private initialized = false;
144
136
  private initPromise: Promise<void> | null = null;
137
+ private consecutiveFailures = 0;
138
+ private nextAllowedAttemptMs = 0;
139
+ private initConsecutiveFailures = 0;
140
+ private initNextAllowedAttemptMs = 0;
145
141
 
146
142
  constructor(workspaceDir: string) {
147
143
  this.workspaceDir = workspaceDir;
148
144
  this.mutex = new Mutex();
149
145
  }
150
146
 
147
+ /**
148
+ * Check if the circuit breaker is open (too many recent failures).
149
+ * When open, commit attempts are skipped until the backoff window expires.
150
+ */
151
+ private isBreakerOpen(): boolean {
152
+ if (this.consecutiveFailures === 0) return false;
153
+ return Date.now() < this.nextAllowedAttemptMs;
154
+ }
155
+
156
+ private recordSuccess(): void {
157
+ if (this.consecutiveFailures > 0) {
158
+ log.info(
159
+ { workspaceDir: this.workspaceDir, previousFailures: this.consecutiveFailures },
160
+ 'Circuit breaker closed: commit succeeded after failures',
161
+ );
162
+ }
163
+ this.consecutiveFailures = 0;
164
+ this.nextAllowedAttemptMs = 0;
165
+ }
166
+
167
+ private recordFailure(): void {
168
+ const config = getConfig();
169
+ const failureBackoffBaseMs = config.workspaceGit?.failureBackoffBaseMs ?? 2000;
170
+ const failureBackoffMaxMs = config.workspaceGit?.failureBackoffMaxMs ?? 60000;
171
+ this.consecutiveFailures++;
172
+ const delay = Math.min(
173
+ failureBackoffBaseMs * Math.pow(2, this.consecutiveFailures - 1),
174
+ failureBackoffMaxMs,
175
+ );
176
+ this.nextAllowedAttemptMs = Date.now() + delay;
177
+ log.warn(
178
+ { workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures, backoffMs: delay },
179
+ 'Circuit breaker opened: commit failed, backing off',
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Check if the init circuit breaker is open (too many recent init failures).
185
+ * When open, init attempts are skipped until the backoff window expires.
186
+ */
187
+ private isInitBreakerOpen(): boolean {
188
+ if (this.initConsecutiveFailures < 2) return false;
189
+ return Date.now() < this.initNextAllowedAttemptMs;
190
+ }
191
+
192
+ private recordInitSuccess(): void {
193
+ if (this.initConsecutiveFailures > 0) {
194
+ log.info(
195
+ { workspaceDir: this.workspaceDir, previousFailures: this.initConsecutiveFailures },
196
+ 'Init circuit breaker closed: initialization succeeded after failures',
197
+ );
198
+ }
199
+ this.initConsecutiveFailures = 0;
200
+ this.initNextAllowedAttemptMs = 0;
201
+ }
202
+
203
+ private recordInitFailure(): void {
204
+ const config = getConfig();
205
+ const failureBackoffBaseMs = config.workspaceGit?.failureBackoffBaseMs ?? 2000;
206
+ const failureBackoffMaxMs = config.workspaceGit?.failureBackoffMaxMs ?? 60000;
207
+ this.initConsecutiveFailures++;
208
+ const delay = Math.min(
209
+ failureBackoffBaseMs * Math.pow(2, this.initConsecutiveFailures - 1),
210
+ failureBackoffMaxMs,
211
+ );
212
+ this.initNextAllowedAttemptMs = Date.now() + delay;
213
+ log.warn(
214
+ { workspaceDir: this.workspaceDir, consecutiveFailures: this.initConsecutiveFailures, backoffMs: delay },
215
+ 'Init circuit breaker opened: initialization failed, backing off',
216
+ );
217
+ }
218
+
151
219
  /**
152
220
  * Ensure the git repository is initialized.
153
221
  * Idempotent: safe to call multiple times.
@@ -172,6 +240,14 @@ export class WorkspaceGitService {
172
240
  return this.initPromise;
173
241
  }
174
242
 
243
+ // Circuit breaker: skip if multiple recent init attempts have been failing.
244
+ // Checked AFTER initPromise so callers waiting on in-progress init aren't
245
+ // blocked, and only activates after 2+ consecutive failures so that a
246
+ // single transient failure allows immediate retry.
247
+ if (this.isInitBreakerOpen()) {
248
+ throw new Error('Init circuit breaker open: backing off after repeated failures');
249
+ }
250
+
175
251
  // Start initialization
176
252
  this.initPromise = this.mutex.withLock(async () => {
177
253
  // Double-check after acquiring lock
@@ -264,6 +340,7 @@ export class WorkspaceGitService {
264
340
  await this.ensureCommitIdentityLocked();
265
341
  await this.ensureOnMainLocked();
266
342
  this.initialized = true;
343
+ this.recordInitSuccess();
267
344
  return;
268
345
  }
269
346
  }
@@ -298,12 +375,14 @@ export class WorkspaceGitService {
298
375
  await this.execGit(['commit', '-m', message, '--allow-empty']);
299
376
 
300
377
  this.initialized = true;
378
+ this.recordInitSuccess();
301
379
  });
302
380
 
303
381
  // If initialization fails, clear the cached promise so subsequent
304
382
  // calls can retry instead of permanently returning the rejected promise.
305
383
  this.initPromise.catch(() => {
306
384
  this.initPromise = null;
385
+ this.recordInitFailure();
307
386
  });
308
387
 
309
388
  return this.initPromise;
@@ -344,47 +423,119 @@ export class WorkspaceGitService {
344
423
  *
345
424
  * @param decide - Called with the current status. Return an object with `message`
346
425
  * (and optional `metadata`) to commit, or `null` to skip.
426
+ * @param options.bypassBreaker - Skip circuit breaker checks (used for shutdown commits).
427
+ * @param options.deadlineMs - Absolute timestamp (Date.now()) after which the commit
428
+ * should be skipped. Checked before lock acquisition, after lock acquisition, and
429
+ * before git add/commit to prevent stale queued attempts from doing expensive work.
347
430
  * @returns Whether a commit was created and the status at check time.
348
431
  */
349
432
  async commitIfDirty(
350
433
  decide: (status: GitStatus) => { message: string; metadata?: GitCommitMetadata } | null,
434
+ options?: { bypassBreaker?: boolean; deadlineMs?: number },
351
435
  ): Promise<{ committed: boolean; status: GitStatus }> {
436
+ const emptyStatus: GitStatus = { staged: [], modified: [], untracked: [], clean: false };
437
+
438
+ // Circuit breaker: skip expensive git work if recent attempts have been failing.
439
+ // Shutdown commits bypass the breaker because the process is about to exit and
440
+ // this is the last chance to persist workspace state.
441
+ if (!options?.bypassBreaker && this.isBreakerOpen()) {
442
+ log.debug(
443
+ { workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures },
444
+ 'Circuit breaker open, skipping commit attempt',
445
+ );
446
+ return { committed: false, status: emptyStatus };
447
+ }
448
+
449
+ // Deadline fast-path: bail before acquiring the lock if already past deadline.
450
+ if (isDeadlineExpired(options?.deadlineMs)) {
451
+ log.debug(
452
+ { workspaceDir: this.workspaceDir },
453
+ 'Deadline expired before lock acquisition, skipping commit',
454
+ );
455
+ return { committed: false, status: emptyStatus };
456
+ }
457
+
352
458
  await this.ensureInitialized();
353
459
 
354
- return this.mutex.withLock(async () => {
355
- const status = await this.getStatusInternal();
356
- if (status.clean) {
357
- return { committed: false, status };
358
- }
460
+ try {
461
+ const result = await this.mutex.withLock(async () => {
462
+ // Re-check breaker under lock: a queued call that started before the
463
+ // breaker opened should not proceed with expensive git work now that
464
+ // the breaker is open.
465
+ if (!options?.bypassBreaker && this.isBreakerOpen()) {
466
+ log.debug(
467
+ { workspaceDir: this.workspaceDir, consecutiveFailures: this.consecutiveFailures },
468
+ 'Circuit breaker open after lock acquisition, skipping commit',
469
+ );
470
+ return { committed: false, status: emptyStatus, didRunGit: false as const };
471
+ }
359
472
 
360
- const decision = decide(status);
361
- if (!decision) {
362
- return { committed: false, status };
363
- }
473
+ // Re-check deadline after lock acquisition: the call may have waited
474
+ // in the mutex queue past its deadline.
475
+ if (isDeadlineExpired(options?.deadlineMs)) {
476
+ log.debug(
477
+ { workspaceDir: this.workspaceDir },
478
+ 'Deadline expired after lock acquisition, skipping commit',
479
+ );
480
+ return { committed: false, status: emptyStatus, didRunGit: false as const };
481
+ }
364
482
 
365
- await this.execGit(['add', '-A']);
483
+ const status = await this.getStatusInternal();
484
+ if (status.clean) {
485
+ return { committed: false, status, didRunGit: true as const };
486
+ }
366
487
 
367
- // Verify something was actually staged. Another service instance
368
- // (or external process) could have committed between our status
369
- // check and the add, leaving the index clean.
370
- try {
371
- await this.execGit(['diff', '--cached', '--quiet']);
372
- // Exit code 0 means nothing staged — nothing to commit
373
- return { committed: false, status };
374
- } catch {
375
- // Exit code 1 means there ARE staged changes — proceed
376
- }
488
+ const decision = decide(status);
489
+ if (!decision) {
490
+ return { committed: false, status, didRunGit: true as const };
491
+ }
377
492
 
378
- let fullMessage = decision.message;
379
- if (decision.metadata && Object.keys(decision.metadata).length > 0) {
380
- fullMessage += '\n\n' + Object.entries(decision.metadata)
381
- .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
382
- .join('\n');
383
- }
493
+ // Check deadline before expensive git add/commit operations.
494
+ if (isDeadlineExpired(options?.deadlineMs)) {
495
+ log.debug(
496
+ { workspaceDir: this.workspaceDir },
497
+ 'Deadline expired before git add/commit, skipping commit',
498
+ );
499
+ return { committed: false, status, didRunGit: true as const };
500
+ }
384
501
 
385
- await this.execGit(['commit', '-m', fullMessage]);
386
- return { committed: true, status };
387
- });
502
+ await this.execGit(['add', '-A']);
503
+
504
+ // Verify something was actually staged. Another service instance
505
+ // (or external process) could have committed between our status
506
+ // check and the add, leaving the index clean.
507
+ try {
508
+ await this.execGit(['diff', '--cached', '--quiet']);
509
+ // Exit code 0 means nothing staged — nothing to commit
510
+ return { committed: false, status, didRunGit: true as const };
511
+ } catch (err) {
512
+ // git diff --cached --quiet exits with code 1 when there are staged changes.
513
+ // Any other error (timeout, permission, etc.) should be treated as a failure.
514
+ const execErr = err as ExecError;
515
+ if (execErr.code !== 1) {
516
+ throw err;
517
+ }
518
+ // Exit code 1 = staged changes exist — proceed with commit
519
+ }
520
+
521
+ let fullMessage = decision.message;
522
+ if (decision.metadata && Object.keys(decision.metadata).length > 0) {
523
+ fullMessage += '\n\n' + Object.entries(decision.metadata)
524
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
525
+ .join('\n');
526
+ }
527
+
528
+ await this.execGit(['commit', '-m', fullMessage]);
529
+ return { committed: true, status, didRunGit: true as const };
530
+ });
531
+ if (result.didRunGit) {
532
+ this.recordSuccess();
533
+ }
534
+ return { committed: result.committed, status: result.status };
535
+ } catch (err) {
536
+ this.recordFailure();
537
+ throw err;
538
+ }
388
539
  }
389
540
 
390
541
  /**
@@ -439,7 +590,6 @@ export class WorkspaceGitService {
439
590
  /**
440
591
  * Ensure .gitignore contains all required workspace exclusion rules.
441
592
  * Idempotent: checks for missing rules and only appends what's needed.
442
- * Also removes deprecated rules that have been superseded by newer ones.
443
593
  * Must be called with the mutex lock held.
444
594
  */
445
595
  private ensureGitignoreRulesLocked(): void {
@@ -447,18 +597,28 @@ export class WorkspaceGitService {
447
597
  if (existsSync(gitignorePath)) {
448
598
  let content = readFileSync(gitignorePath, 'utf-8');
449
599
 
450
- // Remove deprecated rules (e.g. broad 'data/' replaced by selective 'data/db/' etc.)
451
- const deprecatedPresent = DEPRECATED_GITIGNORE_RULES.filter(rule => content.includes(rule));
452
- if (deprecatedPresent.length > 0) {
453
- const lines = content.split('\n');
454
- content = lines.filter(line => !DEPRECATED_GITIGNORE_RULES.includes(line.trim())).join('\n');
600
+ // Migrate legacy broad ignore rule to selective data subdirectory rules.
601
+ // This keeps user-tracked files under data/ visible to git.
602
+ const lines = content.split('\n');
603
+ const hadLegacyDataRule = lines.some(line => line.trim() === 'data/');
604
+ if (hadLegacyDataRule) {
605
+ content = lines
606
+ .filter(line => line.trim() !== 'data/')
607
+ .join('\n');
608
+ if (!content.endsWith('\n')) {
609
+ content += '\n';
610
+ }
455
611
  }
456
612
 
457
613
  const missingRules = WORKSPACE_GITIGNORE_RULES.filter(rule => !content.includes(rule));
458
- if (missingRules.length > 0 || deprecatedPresent.length > 0) {
459
- const updated = missingRules.length > 0
460
- ? content + '\n# Vellum runtime state (auto-added)\n' + missingRules.join('\n') + '\n'
461
- : content;
614
+ if (hadLegacyDataRule || missingRules.length > 0) {
615
+ let updated = content;
616
+ if (missingRules.length > 0) {
617
+ if (!updated.endsWith('\n')) {
618
+ updated += '\n';
619
+ }
620
+ updated += '# Vellum runtime state (auto-added)\n' + missingRules.join('\n') + '\n';
621
+ }
462
622
  writeFileSync(gitignorePath, updated, 'utf-8');
463
623
  }
464
624
  } else {
@@ -532,16 +692,21 @@ export class WorkspaceGitService {
532
692
 
533
693
  /**
534
694
  * Execute a git command in the workspace directory.
535
- * Includes a 30-second timeout to prevent hung operations
536
- * (e.g. stale git lock files).
695
+ * Uses the configurable interactiveGitTimeoutMs (default 10 000 ms) to
696
+ * prevent hung operations (e.g. stale git lock files). The timeout is
697
+ * intentionally short for interactive workspace operations — background
698
+ * enrichment jobs use their own dedicated timeout.
537
699
  */
538
- private async execGit(args: string[]): Promise<{ stdout: string; stderr: string }> {
700
+ private async execGit(args: string[], options?: { signal?: AbortSignal }): Promise<{ stdout: string; stderr: string }> {
701
+ const config = getConfig();
702
+ const timeoutMs = config.workspaceGit?.interactiveGitTimeoutMs ?? 10_000;
539
703
  try {
540
704
  const { stdout, stderr } = await execFileAsync('git', args, {
541
705
  cwd: this.workspaceDir,
542
706
  encoding: 'utf-8',
543
- timeout: 30_000,
707
+ timeout: timeoutMs,
544
708
  env: cleanGitEnv(this.workspaceDir),
709
+ signal: options?.signal,
545
710
  });
546
711
  return { stdout, stderr };
547
712
  } catch (err) {
@@ -567,6 +732,23 @@ export class WorkspaceGitService {
567
732
  }
568
733
  }
569
734
 
735
+ /**
736
+ * Get the commit hash of the current HEAD.
737
+ * This is a lightweight read-only operation that does not require the mutex.
738
+ */
739
+ async getHeadHash(): Promise<string> {
740
+ const { stdout } = await this.execGit(['rev-parse', 'HEAD']);
741
+ return stdout.trim();
742
+ }
743
+
744
+ /**
745
+ * Write a git note to a specific commit.
746
+ * Uses the 'vellum' notes ref to avoid conflicts with default notes.
747
+ */
748
+ async writeNote(commitHash: string, noteContent: string, signal?: AbortSignal): Promise<void> {
749
+ await this.execGit(['notes', '--ref=vellum', 'add', '-f', '-m', noteContent, commitHash], { signal });
750
+ }
751
+
570
752
  /**
571
753
  * Check if the workspace has a git repository initialized.
572
754
  * This is a non-blocking check that doesn't trigger initialization.
@@ -583,6 +765,14 @@ export class WorkspaceGitService {
583
765
  }
584
766
  }
585
767
 
768
+ /**
769
+ * Check whether a deadline has expired.
770
+ * Returns true when `deadlineMs` is provided and `Date.now()` has reached or passed it.
771
+ */
772
+ export function isDeadlineExpired(deadlineMs?: number): boolean {
773
+ return deadlineMs !== undefined && Date.now() >= deadlineMs;
774
+ }
775
+
586
776
  /**
587
777
  * Singleton registry for workspace git services.
588
778
  * Ensures one service instance per workspace directory.
@@ -618,3 +808,33 @@ export function getAllWorkspaceGitServices(): ReadonlyMap<string, WorkspaceGitSe
618
808
  export function _resetGitServiceRegistry(): void {
619
809
  serviceRegistry.clear();
620
810
  }
811
+
812
+ /**
813
+ * @internal Test-only: reset circuit breaker state for a service instance
814
+ */
815
+ export function _resetBreaker(service: WorkspaceGitService): void {
816
+ (service as unknown as { consecutiveFailures: number }).consecutiveFailures = 0;
817
+ (service as unknown as { nextAllowedAttemptMs: number }).nextAllowedAttemptMs = 0;
818
+ }
819
+
820
+ /**
821
+ * @internal Test-only: get consecutive failure count
822
+ */
823
+ export function _getConsecutiveFailures(service: WorkspaceGitService): number {
824
+ return (service as unknown as { consecutiveFailures: number }).consecutiveFailures;
825
+ }
826
+
827
+ /**
828
+ * @internal Test-only: reset init circuit breaker state for a service instance
829
+ */
830
+ export function _resetInitBreaker(service: WorkspaceGitService): void {
831
+ (service as unknown as { initConsecutiveFailures: number }).initConsecutiveFailures = 0;
832
+ (service as unknown as { initNextAllowedAttemptMs: number }).initNextAllowedAttemptMs = 0;
833
+ }
834
+
835
+ /**
836
+ * @internal Test-only: get init consecutive failure count
837
+ */
838
+ export function _getInitConsecutiveFailures(service: WorkspaceGitService): number {
839
+ return (service as unknown as { initConsecutiveFailures: number }).initConsecutiveFailures;
840
+ }
@@ -1,5 +1,11 @@
1
1
  import { getLogger } from '../util/logger.js';
2
2
  import { getAllWorkspaceGitServices, type WorkspaceGitService } from './git-service.js';
3
+ import {
4
+ DefaultCommitMessageProvider,
5
+ type CommitContext,
6
+ type CommitMessageProvider,
7
+ } from './commit-message-provider.js';
8
+ import { getEnrichmentService } from './commit-message-enrichment-service.js';
3
9
 
4
10
  const log = getLogger('heartbeat');
5
11
 
@@ -23,6 +29,8 @@ export interface HeartbeatServiceOptions {
23
29
  getServices?: () => ReadonlyMap<string, WorkspaceGitService>;
24
30
  /** Override for getting the current timestamp (for testing). */
25
31
  now?: () => number;
32
+ /** Custom commit message provider. */
33
+ commitMessageProvider?: CommitMessageProvider;
26
34
  }
27
35
 
28
36
  /**
@@ -61,6 +69,7 @@ export class HeartbeatService {
61
69
  private readonly intervalMs: number;
62
70
  private readonly getServices: () => ReadonlyMap<string, WorkspaceGitService>;
63
71
  private readonly now: () => number;
72
+ private readonly commitMessageProvider: CommitMessageProvider;
64
73
  private timer: ReturnType<typeof setInterval> | null = null;
65
74
  /** Tracks the currently in-flight check to prevent overlapping runs and allow clean shutdown. */
66
75
  private activeCheck: Promise<HeartbeatCheckResult> | null = null;
@@ -71,6 +80,7 @@ export class HeartbeatService {
71
80
  this.intervalMs = options?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
72
81
  this.getServices = options?.getServices ?? getAllWorkspaceGitServices;
73
82
  this.now = options?.now ?? Date.now;
83
+ this.commitMessageProvider = options?.commitMessageProvider ?? new DefaultCommitMessageProvider();
74
84
  }
75
85
 
76
86
  /**
@@ -139,7 +149,7 @@ export class HeartbeatService {
139
149
  result.checked++;
140
150
 
141
151
  try {
142
- const committed = await this.checkWorkspace(workspaceDir, service, 'heartbeat');
152
+ const committed = await this.checkWorkspace(workspaceDir, service);
143
153
  if (committed) {
144
154
  result.committed++;
145
155
  } else {
@@ -189,18 +199,39 @@ export class HeartbeatService {
189
199
 
190
200
  try {
191
201
  const now = this.now();
192
- const { committed } = await service.commitIfDirty((status) => {
193
- const totalChanges = new Set([...status.staged, ...status.modified, ...status.untracked]).size;
194
- log.info({ workspaceDir, totalChanges }, 'Committing pending changes on shutdown');
195
- return {
196
- message: `auto-commit: shutdown safety net (${totalChanges} files)`,
197
- metadata: { trigger: 'shutdown', timestamp: now },
202
+ let shutdownFiles: string[] = [];
203
+ const { committed } = await service.commitIfDirty((st) => {
204
+ const uniqueFiles = [...new Set([...st.staged, ...st.modified, ...st.untracked])];
205
+ shutdownFiles = uniqueFiles;
206
+ log.info({ workspaceDir, totalChanges: uniqueFiles.length }, 'Committing pending changes on shutdown');
207
+
208
+ const ctx: CommitContext = {
209
+ workspaceDir,
210
+ trigger: 'shutdown',
211
+ changedFiles: uniqueFiles,
212
+ timestampMs: now,
198
213
  };
199
- });
214
+
215
+ return this.commitMessageProvider.buildImmediateMessage(ctx);
216
+ }, { bypassBreaker: true });
200
217
 
201
218
  if (committed) {
202
219
  firstSeenDirty.delete(workspaceDir);
203
220
  result.committed++;
221
+
222
+ // Fire-and-forget enrichment
223
+ try {
224
+ const commitHash = await service.getHeadHash();
225
+ const shutdownCtx: CommitContext = {
226
+ workspaceDir,
227
+ trigger: 'shutdown',
228
+ changedFiles: shutdownFiles,
229
+ timestampMs: this.now(),
230
+ };
231
+ getEnrichmentService().enqueue({ workspaceDir, commitHash, context: shutdownCtx, gitService: service });
232
+ } catch (enrichErr) {
233
+ log.debug({ enrichErr }, 'Failed to enqueue shutdown enrichment (non-fatal)');
234
+ }
204
235
  } else {
205
236
  result.skipped++;
206
237
  }
@@ -225,13 +256,15 @@ export class HeartbeatService {
225
256
  private async checkWorkspace(
226
257
  workspaceDir: string,
227
258
  service: WorkspaceGitService,
228
- trigger: string,
229
259
  ): Promise<boolean> {
230
260
  const now = this.now();
261
+ let heartbeatFiles: string[] = [];
262
+ let heartbeatReason: string | undefined;
231
263
 
232
264
  // Atomic status check + conditional commit within a single mutex lock.
233
265
  const { committed, status } = await service.commitIfDirty((st) => {
234
- const totalChanges = new Set([...st.staged, ...st.modified, ...st.untracked]).size;
266
+ const uniqueFiles = [...new Set([...st.staged, ...st.modified, ...st.untracked])];
267
+ const totalChanges = uniqueFiles.length;
235
268
 
236
269
  // Track when we first saw this workspace as dirty
237
270
  if (!firstSeenDirty.has(workspaceDir)) {
@@ -256,19 +289,43 @@ export class HeartbeatService {
256
289
  ? `changes older than ${Math.round(dirtyAge / 1000)}s`
257
290
  : `${totalChanges} files changed (threshold: ${this.fileThreshold})`;
258
291
 
292
+ heartbeatFiles = uniqueFiles;
293
+ heartbeatReason = reason;
294
+
259
295
  log.info(
260
296
  { workspaceDir, totalChanges, dirtyAgeMs: dirtyAge, reason },
261
297
  'Heartbeat auto-committing workspace changes',
262
298
  );
263
299
 
264
- return {
265
- message: `auto-commit: ${trigger} safety net (${totalChanges} files, ${reason})`,
266
- metadata: { trigger, timestamp: now },
300
+ const ctx: CommitContext = {
301
+ workspaceDir,
302
+ trigger: 'heartbeat',
303
+ changedFiles: uniqueFiles,
304
+ timestampMs: now,
305
+ reason,
267
306
  };
307
+
308
+ return this.commitMessageProvider.buildImmediateMessage(ctx);
268
309
  });
269
310
 
270
311
  if (committed) {
271
312
  firstSeenDirty.delete(workspaceDir);
313
+
314
+ // Fire-and-forget enrichment
315
+ try {
316
+ const commitHash = await service.getHeadHash();
317
+ const hbCtx: CommitContext = {
318
+ workspaceDir,
319
+ trigger: 'heartbeat',
320
+ changedFiles: heartbeatFiles,
321
+ timestampMs: now,
322
+ reason: heartbeatReason,
323
+ };
324
+ getEnrichmentService().enqueue({ workspaceDir, commitHash, context: hbCtx, gitService: service });
325
+ } catch (enrichErr) {
326
+ log.debug({ enrichErr }, 'Failed to enqueue heartbeat enrichment (non-fatal)');
327
+ }
328
+
272
329
  return true;
273
330
  }
274
331