trellis 1.0.8 → 2.0.6

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 +564 -83
  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
package/src/engine.ts ADDED
@@ -0,0 +1,1083 @@
1
+ /**
2
+ * TrellisVCS Engine
3
+ *
4
+ * The composition root that ties together the trellis-core kernel,
5
+ * the file watcher, the ingestion pipeline, and VCS middleware.
6
+ *
7
+ * Usage:
8
+ * const engine = new TrellisVcsEngine({ rootPath: '/path/to/repo' });
9
+ * await engine.init(); // scan + create initial ops
10
+ * engine.watch(); // start continuous monitoring
11
+ * engine.stop(); // stop watcher
12
+ */
13
+
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ copyFileSync,
20
+ } from 'fs';
21
+ import { readFile } from 'fs/promises';
22
+ import { join, dirname } from 'path';
23
+ import { EAVStore } from './core/store/eav-store.js';
24
+ import type { Fact, Link } from './core/store/eav-store.js';
25
+ import { FileWatcher } from './watcher/fs-watcher.js';
26
+ import { Ingestion } from './watcher/ingestion.js';
27
+ import { decompose } from './vcs/decompose.js';
28
+ import { createVcsOp, isVcsOpKind } from './vcs/ops.js';
29
+ import type { VcsOp, TrellisVcsConfig, FileChangeEvent } from './vcs/types.js';
30
+ import { DEFAULT_CONFIG } from './vcs/types.js';
31
+ import { BlobStore } from './vcs/blob-store.js';
32
+ import type { EngineContext } from './vcs/engine-context.js';
33
+ import * as branchMod from './vcs/branch.js';
34
+ import * as milestoneMod from './vcs/milestone.js';
35
+ import * as checkpointMod from './vcs/checkpoint.js';
36
+ import * as diffMod from './vcs/diff.js';
37
+ import * as mergeMod from './vcs/merge.js';
38
+ import * as issueMod from './vcs/issue.js';
39
+ import * as decisionMod from './decisions/index.js';
40
+ import { IdeaGarden, buildMilestonedOpHashes } from './garden/index.js';
41
+ import {
42
+ typescriptParser,
43
+ pythonParser,
44
+ goParser,
45
+ rustParser,
46
+ rubyParser,
47
+ javaParser,
48
+ csharpParser,
49
+ } from './semantic/index.js';
50
+ import type {
51
+ ParseResult,
52
+ SemanticPatch,
53
+ ParserAdapter,
54
+ } from './semantic/types.js';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Persistent op log (lightweight SQLite-free version for P0)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * A simple JSON-file-backed op log for P0.
62
+ * Will be replaced by SqliteKernelBackend integration in P1.
63
+ */
64
+ class JsonOpLog {
65
+ private ops: VcsOp[] = [];
66
+ private filePath: string;
67
+
68
+ constructor(filePath: string) {
69
+ this.filePath = filePath;
70
+ }
71
+
72
+ load(): void {
73
+ if (existsSync(this.filePath)) {
74
+ const raw = readFileSync(this.filePath, 'utf-8');
75
+ try {
76
+ this.ops = JSON.parse(raw);
77
+ } catch (err) {
78
+ // Attempt to load from backup
79
+ const backupPath = this.filePath + '.bak';
80
+ if (existsSync(backupPath)) {
81
+ const backupRaw = readFileSync(backupPath, 'utf-8');
82
+ this.ops = JSON.parse(backupRaw);
83
+ // Restore the backup over the corrupted file
84
+ writeFileSync(this.filePath, backupRaw);
85
+ } else {
86
+ throw new Error(
87
+ `Corrupted ops.json and no backup found. Run \`trellis repair\` to attempt recovery.`,
88
+ );
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ append(op: VcsOp): void {
95
+ this.ops.push(op);
96
+ this.flush();
97
+ }
98
+
99
+ readAll(): VcsOp[] {
100
+ return [...this.ops];
101
+ }
102
+
103
+ getLastOp(): VcsOp | undefined {
104
+ return this.ops.length > 0 ? this.ops[this.ops.length - 1] : undefined;
105
+ }
106
+
107
+ count(): number {
108
+ return this.ops.length;
109
+ }
110
+
111
+ private flush(): void {
112
+ const dir = dirname(this.filePath);
113
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
114
+ // Backup-on-write: keep one generation of backup
115
+ if (existsSync(this.filePath)) {
116
+ const backupPath = this.filePath + '.bak';
117
+ try {
118
+ copyFileSync(this.filePath, backupPath);
119
+ } catch {
120
+ // Best-effort backup — don't block writes
121
+ }
122
+ }
123
+ writeFileSync(this.filePath, JSON.stringify(this.ops, null, 2));
124
+ }
125
+
126
+ /**
127
+ * Attempt to repair a corrupted ops.json by truncating to the
128
+ * last valid entry. Returns the number of recovered ops.
129
+ */
130
+ static repair(filePath: string): { recovered: number; lost: number } {
131
+ if (!existsSync(filePath)) {
132
+ return { recovered: 0, lost: 0 };
133
+ }
134
+
135
+ const raw = readFileSync(filePath, 'utf-8');
136
+
137
+ // Try parsing as-is first
138
+ try {
139
+ const ops = JSON.parse(raw);
140
+ return { recovered: ops.length, lost: 0 };
141
+ } catch {
142
+ // Corrupted — attempt truncation repair
143
+ }
144
+
145
+ // Find the last complete object by locating the last valid hash line
146
+ const lastHash = raw.lastIndexOf('"hash": "trellis:op:');
147
+ if (lastHash === -1) {
148
+ // Check backup
149
+ const bakPath = filePath + '.bak';
150
+ if (existsSync(bakPath)) {
151
+ const bakRaw = readFileSync(bakPath, 'utf-8');
152
+ try {
153
+ const ops = JSON.parse(bakRaw);
154
+ writeFileSync(filePath, bakRaw);
155
+ return { recovered: ops.length, lost: 0 };
156
+ } catch {
157
+ // Backup also corrupted
158
+ }
159
+ }
160
+ writeFileSync(filePath, '[]');
161
+ return { recovered: 0, lost: -1 };
162
+ }
163
+
164
+ // Find end of the hash line → closing brace of that object
165
+ const endOfLine = raw.indexOf('\n', lastHash);
166
+ const closingBrace = raw.indexOf(' }', endOfLine);
167
+ if (closingBrace === -1) {
168
+ writeFileSync(filePath, '[]');
169
+ return { recovered: 0, lost: -1 };
170
+ }
171
+
172
+ const fixed = raw.slice(0, closingBrace + 3) + '\n]';
173
+ try {
174
+ const ops = JSON.parse(fixed);
175
+ // Save repaired + backup of corrupted
176
+ writeFileSync(filePath + '.corrupted', raw);
177
+ writeFileSync(filePath, fixed);
178
+ return { recovered: ops.length, lost: 0 };
179
+ } catch {
180
+ writeFileSync(filePath + '.corrupted', raw);
181
+ writeFileSync(filePath, '[]');
182
+ return { recovered: 0, lost: -1 };
183
+ }
184
+ }
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // .gitignore reader
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Parse an ignore file (.gitignore or .trellisignore) and return normalized
193
+ * patterns. Strips comments, blank lines, and trailing slashes.
194
+ */
195
+ function parseIgnoreFile(filePath: string): string[] {
196
+ if (!existsSync(filePath)) return [];
197
+ try {
198
+ const content = readFileSync(filePath, 'utf-8');
199
+ return content
200
+ .split('\n')
201
+ .map((line) => line.trim())
202
+ .filter((line) => line.length > 0 && !line.startsWith('#'))
203
+ .map((line) => line.replace(/\/$/, '')); // strip trailing slash
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Read ignore patterns from both .gitignore and .trellisignore.
211
+ * .trellisignore allows ignoring paths that are tracked by Git but
212
+ * should not be tracked by TrellisVCS (e.g. source-linked dependencies).
213
+ */
214
+ function readIgnorePatterns(rootPath: string): string[] {
215
+ return [
216
+ ...parseIgnoreFile(join(rootPath, '.gitignore')),
217
+ ...parseIgnoreFile(join(rootPath, '.trellisignore')),
218
+ ];
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Config persistence
223
+ // ---------------------------------------------------------------------------
224
+
225
+ interface PersistedConfig {
226
+ rootPath: string;
227
+ ignorePatterns: string[];
228
+ debounceMs: number;
229
+ defaultBranch: string;
230
+ agentId: string;
231
+ createdAt: string;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Engine
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export class TrellisVcsEngine {
239
+ private config: TrellisVcsConfig;
240
+ private store: EAVStore;
241
+ private opLog: JsonOpLog;
242
+ private watcher: FileWatcher | null = null;
243
+ private ingestion: Ingestion | null = null;
244
+ private agentId: string;
245
+ private currentBranch: string = 'main';
246
+ private checkpointOpCount: number = 0;
247
+ private checkpointThreshold: number = 100;
248
+ private _pendingAutoCheckpoint: boolean = false;
249
+ private _blobStore: BlobStore | null = null;
250
+
251
+ constructor(
252
+ opts: { rootPath: string; agentId?: string } & Partial<TrellisVcsConfig>,
253
+ ) {
254
+ // Merge default ignore patterns with .gitignore if present
255
+ const gitignorePatterns = readIgnorePatterns(opts.rootPath);
256
+ const mergedIgnore = [
257
+ ...new Set([
258
+ ...(opts.ignorePatterns ?? DEFAULT_CONFIG.ignorePatterns),
259
+ ...gitignorePatterns,
260
+ ]),
261
+ ];
262
+
263
+ this.config = {
264
+ rootPath: opts.rootPath,
265
+ ignorePatterns: mergedIgnore,
266
+ debounceMs: opts.debounceMs ?? DEFAULT_CONFIG.debounceMs,
267
+ defaultBranch: opts.defaultBranch ?? DEFAULT_CONFIG.defaultBranch,
268
+ dbPath: opts.dbPath ?? DEFAULT_CONFIG.dbPath,
269
+ };
270
+ this.agentId = opts.agentId ?? `agent:${process.env.USER ?? 'unknown'}`;
271
+ this.store = new EAVStore();
272
+ this.opLog = new JsonOpLog(
273
+ join(this.config.rootPath, '.trellis', 'ops.json'),
274
+ );
275
+ }
276
+
277
+ // -------------------------------------------------------------------------
278
+ // Lifecycle
279
+ // -------------------------------------------------------------------------
280
+
281
+ /**
282
+ * Initialize a new TrellisVCS repo. Creates .trellis/ directory and config.
283
+ */
284
+ async initRepo(): Promise<{ opsCreated: number }> {
285
+ const trellisDir = join(this.config.rootPath, '.trellis');
286
+ if (!existsSync(trellisDir)) {
287
+ mkdirSync(trellisDir, { recursive: true });
288
+ }
289
+
290
+ // Initialize blob store
291
+ this._blobStore = new BlobStore(trellisDir);
292
+
293
+ // Write config
294
+ const configPath = join(trellisDir, 'config.json');
295
+ const persistedConfig: PersistedConfig = {
296
+ rootPath: this.config.rootPath,
297
+ ignorePatterns: this.config.ignorePatterns,
298
+ debounceMs: this.config.debounceMs,
299
+ defaultBranch: this.config.defaultBranch,
300
+ agentId: this.agentId,
301
+ createdAt: new Date().toISOString(),
302
+ };
303
+ writeFileSync(configPath, JSON.stringify(persistedConfig, null, 2));
304
+
305
+ // Load existing ops (empty for new repo)
306
+ this.opLog.load();
307
+
308
+ // Create initial branch op
309
+ const branchOp = await createVcsOp('vcs:branchCreate', {
310
+ agentId: this.agentId,
311
+ previousHash: this.opLog.getLastOp()?.hash,
312
+ vcs: {
313
+ branchName: this.config.defaultBranch,
314
+ },
315
+ });
316
+ this.applyOp(branchOp);
317
+
318
+ // Scan filesystem and create file-add ops for all existing files
319
+ const scanner = new FileWatcher({
320
+ rootPath: this.config.rootPath,
321
+ ignorePatterns: [...this.config.ignorePatterns, '.trellis'],
322
+ debounceMs: this.config.debounceMs,
323
+ onEvent: () => {},
324
+ });
325
+ const events = await scanner.scan();
326
+
327
+ let opsCreated = 1; // branch op
328
+ for (const event of events) {
329
+ // Store file content in blob store
330
+ if (event.contentHash) {
331
+ try {
332
+ const absPath = join(this.config.rootPath, event.path);
333
+ const content = await readFile(absPath);
334
+ await this._blobStore!.put(content);
335
+ } catch {}
336
+ }
337
+
338
+ const op = await createVcsOp('vcs:fileAdd', {
339
+ agentId: this.agentId,
340
+ previousHash: this.opLog.getLastOp()?.hash,
341
+ vcs: {
342
+ filePath: event.path,
343
+ contentHash: event.contentHash,
344
+ size: event.size,
345
+ },
346
+ });
347
+ this.applyOp(op);
348
+ opsCreated++;
349
+ }
350
+
351
+ await this.flushAutoCheckpoint();
352
+ return { opsCreated };
353
+ }
354
+
355
+ /**
356
+ * Open an existing TrellisVCS repo. Loads ops and replays into EAV store.
357
+ */
358
+ open(): { opsReplayed: number } {
359
+ this.opLog.load();
360
+
361
+ // Initialize blob store
362
+ const trellisDir = join(this.config.rootPath, '.trellis');
363
+ this._blobStore = new BlobStore(trellisDir);
364
+
365
+ // Load config
366
+ const configPath = join(this.config.rootPath, '.trellis', 'config.json');
367
+ if (existsSync(configPath)) {
368
+ const raw = readFileSync(configPath, 'utf-8');
369
+ const persisted: PersistedConfig = JSON.parse(raw);
370
+ this.agentId = persisted.agentId;
371
+ // Re-merge persisted patterns with .gitignore + .trellisignore
372
+ const filePatterns = readIgnorePatterns(this.config.rootPath);
373
+ this.config.ignorePatterns = [
374
+ ...new Set([...persisted.ignorePatterns, ...filePatterns]),
375
+ ];
376
+ this.config.debounceMs = persisted.debounceMs;
377
+ this.config.defaultBranch = persisted.defaultBranch;
378
+ }
379
+
380
+ // Load branch state
381
+ this.loadCurrentBranch();
382
+
383
+ // Replay all ops into the EAV store
384
+ const ops = this.opLog.readAll();
385
+ for (const op of ops) {
386
+ this.replayOp(op);
387
+ }
388
+
389
+ return { opsReplayed: ops.length };
390
+ }
391
+
392
+ /**
393
+ * Start watching the filesystem for changes.
394
+ */
395
+ watch(): void {
396
+ this.ingestion = new Ingestion({
397
+ agentId: this.agentId,
398
+ lastOpHash: this.opLog.getLastOp()?.hash,
399
+ onOp: (op) => this.applyOp(op),
400
+ });
401
+
402
+ this.watcher = new FileWatcher({
403
+ rootPath: this.config.rootPath,
404
+ ignorePatterns: [...this.config.ignorePatterns, '.trellis'],
405
+ debounceMs: this.config.debounceMs,
406
+ onEvent: async (event) => {
407
+ // Store blob for file adds/modifies
408
+ if (
409
+ (event.type === 'add' || event.type === 'modify') &&
410
+ event.contentHash &&
411
+ this._blobStore
412
+ ) {
413
+ try {
414
+ const absPath = join(this.config.rootPath, event.path);
415
+ const content = await readFile(absPath);
416
+ await this._blobStore.put(content);
417
+ } catch {}
418
+ }
419
+ await this.ingestion!.process(event);
420
+ },
421
+ });
422
+
423
+ // Scan to populate known files map, reconcile against op log for
424
+ // untracked files, then start watching for live changes.
425
+ this.watcher.scan().then(async (scanEvents) => {
426
+ // Build set of paths already tracked in the op log
427
+ const trackedPaths = new Set(this.trackedFiles().map((f) => f.path));
428
+
429
+ // Emit fileAdd ops for files on disk that aren't in the op log
430
+ for (const event of scanEvents) {
431
+ if (!trackedPaths.has(event.path)) {
432
+ // Store blob
433
+ if (event.contentHash && this._blobStore) {
434
+ try {
435
+ const absPath = join(this.config.rootPath, event.path);
436
+ const content = await readFile(absPath);
437
+ await this._blobStore.put(content);
438
+ } catch {}
439
+ }
440
+ await this.ingestion!.process(event);
441
+ }
442
+ }
443
+
444
+ this.watcher!.start();
445
+ });
446
+ }
447
+
448
+ /**
449
+ * Stop watching.
450
+ */
451
+ stop(): void {
452
+ this.watcher?.stop();
453
+ this.watcher = null;
454
+ this.ingestion = null;
455
+ }
456
+
457
+ // -------------------------------------------------------------------------
458
+ // Queries
459
+ // -------------------------------------------------------------------------
460
+
461
+ /**
462
+ * Returns all ops in the causal stream.
463
+ */
464
+ getOps(): VcsOp[] {
465
+ return this.opLog.readAll();
466
+ }
467
+
468
+ /**
469
+ * Returns the total number of ops.
470
+ */
471
+ getOpCount(): number {
472
+ return this.opLog.count();
473
+ }
474
+
475
+ /**
476
+ * Returns the EAV store for direct querying.
477
+ */
478
+ getStore(): EAVStore {
479
+ return this.store;
480
+ }
481
+
482
+ /**
483
+ * Returns the blob store for content retrieval.
484
+ */
485
+ getBlobStore(): BlobStore | null {
486
+ return this._blobStore;
487
+ }
488
+
489
+ /**
490
+ * Returns the current status: tracked files, last op, branch info.
491
+ */
492
+ status(): {
493
+ branch: string;
494
+ totalOps: number;
495
+ trackedFiles: number;
496
+ lastOp: VcsOp | undefined;
497
+ recentOps: VcsOp[];
498
+ } {
499
+ const ops = this.opLog.readAll();
500
+ const fileEntities = this.store
501
+ .getFactsByAttribute('type')
502
+ .filter((f) => f.v === 'FileNode');
503
+
504
+ return {
505
+ branch: this.currentBranch,
506
+ totalOps: ops.length,
507
+ trackedFiles: fileEntities.length,
508
+ lastOp: ops[ops.length - 1],
509
+ recentOps: ops.slice(-10),
510
+ };
511
+ }
512
+
513
+ /**
514
+ * Returns op history, optionally filtered by file path.
515
+ */
516
+ log(opts?: { limit?: number; filePath?: string }): VcsOp[] {
517
+ let ops = this.opLog.readAll();
518
+
519
+ if (opts?.filePath) {
520
+ ops = ops.filter((op) => {
521
+ const vcs = op.vcs;
522
+ return (
523
+ vcs?.filePath === opts.filePath || vcs?.oldFilePath === opts.filePath
524
+ );
525
+ });
526
+ }
527
+
528
+ if (opts?.limit) {
529
+ ops = ops.slice(-opts.limit);
530
+ }
531
+
532
+ return ops;
533
+ }
534
+
535
+ /**
536
+ * Returns all tracked file paths and their content hashes.
537
+ */
538
+ trackedFiles(): Array<{ path: string; contentHash: string | undefined }> {
539
+ const fileTypeFacts = this.store
540
+ .getFactsByAttribute('type')
541
+ .filter((f) => f.v === 'FileNode');
542
+
543
+ return fileTypeFacts.map((f) => {
544
+ const pathFacts = this.store
545
+ .getFactsByEntity(f.e)
546
+ .filter((ef) => ef.a === 'path');
547
+ const hashFacts = this.store
548
+ .getFactsByEntity(f.e)
549
+ .filter((ef) => ef.a === 'contentHash');
550
+ return {
551
+ path: (pathFacts[0]?.v as string) ?? f.e,
552
+ contentHash: hashFacts[0]?.v as string | undefined,
553
+ };
554
+ });
555
+ }
556
+
557
+ /**
558
+ * Returns the root path of the repository.
559
+ */
560
+ getRootPath(): string {
561
+ return this.config.rootPath;
562
+ }
563
+
564
+ /**
565
+ * Checks if a .trellis directory exists at the root path.
566
+ */
567
+ static isRepo(rootPath: string): boolean {
568
+ return existsSync(join(rootPath, '.trellis', 'config.json'));
569
+ }
570
+
571
+ static repair(rootPath: string): { recovered: number; lost: number } {
572
+ const opsPath = join(rootPath, '.trellis', 'ops.json');
573
+ return JsonOpLog.repair(opsPath);
574
+ }
575
+
576
+ // -------------------------------------------------------------------------
577
+ // Branch Management (delegated to src/vcs/branch.ts)
578
+ // -------------------------------------------------------------------------
579
+
580
+ async createBranch(name: string): Promise<VcsOp> {
581
+ const op = await branchMod.createBranch(
582
+ this._ctx(),
583
+ name,
584
+ this.currentBranch,
585
+ );
586
+ await this.flushAutoCheckpoint();
587
+ return op;
588
+ }
589
+
590
+ switchBranch(name: string): void {
591
+ branchMod.switchBranch(this._ctx(), name);
592
+ this.currentBranch = name;
593
+ branchMod.saveBranchState(this.config.rootPath, { currentBranch: name });
594
+ }
595
+
596
+ listBranches(): branchMod.BranchInfo[] {
597
+ return branchMod.listBranches(this._ctx(), this.currentBranch);
598
+ }
599
+
600
+ async deleteBranch(name: string): Promise<VcsOp> {
601
+ const op = await branchMod.deleteBranch(
602
+ this._ctx(),
603
+ name,
604
+ this.currentBranch,
605
+ );
606
+ await this.flushAutoCheckpoint();
607
+ return op;
608
+ }
609
+
610
+ getCurrentBranch(): string {
611
+ return this.currentBranch;
612
+ }
613
+
614
+ // -------------------------------------------------------------------------
615
+ // Milestones (delegated to src/vcs/milestone.ts)
616
+ // -------------------------------------------------------------------------
617
+
618
+ async createMilestone(
619
+ message: string,
620
+ opts?: { fromOpHash?: string; toOpHash?: string },
621
+ ): Promise<VcsOp> {
622
+ const op = await milestoneMod.createMilestone(this._ctx(), message, opts);
623
+ await this.flushAutoCheckpoint();
624
+ return op;
625
+ }
626
+
627
+ listMilestones(): milestoneMod.MilestoneInfo[] {
628
+ return milestoneMod.listMilestones(this._ctx());
629
+ }
630
+
631
+ // -------------------------------------------------------------------------
632
+ // Checkpoints (delegated to src/vcs/checkpoint.ts)
633
+ // -------------------------------------------------------------------------
634
+
635
+ async createCheckpoint(
636
+ trigger: checkpointMod.CheckpointTrigger = 'manual',
637
+ ): Promise<VcsOp> {
638
+ const op = await checkpointMod.createCheckpoint(this._ctx(), trigger);
639
+ this.checkpointOpCount = 0;
640
+ return op;
641
+ }
642
+
643
+ listCheckpoints(): checkpointMod.CheckpointInfo[] {
644
+ return checkpointMod.listCheckpoints(this._ctx());
645
+ }
646
+
647
+ setCheckpointThreshold(threshold: number): void {
648
+ this.checkpointThreshold = threshold;
649
+ }
650
+
651
+ // -------------------------------------------------------------------------
652
+ // Diff & Merge (delegated to src/vcs/diff.ts, src/vcs/merge.ts)
653
+ // -------------------------------------------------------------------------
654
+
655
+ /**
656
+ * Diff two branches by comparing their file states.
657
+ */
658
+ diffBranches(branchA: string, branchB: string): diffMod.DiffResult {
659
+ const ops = this.opLog.readAll();
660
+ // Build file state for each branch by walking all ops
661
+ // (branch-scoped filtering comes later; for now, single linear stream)
662
+ const stateA = diffMod.buildFileStateAtOp(ops);
663
+ const stateB = diffMod.buildFileStateAtOp(ops);
664
+ return diffMod.diffFileStates(stateA, stateB, this._blobStore);
665
+ }
666
+
667
+ /**
668
+ * Diff between two op hashes in the causal stream.
669
+ */
670
+ diffOps(fromHash: string, toHash: string): diffMod.DiffResult {
671
+ return diffMod.diffOpRange(
672
+ this.opLog.readAll(),
673
+ fromHash,
674
+ toHash,
675
+ this._blobStore,
676
+ );
677
+ }
678
+
679
+ /**
680
+ * Diff the current state against a specific op hash (e.g. a milestone).
681
+ */
682
+ diffFromOp(opHash: string): diffMod.DiffResult {
683
+ const ops = this.opLog.readAll();
684
+ const stateA = diffMod.buildFileStateAtOp(ops, opHash);
685
+ const stateB = diffMod.buildFileStateAtOp(ops);
686
+ return diffMod.diffFileStates(stateA, stateB, this._blobStore);
687
+ }
688
+
689
+ /**
690
+ * Three-way merge: merge source branch state into current branch state.
691
+ * Uses the fork-point (branch creation op) as the common ancestor.
692
+ */
693
+ mergeBranch(sourceBranch: string): mergeMod.MergeResult {
694
+ const ops = this.opLog.readAll();
695
+
696
+ // Find the branch creation op to determine fork point
697
+ const branchOp = ops.find(
698
+ (o) =>
699
+ o.kind === 'vcs:branchCreate' && o.vcs?.branchName === sourceBranch,
700
+ );
701
+ const forkHash = branchOp?.vcs?.targetOpHash;
702
+
703
+ // Build three states
704
+ const base = forkHash
705
+ ? diffMod.buildFileStateAtOp(ops, forkHash)
706
+ : new Map<string, diffMod.FileState>();
707
+ const ours = diffMod.buildFileStateAtOp(ops); // current full state
708
+ const theirs = diffMod.buildFileStateAtOp(ops); // same stream for now
709
+
710
+ return mergeMod.threeWayMerge(base, ours, theirs, this._blobStore);
711
+ }
712
+
713
+ // -------------------------------------------------------------------------
714
+ // Semantic Parsing (delegated to src/semantic/)
715
+ // -------------------------------------------------------------------------
716
+
717
+ private _parsers: ParserAdapter[] = [
718
+ typescriptParser,
719
+ pythonParser,
720
+ goParser,
721
+ rustParser,
722
+ rubyParser,
723
+ javaParser,
724
+ csharpParser,
725
+ ];
726
+
727
+ /**
728
+ * Parse a file's content into AST-level entities.
729
+ */
730
+ parseFile(content: string, filePath: string): ParseResult | null {
731
+ const ext = filePath.split('.').pop() ?? '';
732
+ const parser = this._parsers.find((p) =>
733
+ p.languages.some((lang) => {
734
+ if (lang === 'typescript') return ext === 'ts';
735
+ if (lang === 'javascript')
736
+ return ext === 'js' || ext === 'mjs' || ext === 'cjs';
737
+ if (lang === 'tsx') return ext === 'tsx';
738
+ if (lang === 'jsx') return ext === 'jsx';
739
+ if (lang === 'python') return ext === 'py' || ext === 'pyi';
740
+ if (lang === 'go') return ext === 'go';
741
+ if (lang === 'rust') return ext === 'rs';
742
+ if (lang === 'ruby') return ext === 'rb';
743
+ if (lang === 'java') return ext === 'java';
744
+ if (lang === 'csharp') return ext === 'cs';
745
+ return false;
746
+ }),
747
+ );
748
+ if (!parser) return null;
749
+ return parser.parse(content, filePath);
750
+ }
751
+
752
+ /**
753
+ * Compute semantic diff between two versions of a file.
754
+ */
755
+ semanticDiff(
756
+ oldContent: string,
757
+ newContent: string,
758
+ filePath: string,
759
+ ): SemanticPatch[] {
760
+ const parser = this._parsers.find((p) =>
761
+ p.languages.some((lang) => {
762
+ const ext = filePath.split('.').pop() ?? '';
763
+ if (lang === 'typescript') return ext === 'ts';
764
+ if (lang === 'javascript')
765
+ return ext === 'js' || ext === 'mjs' || ext === 'cjs';
766
+ if (lang === 'tsx') return ext === 'tsx';
767
+ if (lang === 'jsx') return ext === 'jsx';
768
+ if (lang === 'python') return ext === 'py' || ext === 'pyi';
769
+ if (lang === 'go') return ext === 'go';
770
+ if (lang === 'rust') return ext === 'rs';
771
+ if (lang === 'ruby') return ext === 'rb';
772
+ if (lang === 'java') return ext === 'java';
773
+ if (lang === 'csharp') return ext === 'cs';
774
+ return false;
775
+ }),
776
+ );
777
+ if (!parser) return [];
778
+ const oldResult = parser.parse(oldContent, filePath);
779
+ const newResult = parser.parse(newContent, filePath);
780
+ return parser.diff(oldResult, newResult);
781
+ }
782
+
783
+ // -------------------------------------------------------------------------
784
+ // Idea Garden (delegated to src/garden/)
785
+ // -------------------------------------------------------------------------
786
+
787
+ private _garden: IdeaGarden | null = null;
788
+
789
+ /**
790
+ * Get the Idea Garden instance for exploring abandoned work.
791
+ */
792
+ garden(): IdeaGarden {
793
+ if (!this._garden) {
794
+ this._garden = new IdeaGarden({
795
+ readAllOps: () => this.opLog.readAll(),
796
+ getMilestonedOpHashes: () =>
797
+ buildMilestonedOpHashes(this.opLog.readAll()),
798
+ });
799
+ }
800
+ return this._garden;
801
+ }
802
+
803
+ // -------------------------------------------------------------------------
804
+ // Issue Management (delegated to src/vcs/issue.ts)
805
+ // -------------------------------------------------------------------------
806
+
807
+ async createIssue(
808
+ title: string,
809
+ opts?: {
810
+ priority?: 'critical' | 'high' | 'medium' | 'low';
811
+ labels?: string[];
812
+ assignee?: string;
813
+ parentId?: string;
814
+ description?: string;
815
+ status?: 'backlog' | 'queue';
816
+ criteria?: Array<{ description: string; command?: string }>;
817
+ },
818
+ ): Promise<VcsOp> {
819
+ const op = await issueMod.createIssue(
820
+ this._ctx(),
821
+ this.config.rootPath,
822
+ title,
823
+ opts,
824
+ );
825
+ await this.flushAutoCheckpoint();
826
+ return op;
827
+ }
828
+
829
+ async updateIssue(
830
+ id: string,
831
+ updates: {
832
+ title?: string;
833
+ description?: string;
834
+ priority?: 'critical' | 'high' | 'medium' | 'low';
835
+ labels?: string[];
836
+ assignee?: string;
837
+ status?: 'backlog' | 'queue' | 'in_progress' | 'paused' | 'closed';
838
+ },
839
+ ): Promise<VcsOp> {
840
+ const op = await issueMod.updateIssue(this._ctx(), id, updates);
841
+ await this.flushAutoCheckpoint();
842
+ return op;
843
+ }
844
+
845
+ async startIssue(id: string): Promise<VcsOp> {
846
+ const issue = issueMod.getIssue(this._ctx(), id);
847
+ if (!issue) throw new Error(`Issue ${id} not found.`);
848
+
849
+ const slug = (issue.title ?? id)
850
+ .toLowerCase()
851
+ .replace(/[^a-z0-9]+/g, '-')
852
+ .replace(/^-|-$/g, '')
853
+ .slice(0, 40);
854
+ const branchName = `issue/${id}-${slug}`;
855
+
856
+ // Create the branch
857
+ await this.createBranch(branchName);
858
+
859
+ // Emit the issueStart op
860
+ const op = await issueMod.startIssue(this._ctx(), id, branchName);
861
+
862
+ // Switch to the branch
863
+ this.switchBranch(branchName);
864
+
865
+ await this.flushAutoCheckpoint();
866
+ return op;
867
+ }
868
+
869
+ async pauseIssue(id: string, note: string): Promise<VcsOp> {
870
+ const op = await issueMod.pauseIssue(this._ctx(), id, note);
871
+
872
+ // Switch back to default branch
873
+ this.switchBranch(this.config.defaultBranch);
874
+
875
+ await this.flushAutoCheckpoint();
876
+ return op;
877
+ }
878
+
879
+ async resumeIssue(id: string): Promise<VcsOp> {
880
+ const issue = issueMod.getIssue(this._ctx(), id);
881
+ if (!issue) throw new Error(`Issue ${id} not found.`);
882
+ if (!issue.branchName)
883
+ throw new Error(`Issue ${id} has no tracked branch.`);
884
+
885
+ const op = await issueMod.resumeIssue(this._ctx(), id);
886
+
887
+ // Switch to the issue branch
888
+ this.switchBranch(issue.branchName);
889
+
890
+ await this.flushAutoCheckpoint();
891
+ return op;
892
+ }
893
+
894
+ async closeIssue(
895
+ id: string,
896
+ opts?: { confirm?: boolean },
897
+ ): Promise<{ op?: VcsOp; criteriaResults: issueMod.CriterionResult[] }> {
898
+ const result = await issueMod.closeIssue(this._ctx(), id, opts);
899
+ if (result.op) {
900
+ await this.flushAutoCheckpoint();
901
+ }
902
+ return result;
903
+ }
904
+
905
+ async triageIssue(id: string): Promise<VcsOp> {
906
+ const op = await issueMod.triageIssue(this._ctx(), id);
907
+ await this.flushAutoCheckpoint();
908
+ return op;
909
+ }
910
+
911
+ async reopenIssue(id: string): Promise<VcsOp> {
912
+ const op = await issueMod.reopenIssue(this._ctx(), id);
913
+ await this.flushAutoCheckpoint();
914
+ return op;
915
+ }
916
+
917
+ checkCompletionReadiness(): issueMod.CompletionReadiness {
918
+ return issueMod.checkCompletionReadiness(this._ctx());
919
+ }
920
+
921
+ async assignIssue(id: string, agentId: string): Promise<VcsOp> {
922
+ const op = await issueMod.assignIssue(this._ctx(), id, agentId);
923
+ await this.flushAutoCheckpoint();
924
+ return op;
925
+ }
926
+
927
+ async blockIssue(id: string, blockedById: string): Promise<VcsOp> {
928
+ const op = await issueMod.blockIssue(this._ctx(), id, blockedById);
929
+ await this.flushAutoCheckpoint();
930
+ return op;
931
+ }
932
+
933
+ async unblockIssue(id: string, blockedById: string): Promise<VcsOp> {
934
+ const op = await issueMod.unblockIssue(this._ctx(), id, blockedById);
935
+ await this.flushAutoCheckpoint();
936
+ return op;
937
+ }
938
+
939
+ async addCriterion(
940
+ issueId: string,
941
+ description: string,
942
+ command?: string,
943
+ ): Promise<VcsOp> {
944
+ const op = await issueMod.addCriterion(
945
+ this._ctx(),
946
+ issueId,
947
+ description,
948
+ command,
949
+ );
950
+ await this.flushAutoCheckpoint();
951
+ return op;
952
+ }
953
+
954
+ async setCriterionStatus(
955
+ issueId: string,
956
+ criterionIndex: number,
957
+ status: 'passed' | 'failed' | 'pending',
958
+ ): Promise<VcsOp> {
959
+ const op = await issueMod.setCriterionStatus(
960
+ this._ctx(),
961
+ issueId,
962
+ criterionIndex,
963
+ status,
964
+ );
965
+ await this.flushAutoCheckpoint();
966
+ return op;
967
+ }
968
+
969
+ async runCriteria(issueId: string): Promise<issueMod.CriterionResult[]> {
970
+ return issueMod.runCriteria(this._ctx(), issueId, this.config.rootPath);
971
+ }
972
+
973
+ listIssues(filters?: issueMod.IssueFilters): issueMod.IssueInfo[] {
974
+ return issueMod.listIssues(this._ctx(), filters);
975
+ }
976
+
977
+ getIssue(id: string): issueMod.IssueInfo | null {
978
+ return issueMod.getIssue(this._ctx(), id);
979
+ }
980
+
981
+ getActiveIssues(): issueMod.IssueInfo[] {
982
+ return issueMod.getActiveIssues(this._ctx());
983
+ }
984
+
985
+ // -------------------------------------------------------------------------
986
+ // Decision Traces
987
+ // -------------------------------------------------------------------------
988
+
989
+ async recordDecision(input: decisionMod.DecisionInput): Promise<VcsOp> {
990
+ const op = await decisionMod.recordDecision(
991
+ this._ctx(),
992
+ this.config.rootPath,
993
+ input,
994
+ );
995
+ await this.flushAutoCheckpoint();
996
+ return op;
997
+ }
998
+
999
+ queryDecisions(filter?: decisionMod.DecisionFilter): decisionMod.Decision[] {
1000
+ return decisionMod.queryDecisions(this._ctx(), filter);
1001
+ }
1002
+
1003
+ getDecisionChain(entityId: string): decisionMod.Decision[] {
1004
+ return decisionMod.getDecisionChain(this._ctx(), entityId);
1005
+ }
1006
+
1007
+ getDecision(id: string): decisionMod.Decision | null {
1008
+ return decisionMod.getDecision(this._ctx(), id);
1009
+ }
1010
+
1011
+ // -------------------------------------------------------------------------
1012
+ // Internal
1013
+ // -------------------------------------------------------------------------
1014
+
1015
+ private _ctx(): EngineContext {
1016
+ return {
1017
+ store: this.store,
1018
+ agentId: this.agentId,
1019
+ readAllOps: () => this.opLog.readAll(),
1020
+ getLastOp: () => this.opLog.getLastOp(),
1021
+ applyOp: (op) => this.applyOp(op),
1022
+ };
1023
+ }
1024
+
1025
+ private applyOp(op: VcsOp): void {
1026
+ // Decompose VCS op into EAV primitives and apply to store
1027
+ const decomposed = decompose(op);
1028
+
1029
+ if (decomposed.deleteFacts.length > 0) {
1030
+ this.store.deleteFacts(decomposed.deleteFacts);
1031
+ }
1032
+ if (decomposed.deleteLinks.length > 0) {
1033
+ this.store.deleteLinks(decomposed.deleteLinks);
1034
+ }
1035
+ if (decomposed.addFacts.length > 0) {
1036
+ this.store.addFacts(decomposed.addFacts);
1037
+ }
1038
+ if (decomposed.addLinks.length > 0) {
1039
+ this.store.addLinks(decomposed.addLinks);
1040
+ }
1041
+
1042
+ // Persist to op log
1043
+ this.opLog.append(op);
1044
+
1045
+ // Auto-checkpoint logic: set flag, flushed by public async callers
1046
+ if (op.kind !== 'vcs:checkpointCreate' && this.checkpointThreshold > 0) {
1047
+ this.checkpointOpCount++;
1048
+ if (this.checkpointOpCount >= this.checkpointThreshold) {
1049
+ this._pendingAutoCheckpoint = true;
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ private async flushAutoCheckpoint(): Promise<void> {
1055
+ if (this._pendingAutoCheckpoint) {
1056
+ this._pendingAutoCheckpoint = false;
1057
+ await this.createCheckpoint('op-count');
1058
+ }
1059
+ }
1060
+
1061
+ private loadCurrentBranch(): void {
1062
+ const state = branchMod.loadBranchState(this.config.rootPath);
1063
+ this.currentBranch = state.currentBranch;
1064
+ }
1065
+
1066
+ private replayOp(op: VcsOp): void {
1067
+ // Same as applyOp but doesn't persist (ops are already in the log)
1068
+ const decomposed = decompose(op);
1069
+
1070
+ if (decomposed.deleteFacts.length > 0) {
1071
+ this.store.deleteFacts(decomposed.deleteFacts);
1072
+ }
1073
+ if (decomposed.deleteLinks.length > 0) {
1074
+ this.store.deleteLinks(decomposed.deleteLinks);
1075
+ }
1076
+ if (decomposed.addFacts.length > 0) {
1077
+ this.store.addFacts(decomposed.addFacts);
1078
+ }
1079
+ if (decomposed.addLinks.length > 0) {
1080
+ this.store.addLinks(decomposed.addLinks);
1081
+ }
1082
+ }
1083
+ }