trellis 1.0.8 → 2.0.5

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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +533 -82
  3. package/bin/trellis.mjs +2 -0
  4. package/dist/cli/index.js +4718 -0
  5. package/dist/core/index.js +12 -0
  6. package/dist/decisions/index.js +19 -0
  7. package/dist/embeddings/index.js +43 -0
  8. package/dist/index-1j1anhmr.js +4038 -0
  9. package/dist/index-3s0eak0p.js +1556 -0
  10. package/dist/index-8pce39mh.js +272 -0
  11. package/dist/index-a76rekgs.js +67 -0
  12. package/dist/index-cy9k1g6v.js +684 -0
  13. package/dist/index-fd4e26s4.js +69 -0
  14. package/dist/{store/eav-store.js → index-gkvhzm9f.js} +4 -6
  15. package/dist/index-gnw8d7d6.js +51 -0
  16. package/dist/index-vkpkfwhq.js +817 -0
  17. package/dist/index.js +118 -2876
  18. package/dist/links/index.js +55 -0
  19. package/dist/transformers-m9je15kg.js +32491 -0
  20. package/dist/vcs/index.js +110 -0
  21. package/logo.png +0 -0
  22. package/logo.svg +9 -0
  23. package/package.json +79 -76
  24. package/src/cli/index.ts +2340 -0
  25. package/src/core/index.ts +35 -0
  26. package/src/core/kernel/middleware.ts +44 -0
  27. package/src/core/persist/backend.ts +64 -0
  28. package/src/core/store/eav-store.ts +467 -0
  29. package/src/decisions/auto-capture.ts +136 -0
  30. package/src/decisions/hooks.ts +163 -0
  31. package/src/decisions/index.ts +261 -0
  32. package/src/decisions/types.ts +103 -0
  33. package/src/embeddings/chunker.ts +327 -0
  34. package/src/embeddings/index.ts +41 -0
  35. package/src/embeddings/model.ts +95 -0
  36. package/src/embeddings/search.ts +305 -0
  37. package/src/embeddings/store.ts +313 -0
  38. package/src/embeddings/types.ts +85 -0
  39. package/src/engine.ts +1083 -0
  40. package/src/garden/cluster.ts +330 -0
  41. package/src/garden/garden.ts +306 -0
  42. package/src/garden/index.ts +29 -0
  43. package/src/git/git-exporter.ts +286 -0
  44. package/src/git/git-importer.ts +329 -0
  45. package/src/git/git-reader.ts +189 -0
  46. package/src/git/index.ts +22 -0
  47. package/src/identity/governance.ts +211 -0
  48. package/src/identity/identity.ts +224 -0
  49. package/src/identity/index.ts +30 -0
  50. package/src/identity/signing-middleware.ts +97 -0
  51. package/src/index.ts +20 -0
  52. package/src/links/index.ts +49 -0
  53. package/src/links/lifecycle.ts +400 -0
  54. package/src/links/parser.ts +484 -0
  55. package/src/links/ref-index.ts +186 -0
  56. package/src/links/resolver.ts +314 -0
  57. package/src/links/types.ts +108 -0
  58. package/src/mcp/index.ts +22 -0
  59. package/src/mcp/server.ts +1278 -0
  60. package/src/semantic/csharp-parser.ts +493 -0
  61. package/src/semantic/go-parser.ts +585 -0
  62. package/src/semantic/index.ts +34 -0
  63. package/src/semantic/java-parser.ts +456 -0
  64. package/src/semantic/python-parser.ts +659 -0
  65. package/src/semantic/ruby-parser.ts +446 -0
  66. package/src/semantic/rust-parser.ts +784 -0
  67. package/src/semantic/semantic-merge.ts +210 -0
  68. package/src/semantic/ts-parser.ts +681 -0
  69. package/src/semantic/types.ts +175 -0
  70. package/src/sync/index.ts +32 -0
  71. package/src/sync/memory-transport.ts +66 -0
  72. package/src/sync/reconciler.ts +237 -0
  73. package/src/sync/sync-engine.ts +258 -0
  74. package/src/sync/types.ts +104 -0
  75. package/src/vcs/blob-store.ts +124 -0
  76. package/src/vcs/branch.ts +150 -0
  77. package/src/vcs/checkpoint.ts +64 -0
  78. package/src/vcs/decompose.ts +469 -0
  79. package/src/vcs/diff.ts +409 -0
  80. package/src/vcs/engine-context.ts +26 -0
  81. package/src/vcs/index.ts +23 -0
  82. package/src/vcs/issue.ts +800 -0
  83. package/src/vcs/merge.ts +425 -0
  84. package/src/vcs/milestone.ts +124 -0
  85. package/src/vcs/ops.ts +59 -0
  86. package/src/vcs/types.ts +213 -0
  87. package/src/vcs/vcs-middleware.ts +81 -0
  88. package/src/watcher/fs-watcher.ts +217 -0
  89. package/src/watcher/index.ts +9 -0
  90. package/src/watcher/ingestion.ts +116 -0
  91. package/dist/ai/index.js +0 -688
  92. package/dist/cli/server.js +0 -3321
  93. package/dist/cli/tql.js +0 -5282
  94. package/dist/client/tql-client.js +0 -108
  95. package/dist/graph/index.js +0 -2248
  96. package/dist/kernel/logic-middleware.js +0 -179
  97. package/dist/kernel/middleware.js +0 -0
  98. package/dist/kernel/operations.js +0 -32
  99. package/dist/kernel/schema-middleware.js +0 -34
  100. package/dist/kernel/security-middleware.js +0 -53
  101. package/dist/kernel/trellis-kernel.js +0 -2239
  102. package/dist/kernel/workspace.js +0 -91
  103. package/dist/persist/backend.js +0 -0
  104. package/dist/persist/sqlite-backend.js +0 -123
  105. package/dist/query/index.js +0 -1643
  106. package/dist/server/index.js +0 -3309
  107. package/dist/workflows/index.js +0 -3160
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Merge Engine
3
+ *
4
+ * Three-way file-level merge with text-based fallback (Tier 0 / P3).
5
+ * Merges a source branch into the current branch by:
6
+ * 1. Finding the common ancestor (fork point) in the op stream
7
+ * 2. Building file states at ancestor, ours, and theirs
8
+ * 3. Producing a merged file state or conflicts
9
+ *
10
+ * DESIGN.md §4.4 — Patch Commutativity and Conflict Detection
11
+ */
12
+
13
+ import type { VcsOp } from './types.js';
14
+ import type { BlobStore } from './blob-store.js';
15
+ import { buildFileStateAtOp, type FileState } from './diff.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface MergeConflict {
22
+ path: string;
23
+ kind: 'modify-modify' | 'modify-delete' | 'add-add';
24
+ /** Content from the current (ours) branch. */
25
+ ours?: string;
26
+ /** Content from the source (theirs) branch. */
27
+ theirs?: string;
28
+ /** Content from the common ancestor. */
29
+ base?: string;
30
+ /** For text conflicts: attempted merge with conflict markers. */
31
+ mergedWithMarkers?: string;
32
+ }
33
+
34
+ export interface MergeResult {
35
+ /** True if merge completed without conflicts. */
36
+ clean: boolean;
37
+ /** Merged file states to apply (path → content string). */
38
+ mergedFiles: Map<string, string | null>; // null = delete
39
+ /** Conflicts requiring manual resolution. */
40
+ conflicts: MergeConflict[];
41
+ /** Summary stats. */
42
+ stats: {
43
+ added: number;
44
+ modified: number;
45
+ deleted: number;
46
+ conflicted: number;
47
+ };
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Three-way merge
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Perform a three-way merge given ancestor, ours, and theirs file states.
56
+ */
57
+ export function threeWayMerge(
58
+ base: Map<string, FileState>,
59
+ ours: Map<string, FileState>,
60
+ theirs: Map<string, FileState>,
61
+ blobStore?: BlobStore | null,
62
+ ): MergeResult {
63
+ const mergedFiles = new Map<string, string | null>();
64
+ const conflicts: MergeConflict[] = [];
65
+
66
+ // Collect all file paths across all three states
67
+ const allPaths = new Set<string>();
68
+ for (const [p, s] of base) if (!s.deleted) allPaths.add(p);
69
+ for (const [p, s] of ours) if (!s.deleted) allPaths.add(p);
70
+ for (const [p, s] of theirs) if (!s.deleted) allPaths.add(p);
71
+
72
+ // Also track deleted paths
73
+ for (const [p, s] of ours) if (s.deleted) allPaths.add(p);
74
+ for (const [p, s] of theirs) if (s.deleted) allPaths.add(p);
75
+
76
+ for (const path of allPaths) {
77
+ const b = base.get(path);
78
+ const o = ours.get(path);
79
+ const t = theirs.get(path);
80
+
81
+ const baseExists = b && !b.deleted;
82
+ const oursExists = o && !o.deleted;
83
+ const theirsExists = t && !t.deleted;
84
+
85
+ const baseHash = baseExists ? b.contentHash : undefined;
86
+ const oursHash = oursExists ? o.contentHash : undefined;
87
+ const theirsHash = theirsExists ? t.contentHash : undefined;
88
+
89
+ // Neither side changed
90
+ if (oursHash === theirsHash) {
91
+ // Both same — no-op (keep ours)
92
+ continue;
93
+ }
94
+
95
+ // Only ours changed (theirs same as base)
96
+ if (theirsHash === baseHash && oursHash !== baseHash) {
97
+ if (!oursExists) {
98
+ mergedFiles.set(path, null); // we deleted
99
+ }
100
+ // else keep ours (already in our state)
101
+ continue;
102
+ }
103
+
104
+ // Only theirs changed (ours same as base)
105
+ if (oursHash === baseHash && theirsHash !== baseHash) {
106
+ if (!theirsExists) {
107
+ mergedFiles.set(path, null); // they deleted
108
+ } else if (theirsHash && blobStore) {
109
+ const content = blobStore.get(theirsHash);
110
+ if (content) {
111
+ mergedFiles.set(path, content.toString('utf-8'));
112
+ }
113
+ }
114
+ continue;
115
+ }
116
+
117
+ // Both sides changed — potential conflict
118
+
119
+ // Case: both added (not in base)
120
+ if (!baseExists && oursExists && theirsExists) {
121
+ if (oursHash === theirsHash) {
122
+ continue; // identical add — no conflict
123
+ }
124
+ const oursContent = oursHash && blobStore ? blobStore.get(oursHash)?.toString('utf-8') : undefined;
125
+ const theirsContent = theirsHash && blobStore ? blobStore.get(theirsHash)?.toString('utf-8') : undefined;
126
+
127
+ // Try text merge with empty base
128
+ if (oursContent !== undefined && theirsContent !== undefined) {
129
+ const textResult = threeWayTextMerge('', oursContent, theirsContent);
130
+ if (textResult.clean) {
131
+ mergedFiles.set(path, textResult.merged);
132
+ continue;
133
+ }
134
+ conflicts.push({
135
+ path,
136
+ kind: 'add-add',
137
+ ours: oursContent,
138
+ theirs: theirsContent,
139
+ mergedWithMarkers: textResult.merged,
140
+ });
141
+ } else {
142
+ conflicts.push({ path, kind: 'add-add', ours: oursContent, theirs: theirsContent });
143
+ }
144
+ continue;
145
+ }
146
+
147
+ // Case: one side deleted, other modified
148
+ if (oursExists && !theirsExists) {
149
+ conflicts.push({
150
+ path,
151
+ kind: 'modify-delete',
152
+ ours: oursHash && blobStore ? blobStore.get(oursHash)?.toString('utf-8') : undefined,
153
+ });
154
+ continue;
155
+ }
156
+ if (!oursExists && theirsExists) {
157
+ conflicts.push({
158
+ path,
159
+ kind: 'modify-delete',
160
+ theirs: theirsHash && blobStore ? blobStore.get(theirsHash)?.toString('utf-8') : undefined,
161
+ });
162
+ continue;
163
+ }
164
+
165
+ // Case: both modified (both exist, different hashes)
166
+ if (oursExists && theirsExists && oursHash !== theirsHash) {
167
+ const baseContent = baseHash && blobStore ? blobStore.get(baseHash)?.toString('utf-8') : undefined;
168
+ const oursContent = oursHash && blobStore ? blobStore.get(oursHash)?.toString('utf-8') : undefined;
169
+ const theirsContent = theirsHash && blobStore ? blobStore.get(theirsHash)?.toString('utf-8') : undefined;
170
+
171
+ if (baseContent !== undefined && oursContent !== undefined && theirsContent !== undefined) {
172
+ const textResult = threeWayTextMerge(baseContent, oursContent, theirsContent);
173
+ if (textResult.clean) {
174
+ mergedFiles.set(path, textResult.merged);
175
+ } else {
176
+ conflicts.push({
177
+ path,
178
+ kind: 'modify-modify',
179
+ base: baseContent,
180
+ ours: oursContent,
181
+ theirs: theirsContent,
182
+ mergedWithMarkers: textResult.merged,
183
+ });
184
+ }
185
+ } else {
186
+ conflicts.push({
187
+ path,
188
+ kind: 'modify-modify',
189
+ base: baseContent,
190
+ ours: oursContent,
191
+ theirs: theirsContent,
192
+ });
193
+ }
194
+ continue;
195
+ }
196
+ }
197
+
198
+ const added = [...mergedFiles.values()].filter((v) => v !== null).length;
199
+ const deleted = [...mergedFiles.values()].filter((v) => v === null).length;
200
+
201
+ return {
202
+ clean: conflicts.length === 0,
203
+ mergedFiles,
204
+ conflicts,
205
+ stats: {
206
+ added,
207
+ modified: added, // in a merge context, additions from theirs are "modified"
208
+ deleted,
209
+ conflicted: conflicts.length,
210
+ },
211
+ };
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Three-way text merge
216
+ // ---------------------------------------------------------------------------
217
+
218
+ export interface TextMergeResult {
219
+ clean: boolean;
220
+ merged: string;
221
+ }
222
+
223
+ /**
224
+ * Three-way line-level text merge.
225
+ * Uses a simple approach: diff base→ours and base→theirs, then interleave.
226
+ * Produces conflict markers when both sides change the same region.
227
+ */
228
+ export function threeWayTextMerge(
229
+ baseText: string,
230
+ oursText: string,
231
+ theirsText: string,
232
+ ): TextMergeResult {
233
+ const baseLines = baseText.split('\n');
234
+ const oursLines = oursText.split('\n');
235
+ const theirsLines = theirsText.split('\n');
236
+
237
+ // Build change maps: line index in base → what each side did
238
+ const oursChanges = computeLineChanges(baseLines, oursLines);
239
+ const theirsChanges = computeLineChanges(baseLines, theirsLines);
240
+
241
+ const result: string[] = [];
242
+ let clean = true;
243
+
244
+ let baseIdx = 0;
245
+ let oursIdx = 0;
246
+ let theirsIdx = 0;
247
+
248
+ while (baseIdx < baseLines.length || oursIdx < oursLines.length || theirsIdx < theirsLines.length) {
249
+ const oursChange = oursChanges.get(baseIdx);
250
+ const theirsChange = theirsChanges.get(baseIdx);
251
+
252
+ if (baseIdx >= baseLines.length) {
253
+ // Past base — append remaining from both sides
254
+ // Ours remaining
255
+ while (oursIdx < oursLines.length) {
256
+ result.push(oursLines[oursIdx++]);
257
+ }
258
+ // Theirs remaining
259
+ while (theirsIdx < theirsLines.length) {
260
+ result.push(theirsLines[theirsIdx++]);
261
+ }
262
+ break;
263
+ }
264
+
265
+ if (!oursChange && !theirsChange) {
266
+ // Both kept the line unchanged
267
+ result.push(baseLines[baseIdx]);
268
+ baseIdx++;
269
+ oursIdx++;
270
+ theirsIdx++;
271
+ } else if (oursChange && !theirsChange) {
272
+ // Only ours changed
273
+ applyChange(oursChange, result);
274
+ baseIdx += oursChange.baseCount;
275
+ oursIdx += oursChange.newCount;
276
+ theirsIdx += oursChange.baseCount;
277
+ } else if (!oursChange && theirsChange) {
278
+ // Only theirs changed
279
+ applyChange(theirsChange, result);
280
+ baseIdx += theirsChange.baseCount;
281
+ oursIdx += theirsChange.baseCount;
282
+ theirsIdx += theirsChange.newCount;
283
+ } else if (oursChange && theirsChange) {
284
+ // Both changed — check if identical
285
+ if (
286
+ oursChange.baseCount === theirsChange.baseCount &&
287
+ oursChange.newLines.join('\n') === theirsChange.newLines.join('\n')
288
+ ) {
289
+ // Identical change — apply once
290
+ applyChange(oursChange, result);
291
+ baseIdx += oursChange.baseCount;
292
+ oursIdx += oursChange.newCount;
293
+ theirsIdx += theirsChange.newCount;
294
+ } else {
295
+ // Conflict
296
+ clean = false;
297
+ result.push('<<<<<<< ours');
298
+ for (const line of oursChange.newLines) result.push(line);
299
+ result.push('=======');
300
+ for (const line of theirsChange.newLines) result.push(line);
301
+ result.push('>>>>>>> theirs');
302
+ baseIdx += Math.max(oursChange.baseCount, theirsChange.baseCount);
303
+ oursIdx += oursChange.newCount;
304
+ theirsIdx += theirsChange.newCount;
305
+ }
306
+ }
307
+ }
308
+
309
+ return { clean, merged: result.join('\n') };
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Line change detection
314
+ // ---------------------------------------------------------------------------
315
+
316
+ interface LineChange {
317
+ baseStart: number;
318
+ baseCount: number;
319
+ newCount: number;
320
+ newLines: string[];
321
+ }
322
+
323
+ function applyChange(change: LineChange, result: string[]): void {
324
+ for (const line of change.newLines) {
325
+ result.push(line);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Compute a map of base line index → change region.
331
+ * Uses LCS (longest common subsequence) to detect changed regions.
332
+ */
333
+ function computeLineChanges(
334
+ baseLines: string[],
335
+ newLines: string[],
336
+ ): Map<number, LineChange> {
337
+ const changes = new Map<number, LineChange>();
338
+ const matches = lcsMatch(baseLines, newLines);
339
+
340
+ let baseIdx = 0;
341
+ let newIdx = 0;
342
+
343
+ for (const match of matches) {
344
+ // Process gap before this match
345
+ if (baseIdx < match.baseIdx || newIdx < match.newIdx) {
346
+ const baseCount = match.baseIdx - baseIdx;
347
+ const newCount = match.newIdx - newIdx;
348
+ if (baseCount > 0 || newCount > 0) {
349
+ changes.set(baseIdx, {
350
+ baseStart: baseIdx,
351
+ baseCount,
352
+ newCount,
353
+ newLines: newLines.slice(newIdx, newIdx + newCount),
354
+ });
355
+ }
356
+ }
357
+ baseIdx = match.baseIdx + 1;
358
+ newIdx = match.newIdx + 1;
359
+ }
360
+
361
+ // Handle trailing gap
362
+ if (baseIdx < baseLines.length || newIdx < newLines.length) {
363
+ const baseCount = baseLines.length - baseIdx;
364
+ const newCount = newLines.length - newIdx;
365
+ if (baseCount > 0 || newCount > 0) {
366
+ changes.set(baseIdx, {
367
+ baseStart: baseIdx,
368
+ baseCount,
369
+ newCount,
370
+ newLines: newLines.slice(newIdx),
371
+ });
372
+ }
373
+ }
374
+
375
+ return changes;
376
+ }
377
+
378
+ interface LCSMatch {
379
+ baseIdx: number;
380
+ newIdx: number;
381
+ }
382
+
383
+ /**
384
+ * Find the LCS (longest common subsequence) matches between two line arrays.
385
+ * Returns an ordered list of matched line index pairs.
386
+ */
387
+ function lcsMatch(a: string[], b: string[]): LCSMatch[] {
388
+ const n = a.length;
389
+ const m = b.length;
390
+
391
+ if (n === 0 || m === 0) return [];
392
+
393
+ // DP table
394
+ const dp: number[][] = Array.from({ length: n + 1 }, () =>
395
+ new Array(m + 1).fill(0),
396
+ );
397
+
398
+ for (let i = 1; i <= n; i++) {
399
+ for (let j = 1; j <= m; j++) {
400
+ if (a[i - 1] === b[j - 1]) {
401
+ dp[i][j] = dp[i - 1][j - 1] + 1;
402
+ } else {
403
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
404
+ }
405
+ }
406
+ }
407
+
408
+ // Backtrack
409
+ const matches: LCSMatch[] = [];
410
+ let i = n;
411
+ let j = m;
412
+ while (i > 0 && j > 0) {
413
+ if (a[i - 1] === b[j - 1]) {
414
+ matches.unshift({ baseIdx: i - 1, newIdx: j - 1 });
415
+ i--;
416
+ j--;
417
+ } else if (dp[i - 1][j] > dp[i][j - 1]) {
418
+ i--;
419
+ } else {
420
+ j--;
421
+ }
422
+ }
423
+
424
+ return matches;
425
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Milestone Module
3
+ *
4
+ * Extracted from engine.ts per DESIGN.md §8.1.
5
+ * Handles milestone creation, listing, and op-range computation.
6
+ */
7
+
8
+ import { createVcsOp } from './ops.js';
9
+ import type { VcsOp } from './types.js';
10
+ import type { EngineContext } from './engine-context.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface MilestoneInfo {
17
+ id: string;
18
+ message?: string;
19
+ createdAt?: string;
20
+ createdBy?: string;
21
+ fromOpHash?: string;
22
+ toOpHash?: string;
23
+ affectedFiles: string[];
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Operations
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Create a milestone spanning a range of ops.
32
+ * If no fromOpHash is specified, spans from the last milestone (or start).
33
+ */
34
+ export async function createMilestone(
35
+ ctx: EngineContext,
36
+ message: string,
37
+ opts?: {
38
+ fromOpHash?: string;
39
+ toOpHash?: string;
40
+ },
41
+ ): Promise<VcsOp> {
42
+ const ops = ctx.readAllOps();
43
+ const toOpHash = opts?.toOpHash ?? ops[ops.length - 1]?.hash;
44
+
45
+ // Find the start: either specified, or the op after the last milestone
46
+ let fromOpHash = opts?.fromOpHash;
47
+ if (!fromOpHash) {
48
+ const milestones = ops.filter((o) => o.kind === 'vcs:milestoneCreate');
49
+ if (milestones.length > 0) {
50
+ const lastMilestone = milestones[milestones.length - 1];
51
+ fromOpHash = lastMilestone.vcs?.toOpHash ?? lastMilestone.hash;
52
+ } else {
53
+ fromOpHash = ops[0]?.hash;
54
+ }
55
+ }
56
+
57
+ // Generate milestone ID
58
+ const idBase = `${message}:${Date.now()}`;
59
+ const msgUint8 = new TextEncoder().encode(idBase);
60
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
61
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
62
+ const hashHex = hashArray
63
+ .map((b) => b.toString(16).padStart(2, '0'))
64
+ .join('');
65
+ const milestoneId = `milestone:${hashHex.slice(0, 12)}`;
66
+
67
+ // Determine affected files in the range
68
+ const fromIdx = ops.findIndex((o) => o.hash === fromOpHash);
69
+ const toIdx = ops.findIndex((o) => o.hash === toOpHash);
70
+ const rangeOps =
71
+ fromIdx >= 0 && toIdx >= 0 ? ops.slice(fromIdx, toIdx + 1) : ops;
72
+ const affectedFiles = [
73
+ ...new Set(
74
+ rangeOps.filter((o) => o.vcs?.filePath).map((o) => o.vcs!.filePath!),
75
+ ),
76
+ ];
77
+
78
+ const op = await createVcsOp('vcs:milestoneCreate', {
79
+ agentId: ctx.agentId,
80
+ previousHash: ctx.getLastOp()?.hash,
81
+ vcs: {
82
+ milestoneId,
83
+ message,
84
+ fromOpHash,
85
+ toOpHash,
86
+ },
87
+ });
88
+ ctx.applyOp(op);
89
+
90
+ // Store affected files as multi-valued facts
91
+ for (const file of affectedFiles) {
92
+ ctx.store.addFacts([{ e: milestoneId, a: 'affectsFile', v: file }]);
93
+ }
94
+
95
+ return op;
96
+ }
97
+
98
+ /**
99
+ * List all milestones from the EAV store.
100
+ */
101
+ export function listMilestones(ctx: EngineContext): MilestoneInfo[] {
102
+ const milestoneFacts = ctx.store
103
+ .getFactsByAttribute('type')
104
+ .filter((f) => f.v === 'Milestone');
105
+
106
+ return milestoneFacts.map((f) => {
107
+ const facts = ctx.store.getFactsByEntity(f.e);
108
+ const get = (attr: string) =>
109
+ facts.find((ef) => ef.a === attr)?.v as string | undefined;
110
+ const affectedFiles = facts
111
+ .filter((ef) => ef.a === 'affectsFile')
112
+ .map((ef) => ef.v as string);
113
+
114
+ return {
115
+ id: f.e,
116
+ message: get('message'),
117
+ createdAt: get('createdAt'),
118
+ createdBy: get('createdBy'),
119
+ fromOpHash: get('fromOpHash'),
120
+ toOpHash: get('toOpHash'),
121
+ affectedFiles,
122
+ };
123
+ });
124
+ }
package/src/vcs/ops.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * VCS Operation Constructors
3
+ *
4
+ * Helpers to create content-addressed VcsOps with proper
5
+ * causality chaining and metadata.
6
+ */
7
+
8
+ import type { VcsOp, VcsOpKind, VcsPayload } from './types.js';
9
+
10
+ /**
11
+ * Creates a VcsOp with full metadata, hash, and causal chain link.
12
+ */
13
+ export async function createVcsOp(
14
+ kind: VcsOpKind,
15
+ params: {
16
+ agentId: string;
17
+ previousHash?: string;
18
+ vcs: VcsPayload;
19
+ },
20
+ ): Promise<VcsOp> {
21
+ const opBase = {
22
+ kind,
23
+ timestamp: new Date().toISOString(),
24
+ agentId: params.agentId,
25
+ previousHash: params.previousHash,
26
+ vcs: params.vcs,
27
+ };
28
+
29
+ // Hash covers the full op including VCS payload for content-addressability.
30
+ const content = JSON.stringify(opBase);
31
+ const msgUint8 = new TextEncoder().encode(content);
32
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
33
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
34
+ const hashHex = hashArray
35
+ .map((b) => b.toString(16).padStart(2, '0'))
36
+ .join('');
37
+
38
+ return {
39
+ ...opBase,
40
+ hash: `trellis:op:${hashHex}`,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Checks whether a KernelOp is a VcsOp (has a vcs payload).
46
+ */
47
+ export function isVcsOp(op: { kind: string; vcs?: unknown }): op is VcsOp {
48
+ return (
49
+ op.vcs !== undefined ||
50
+ (typeof op.kind === 'string' && op.kind.startsWith('vcs:'))
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Checks whether an op kind is a VCS kind.
56
+ */
57
+ export function isVcsOpKind(kind: string): kind is VcsOpKind {
58
+ return kind.startsWith('vcs:');
59
+ }