vellum 0.2.0 → 0.2.1

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -1,6 +1,7 @@
1
1
  import { eq, desc, asc } from 'drizzle-orm';
2
2
  import { getDb } from '../memory/db.js';
3
3
  import { workItems } from '../memory/schema.js';
4
+ import { getTask } from '../tasks/task-store.js';
4
5
 
5
6
  // ── Types ────────────────────────────────────────────────────────────
6
7
 
@@ -89,3 +90,211 @@ export function deleteWorkItem(id: string): void {
89
90
  const db = getDb();
90
91
  db.delete(workItems).where(eq(workItems.id, id)).run();
91
92
  }
93
+
94
+ // ── Queue Removal ───────────────────────────────────────────────────
95
+
96
+ export interface RemoveWorkItemResult {
97
+ success: boolean;
98
+ title: string;
99
+ message: string;
100
+ }
101
+
102
+ /**
103
+ * Shared helper for removing a single work item from the queue by ID.
104
+ * Used by both task_delete (compat path) and task_list_remove so all
105
+ * single-item deletions follow one codepath.
106
+ */
107
+ export function removeWorkItemFromQueue(id: string): RemoveWorkItemResult {
108
+ const item = getWorkItem(id);
109
+ if (!item) {
110
+ return { success: false, title: '', message: `No work item found with ID "${id}"` };
111
+ }
112
+ deleteWorkItem(item.id);
113
+ return { success: true, title: item.title, message: `Removed "${item.title}" from the task queue.` };
114
+ }
115
+
116
+ // ── Selectors / Helpers ─────────────────────────────────────────────
117
+
118
+ export interface WorkItemSelector {
119
+ workItemId?: string;
120
+ taskId?: string;
121
+ title?: string;
122
+ /** Disambiguator: filter by priority tier (0 = high, 1 = medium, 2 = low) */
123
+ priorityTier?: number;
124
+ /** Disambiguator: filter by status (queued, running, etc.) */
125
+ status?: WorkItemStatus;
126
+ /** Disambiguator: 1-indexed creation order (1 = oldest, 2 = second oldest, etc.) */
127
+ createdOrder?: number;
128
+ }
129
+
130
+ export type ResolveWorkItemResult =
131
+ | { status: 'found'; workItem: WorkItem }
132
+ | { status: 'not_found'; message: string }
133
+ | { status: 'ambiguous'; matches: WorkItem[]; message: string };
134
+
135
+ const PRIORITY_TIER_LABELS: Record<number, string> = { 0: 'high', 1: 'medium', 2: 'low' };
136
+
137
+ function formatAmbiguityMessage(selectorLabel: string, matches: WorkItem[]): string {
138
+ const lines = matches.map(
139
+ m =>
140
+ ` - ID: ${m.id} | title: "${m.title}" | priority: ${PRIORITY_TIER_LABELS[m.priorityTier] ?? m.priorityTier} | status: ${m.status}`,
141
+ );
142
+ return `Multiple items match '${selectorLabel}'. Please specify which one:\n${lines.join('\n')}`;
143
+ }
144
+
145
+ /** Find all active work items for a given task ID */
146
+ export function findActiveWorkItemsByTaskId(taskId: string): WorkItem[] {
147
+ return listWorkItems().filter(
148
+ i => i.taskId === taskId && i.status !== 'done' && i.status !== 'archived'
149
+ );
150
+ }
151
+
152
+ /** Find all active work items matching a title (case-insensitive exact match) */
153
+ export function findActiveWorkItemsByTitle(title: string): WorkItem[] {
154
+ const normalized = title.trim().toLowerCase();
155
+ return listWorkItems().filter(
156
+ i => i.title.trim().toLowerCase() === normalized && i.status !== 'done' && i.status !== 'archived'
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Apply disambiguator fields to narrow down a set of candidate matches.
162
+ * Filters by priorityTier, then status, then picks by createdOrder if provided.
163
+ * Returns the filtered list (may still contain multiple items if disambiguation
164
+ * fields are insufficient).
165
+ */
166
+ function applyDisambiguators(items: WorkItem[], selector: WorkItemSelector): WorkItem[] {
167
+ let filtered = items;
168
+
169
+ if (selector.priorityTier !== undefined) {
170
+ filtered = filtered.filter(i => i.priorityTier === selector.priorityTier);
171
+ }
172
+
173
+ if (selector.status !== undefined) {
174
+ filtered = filtered.filter(i => i.status === selector.status);
175
+ }
176
+
177
+ if (selector.createdOrder !== undefined && filtered.length > 0) {
178
+ const sorted = [...filtered].sort((a, b) => a.createdAt - b.createdAt);
179
+ const idx = selector.createdOrder - 1; // convert 1-indexed to 0-indexed
180
+ if (idx >= 0 && idx < sorted.length) {
181
+ filtered = [sorted[idx]];
182
+ }
183
+ // If createdOrder is out of range, return the full filtered list so the
184
+ // caller can report ambiguity with the remaining candidates.
185
+ }
186
+
187
+ return filtered;
188
+ }
189
+
190
+ /**
191
+ * Given a list of candidate matches, apply disambiguators and return a resolution result.
192
+ * Centralises the disambiguate-or-return-ambiguous logic shared across selector branches.
193
+ */
194
+ function resolveFromCandidates(items: WorkItem[], selectorLabel: string, selector: WorkItemSelector): ResolveWorkItemResult {
195
+ if (items.length === 0) {
196
+ return { status: 'not_found', message: `No active work item found for "${selectorLabel}"` };
197
+ }
198
+ if (items.length === 1) {
199
+ return { status: 'found', workItem: items[0] };
200
+ }
201
+
202
+ // Multiple matches — try to narrow down with disambiguator fields
203
+ const narrowed = applyDisambiguators(items, selector);
204
+
205
+ if (narrowed.length === 1) {
206
+ return { status: 'found', workItem: narrowed[0] };
207
+ }
208
+ if (narrowed.length === 0) {
209
+ // Disambiguators filtered out everything — report the original set so the
210
+ // caller sees what was available
211
+ return { status: 'ambiguous', matches: items, message: formatAmbiguityMessage(selectorLabel, items) };
212
+ }
213
+ return { status: 'ambiguous', matches: narrowed, message: formatAmbiguityMessage(selectorLabel, narrowed) };
214
+ }
215
+
216
+ /**
217
+ * Resolve a single active work item from a selector.
218
+ * Tries fields in priority order: workItemId > taskId > title.
219
+ * Only considers active items (status not 'done' or 'archived').
220
+ * Returns a discriminated union so callers can handle ambiguity explicitly
221
+ * instead of silently picking one match when multiple exist.
222
+ *
223
+ * When multiple items match, the optional disambiguator fields (priorityTier,
224
+ * status, createdOrder) are applied to narrow down the set.
225
+ */
226
+ export function resolveWorkItem(selector: WorkItemSelector): ResolveWorkItemResult {
227
+ if (selector.workItemId) {
228
+ const item = getWorkItem(selector.workItemId);
229
+ if (!item) return { status: 'not_found', message: `No work item found with ID "${selector.workItemId}"` };
230
+ if (item.status === 'done' || item.status === 'archived') {
231
+ return { status: 'not_found', message: `Work item "${selector.workItemId}" is ${item.status}` };
232
+ }
233
+ return { status: 'found', workItem: item };
234
+ }
235
+
236
+ if (selector.taskId) {
237
+ const items = findActiveWorkItemsByTaskId(selector.taskId);
238
+ return resolveFromCandidates(items, selector.taskId, selector);
239
+ }
240
+
241
+ if (selector.title) {
242
+ const items = findActiveWorkItemsByTitle(selector.title);
243
+ return resolveFromCandidates(items, selector.title, selector);
244
+ }
245
+
246
+ return { status: 'not_found', message: 'At least one selector field (workItemId, taskId, or title) must be provided' };
247
+ }
248
+
249
+ // ── Entity Identification ───────────────────────────────────────────
250
+
251
+ export type EntityType = 'task_template' | 'work_item' | 'unknown';
252
+
253
+ export interface EntityIdentification {
254
+ type: EntityType;
255
+ id: string;
256
+ title?: string;
257
+ }
258
+
259
+ /**
260
+ * Determine whether an ID refers to a task template (tasks table) or
261
+ * a work item (work_items table). Used by tool error messages to give
262
+ * the model corrective guidance when the wrong entity type is provided.
263
+ */
264
+ export function identifyEntityById(id: string): EntityIdentification {
265
+ const workItem = getWorkItem(id);
266
+ if (workItem) {
267
+ return { type: 'work_item', id: workItem.id, title: workItem.title };
268
+ }
269
+
270
+ const task = getTask(id);
271
+ if (task) {
272
+ return { type: 'task_template', id: task.id, title: task.title };
273
+ }
274
+
275
+ return { type: 'unknown', id };
276
+ }
277
+
278
+ /**
279
+ * Build a corrective error message when a work item ID is passed where
280
+ * a task template ID is expected.
281
+ */
282
+ export function buildWorkItemMismatchError(id: string, title: string, expectedTool: string): string {
283
+ return [
284
+ `Entity mismatch: The ID "${id}" refers to a work item ("${title}"), not a task template.`,
285
+ `Corrective action: Use ${expectedTool} to operate on work items in the task queue.`,
286
+ `Selector fields: work_item_id: "${id}" or title: "${title}"`,
287
+ ].join('\n');
288
+ }
289
+
290
+ /**
291
+ * Build a corrective error message when a task template ID is passed where
292
+ * a work item ID is expected.
293
+ */
294
+ export function buildTaskTemplateMismatchError(id: string, title: string, expectedTool: string): string {
295
+ return [
296
+ `Entity mismatch: The ID "${id}" refers to a task template ("${title}"), not a work item.`,
297
+ `Corrective action: Use ${expectedTool} to operate on task templates.`,
298
+ `Selector fields: task_id: "${id}" or task_name: "${title}"`,
299
+ ].join('\n');
300
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Non-blocking enrichment queue for post-commit message enhancement.
3
+ *
4
+ * After a synchronous commit succeeds, callers can enqueue enrichment jobs
5
+ * that run asynchronously without blocking the commit path. This is the
6
+ * scaffold for future LLM-powered commit message enrichment.
7
+ *
8
+ * Key properties:
9
+ * - Bounded queue with configurable max size (drops oldest on overflow)
10
+ * - Bounded concurrency (default 1 worker)
11
+ * - Per-job timeout with retry + exponential backoff
12
+ * - Graceful shutdown: drains in-flight jobs, discards pending jobs
13
+ * - Fire-and-forget: enqueue() never blocks or throws
14
+ */
15
+
16
+ import { getLogger } from '../util/logger.js';
17
+ import { getConfig } from '../config/loader.js';
18
+ import type { WorkspaceGitService } from './git-service.js';
19
+ import type { CommitContext } from './commit-message-provider.js';
20
+
21
+ const log = getLogger('enrichment-queue');
22
+
23
+ export interface EnrichmentJob {
24
+ workspaceDir: string;
25
+ commitHash: string;
26
+ context: CommitContext;
27
+ gitService: WorkspaceGitService;
28
+ }
29
+
30
+ interface InternalJob extends EnrichmentJob {
31
+ attempts: number;
32
+ }
33
+
34
+ export interface EnrichmentServiceOptions {
35
+ maxQueueSize?: number;
36
+ maxConcurrency?: number;
37
+ jobTimeoutMs?: number;
38
+ maxRetries?: number;
39
+ }
40
+
41
+ /**
42
+ * Non-blocking enrichment queue service.
43
+ *
44
+ * Enqueue jobs after successful commits. Each job runs the enrichment
45
+ * worker (currently a no-op placeholder) and writes the result as a
46
+ * git note on the commit.
47
+ */
48
+ export class CommitEnrichmentService {
49
+ private readonly maxQueueSize: number;
50
+ private readonly maxConcurrency: number;
51
+ private readonly jobTimeoutMs: number;
52
+ private readonly maxRetries: number;
53
+
54
+ private queue: InternalJob[] = [];
55
+ private activeWorkers = 0;
56
+ private droppedCount = 0;
57
+ private succeededCount = 0;
58
+ private failedCount = 0;
59
+ private shuttingDown = false;
60
+ private inFlightPromises: Set<Promise<void>> = new Set();
61
+
62
+ constructor(options?: EnrichmentServiceOptions) {
63
+ const config = getConfig();
64
+ const gitConfig = config.workspaceGit;
65
+ this.maxQueueSize = options?.maxQueueSize ?? gitConfig?.enrichmentQueueSize ?? 50;
66
+ this.maxConcurrency = options?.maxConcurrency ?? gitConfig?.enrichmentConcurrency ?? 1;
67
+ this.jobTimeoutMs = options?.jobTimeoutMs ?? gitConfig?.enrichmentJobTimeoutMs ?? 30000;
68
+ this.maxRetries = options?.maxRetries ?? gitConfig?.enrichmentMaxRetries ?? 2;
69
+ }
70
+
71
+ /**
72
+ * Enqueue an enrichment job. Fire-and-forget — never blocks or throws.
73
+ */
74
+ enqueue(job: EnrichmentJob): void {
75
+ if (this.shuttingDown) {
76
+ log.debug({ commitHash: job.commitHash }, 'Enrichment queue shutting down, discarding job');
77
+ return;
78
+ }
79
+
80
+ const internalJob: InternalJob = { ...job, attempts: 0 };
81
+
82
+ // Drop oldest if queue is full
83
+ if (this.queue.length >= this.maxQueueSize) {
84
+ const dropped = this.queue.shift()!;
85
+ this.droppedCount++;
86
+ log.warn(
87
+ { droppedHash: dropped.commitHash, queueSize: this.queue.length, droppedCount: this.droppedCount },
88
+ 'Enrichment queue full, dropping oldest job',
89
+ );
90
+ }
91
+
92
+ this.queue.push(internalJob);
93
+ log.debug(
94
+ { commitHash: job.commitHash, queueSize: this.queue.length },
95
+ 'Enrichment job enqueued',
96
+ );
97
+
98
+ this.processNext();
99
+ }
100
+
101
+ /**
102
+ * Graceful shutdown: discard pending queue and wait for in-flight jobs only.
103
+ *
104
+ * Bounded shutdown time is more important than processing all pending
105
+ * enrichments. Enrichment is best-effort metadata and must never delay
106
+ * daemon shutdown materially. Pending jobs are counted as dropped.
107
+ */
108
+ async shutdown(): Promise<void> {
109
+ this.shuttingDown = true;
110
+
111
+ // Discard pending jobs — enrichment is best-effort and must not delay shutdown
112
+ if (this.queue.length > 0) {
113
+ const pendingCount = this.queue.length;
114
+ this.droppedCount += pendingCount;
115
+ this.queue = [];
116
+ log.info({ discarded: pendingCount, droppedCount: this.droppedCount }, 'Enrichment queue shutting down, discarded pending jobs');
117
+ }
118
+
119
+ // Wait for any in-flight workers to finish
120
+ if (this.inFlightPromises.size > 0) {
121
+ log.debug({ inFlight: this.inFlightPromises.size }, 'Waiting for in-flight enrichment jobs');
122
+ await Promise.all(this.inFlightPromises);
123
+ }
124
+
125
+ log.info(
126
+ { succeeded: this.succeededCount, failed: this.failedCount, dropped: this.droppedCount },
127
+ 'Enrichment queue shut down',
128
+ );
129
+ }
130
+
131
+ /** @internal Test-only: get queue size */
132
+ _getQueueSize(): number {
133
+ return this.queue.length;
134
+ }
135
+
136
+ /** @internal Test-only: get dropped count */
137
+ _getDroppedCount(): number {
138
+ return this.droppedCount;
139
+ }
140
+
141
+ /** @internal Test-only: get succeeded count */
142
+ _getSucceededCount(): number {
143
+ return this.succeededCount;
144
+ }
145
+
146
+ /** @internal Test-only: get failed count */
147
+ _getFailedCount(): number {
148
+ return this.failedCount;
149
+ }
150
+
151
+ /** @internal Test-only: get active workers */
152
+ _getActiveWorkers(): number {
153
+ return this.activeWorkers;
154
+ }
155
+
156
+ private processNext(): void {
157
+ if (this.shuttingDown) return;
158
+ if (this.activeWorkers >= this.maxConcurrency) return;
159
+ if (this.queue.length === 0) return;
160
+
161
+ const job = this.queue.shift()!;
162
+ this.activeWorkers++;
163
+
164
+ const promise = this.executeJob(job).finally(() => {
165
+ this.activeWorkers--;
166
+ this.inFlightPromises.delete(promise);
167
+ // Try to process next job after this one completes
168
+ this.processNext();
169
+ });
170
+
171
+ this.inFlightPromises.add(promise);
172
+ }
173
+
174
+ private async executeJob(job: InternalJob): Promise<void> {
175
+ job.attempts++;
176
+
177
+ try {
178
+ // Race the enrichment work against a timeout
179
+ await Promise.race([
180
+ this.doEnrichment(job),
181
+ new Promise<never>((_, reject) =>
182
+ setTimeout(() => reject(new Error('Enrichment job timed out')), this.jobTimeoutMs),
183
+ ),
184
+ ]);
185
+ this.succeededCount++;
186
+ log.debug(
187
+ { commitHash: job.commitHash, attempts: job.attempts },
188
+ 'Enrichment job completed',
189
+ );
190
+ } catch (err) {
191
+ const isTimeout = err instanceof Error && err.message === 'Enrichment job timed out';
192
+ if (job.attempts <= this.maxRetries) {
193
+ // Exponential backoff: 1s, 2s, 4s, ...
194
+ const backoffMs = 1000 * Math.pow(2, job.attempts - 1);
195
+ log.debug(
196
+ { commitHash: job.commitHash, attempts: job.attempts, backoffMs, timedOut: isTimeout, err },
197
+ isTimeout ? 'Enrichment job timed out, scheduling retry' : 'Enrichment job failed, scheduling retry',
198
+ );
199
+ await new Promise<void>((resolve) => setTimeout(resolve, backoffMs));
200
+
201
+ if (!this.shuttingDown) {
202
+ // Re-enqueue at front for retry (don't count against queue limit)
203
+ this.queue.unshift(job);
204
+ this.processNext();
205
+ } else {
206
+ // Can't retry during shutdown — count as failed
207
+ this.failedCount++;
208
+ }
209
+ return;
210
+ }
211
+
212
+ this.failedCount++;
213
+ log.warn(
214
+ { commitHash: job.commitHash, attempts: job.attempts, timedOut: isTimeout, err },
215
+ isTimeout ? 'Enrichment job timed out after max retries' : 'Enrichment job failed after max retries',
216
+ );
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Perform the actual enrichment work.
222
+ *
223
+ * Currently a no-op placeholder that writes a scaffold JSON note
224
+ * to prove the plumbing works. Future: call LLM to generate a
225
+ * rich commit description and write it as a git note.
226
+ */
227
+ private async doEnrichment(job: InternalJob): Promise<void> {
228
+ const note = JSON.stringify({
229
+ enriched: true,
230
+ trigger: job.context.trigger,
231
+ filesChanged: job.context.changedFiles.length,
232
+ timestamp: job.context.timestampMs,
233
+ sessionId: job.context.sessionId,
234
+ turnNumber: job.context.turnNumber,
235
+ });
236
+
237
+ await job.gitService.writeNote(job.commitHash, note);
238
+ }
239
+ }
240
+
241
+ /** Singleton enrichment service instance. */
242
+ let enrichmentService: CommitEnrichmentService | null = null;
243
+
244
+ /**
245
+ * Get the global enrichment service singleton.
246
+ * Created lazily on first access.
247
+ */
248
+ export function getEnrichmentService(): CommitEnrichmentService {
249
+ if (!enrichmentService) {
250
+ enrichmentService = new CommitEnrichmentService();
251
+ }
252
+ return enrichmentService;
253
+ }
254
+
255
+ /**
256
+ * @internal Test-only: reset the singleton
257
+ */
258
+ export function _resetEnrichmentService(): void {
259
+ enrichmentService = null;
260
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Abstraction for generating commit messages across different triggers.
3
+ *
4
+ * Provides a seam for future LLM-powered enrichment without changing
5
+ * the synchronous commit path.
6
+ */
7
+
8
+ export interface CommitContext {
9
+ workspaceDir: string;
10
+ trigger: 'turn' | 'heartbeat' | 'shutdown';
11
+ sessionId?: string;
12
+ turnNumber?: number;
13
+ changedFiles: string[];
14
+ timestampMs: number;
15
+ /** Optional reason string (used by heartbeat to describe threshold exceeded). */
16
+ reason?: string;
17
+ }
18
+
19
+ export interface CommitMessageResult {
20
+ message: string;
21
+ metadata?: Record<string, unknown>;
22
+ }
23
+
24
+ export interface CommitMessageProvider {
25
+ /** Build a commit message synchronously for immediate use. */
26
+ buildImmediateMessage(ctx: CommitContext): CommitMessageResult;
27
+ /** Optional: enqueue async enrichment after commit succeeds. */
28
+ enqueueEnrichment?(ctx: CommitContext & { commitHash: string }): Promise<void>;
29
+ }
30
+
31
+ /**
32
+ * Build a short summary of what changed from a list of file paths.
33
+ */
34
+ export function buildChangeSummary(files: string[]): string {
35
+ if (files.length === 0) {
36
+ return 'workspace changes';
37
+ }
38
+ if (files.length === 1) {
39
+ return files[0];
40
+ }
41
+ if (files.length <= 3) {
42
+ return files.join(', ');
43
+ }
44
+ return `${files.slice(0, 2).join(', ')} and ${files.length - 2} more`;
45
+ }
46
+
47
+ /**
48
+ * Default deterministic commit message provider.
49
+ *
50
+ * Produces identical output to the pre-refactor inline logic in
51
+ * turn-commit.ts and heartbeat-service.ts.
52
+ */
53
+ export class DefaultCommitMessageProvider implements CommitMessageProvider {
54
+ buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
55
+ switch (ctx.trigger) {
56
+ case 'turn':
57
+ return this.buildTurnMessage(ctx);
58
+ case 'heartbeat':
59
+ return this.buildHeartbeatMessage(ctx);
60
+ case 'shutdown':
61
+ return this.buildShutdownMessage(ctx);
62
+ }
63
+ }
64
+
65
+ private buildTurnMessage(ctx: CommitContext): CommitMessageResult {
66
+ const summary = buildChangeSummary(ctx.changedFiles);
67
+ const timestamp = new Date(ctx.timestampMs).toISOString();
68
+ const message = [
69
+ `Turn: ${summary}`,
70
+ '',
71
+ `Session: ${ctx.sessionId}`,
72
+ `Turn: ${ctx.turnNumber}`,
73
+ `Timestamp: ${timestamp}`,
74
+ `Files: ${ctx.changedFiles.length} changed`,
75
+ ].join('\n');
76
+ return { message };
77
+ }
78
+
79
+ private buildHeartbeatMessage(ctx: CommitContext): CommitMessageResult {
80
+ const totalChanges = ctx.changedFiles.length;
81
+ const reason = ctx.reason ?? `${totalChanges} files`;
82
+ return {
83
+ message: `auto-commit: heartbeat safety net (${totalChanges} files, ${reason})`,
84
+ metadata: { trigger: 'heartbeat', timestamp: ctx.timestampMs },
85
+ };
86
+ }
87
+
88
+ private buildShutdownMessage(ctx: CommitContext): CommitMessageResult {
89
+ const totalChanges = ctx.changedFiles.length;
90
+ return {
91
+ message: `auto-commit: shutdown safety net (${totalChanges} files)`,
92
+ metadata: { trigger: 'shutdown', timestamp: ctx.timestampMs },
93
+ };
94
+ }
95
+ }